Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion backend/src/routes/api/__tests__/filesApi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import FormData from "form-data";
import filesRouter from "../files.js";
import StoredFile from "../../../db/models/StoredFile.js";
import CollectionGroup from "../../../db/models/CollectionGroup.js";
import Scenario from "../../../db/models/scenario.js";
import auth from "../../../middleware/firebaseAuth.js";
import errorHandler from "../../../middleware/errorHandler.js";

Expand Down Expand Up @@ -58,11 +59,17 @@ describe("Files API tests", () => {
return app;
});

const scenarioId = new mongoose.mongo.ObjectId("ccc000000000000000000001");
let scenarioId;
let collectionGroup;
let storedFile;

beforeEach(async () => {
const scenario = await Scenario.create({
name: "Test Scenario",
uid: "user1",
});
scenarioId = scenario._id;

collectionGroup = await CollectionGroup.create({
scenarioId,
name: "Test Group",
Expand Down Expand Up @@ -200,6 +207,47 @@ describe("Files API tests", () => {
).rejects.toMatchObject({ response: { status: 404 } });
});

it("DELETE /files/:fileId returns 403 when caller does not own the scenario", async () => {
await expect(
axios.delete(
`http://localhost:${ctx.port}/api/files/${storedFile._id}`,
authHeaders("stranger")
)
).rejects.toMatchObject({ response: { status: 403 } });
});

// --- Rename file ---

it("PATCH /files/:fileId renames the file", async () => {
const response = await axios.patch(
`http://localhost:${ctx.port}/api/files/${storedFile._id}`,
{ name: "renamed.png" },
authHeaders("user1")
);
expect(response.status).toBe(200);
expect(response.data.name).toBe("renamed.png");
});

it("PATCH /files/:fileId returns 400 when name is empty", async () => {
await expect(
axios.patch(
`http://localhost:${ctx.port}/api/files/${storedFile._id}`,
{ name: " " },
authHeaders("user1")
)
).rejects.toMatchObject({ response: { status: 400 } });
});

it("PATCH /files/:fileId returns 403 when caller does not own the scenario", async () => {
await expect(
axios.patch(
`http://localhost:${ctx.port}/api/files/${storedFile._id}`,
{ name: "renamed.png" },
authHeaders("stranger")
)
).rejects.toMatchObject({ response: { status: 403 } });
});

// --- State conditionals ---

it("POST /files/state-conditionals/:fileId adds a state conditional", async () => {
Expand All @@ -219,6 +267,22 @@ describe("Files API tests", () => {
expect(response.data.stateConditionals[0].stateVariableId).toBe("var-1");
});

it("POST /files/state-conditionals/:fileId returns 403 when caller does not own the scenario", async () => {
await expect(
axios.post(
`http://localhost:${ctx.port}/api/files/state-conditionals/${storedFile._id}`,
{
stateConditional: {
stateVariableId: "var-1",
comparator: "=",
value: "open",
},
},
authHeaders("stranger")
)
).rejects.toMatchObject({ response: { status: 403 } });
});

it("POST /files/state-conditionals/:fileId returns 404 for unknown file", async () => {
await expect(
axios.post(
Expand Down
69 changes: 55 additions & 14 deletions backend/src/routes/api/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Router } from "express";
import mongoose from "mongoose";
import multer from "multer";
import auth from "../../middleware/firebaseAuth.js";
import { isAuthor } from "../../middleware/scenarioAuth.js";
import CollectionGroup from "../../db/models/CollectionGroup.js";
import StoredFile from "../../db/models/StoredFile.js";
import {
Expand Down Expand Up @@ -81,6 +82,21 @@ async function assertGroupInScenario({ scenarioId, groupId }) {
}
}

/**
* Load a stored file and verify the caller has access to its scenario.
* Throws an Error tagged with .status (404/403) for the route's catch block.
*/
async function loadAuthorizedFile(fileId, uid) {
const meta = await StoredFile.findById(fileId);
if (!meta) {
throw Object.assign(new Error("File not found"), { status: 404 });
}
if (!(await isAuthor(meta.scenarioId, uid))) {
throw Object.assign(new Error("Forbidden"), { status: 403 });
}
return meta;
}

/**
* @route POST /api/files/upload
* @desc Upload one or more files to a group within a scenario
Expand Down Expand Up @@ -145,22 +161,49 @@ router.post("/upload", upload.array("files"), async (req, res) => {
}
});

/**
* @route PATCH /api/files/:fileId
* @desc Update the name of a stored file
*/
router.patch("/:fileId", async (req, res) => {
try {
const { fileId } = req.params;
const { name, uid } = req.body;
if (typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "Name is required" });
}
if (name.trim().length > 255) {
return res
.status(400)
.json({ error: "Name must be 255 characters or fewer" });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const meta = await loadAuthorizedFile(fileId, uid);
meta.name = name.trim();
await meta.save();
const file = meta.toObject();
delete file.gridFsId;
return res.json(file);
} catch (err) {
return res.status(err.status || 500).json({ error: err.message });
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* @route DELETE /api/files/:fileId
* @desc Delete a stored file and its GridFS data
*/
router.delete("/:fileId", async (req, res) => {
try {
const { fileId } = req.params;
const meta = await StoredFile.findById(fileId);
if (!meta) return res.status(404).json({ error: "File not found" });
const { uid } = req.body;
const meta = await loadAuthorizedFile(fileId, uid);

await deleteGridFsById(meta.gridFsId);
await meta.deleteOne();

return res.json({ deleted: 1 });
} catch (err) {
return res.status(500).json({ error: err.message });
return res.status(err.status || 500).json({ error: err.message });
}
});

Expand All @@ -171,16 +214,15 @@ router.delete("/:fileId", async (req, res) => {
router.post("/state-conditionals/:fileId", async (req, res) => {
try {
const { fileId } = req.params;
const { stateConditional } = req.body;
const meta = await StoredFile.findById(fileId);
if (!meta) return res.status(404).json({ error: "File not found" });
const { stateConditional, uid } = req.body;
const meta = await loadAuthorizedFile(fileId, uid);
meta.stateConditionals.push(stateConditional);
await meta.save();
const file = meta.toObject();
delete file.gridFsId;
return res.json(file);
} catch (err) {
return res.status(500).json({ error: err.message });
return res.status(err.status || 500).json({ error: err.message });
}
});

Expand All @@ -191,9 +233,8 @@ router.post("/state-conditionals/:fileId", async (req, res) => {
router.put("/state-conditionals/:fileId", async (req, res) => {
try {
const { fileId } = req.params;
const { stateConditional } = req.body;
const meta = await StoredFile.findById(fileId);
if (!meta) return res.status(404).json({ error: "File not found" });
const { stateConditional, uid } = req.body;
const meta = await loadAuthorizedFile(fileId, uid);

meta.stateConditionals = meta.stateConditionals.map((sc) =>
sc._id.toString() === stateConditional._id ? stateConditional : sc
Expand All @@ -204,7 +245,7 @@ router.put("/state-conditionals/:fileId", async (req, res) => {
delete file.gridFsId;
return res.json(file);
} catch (err) {
return res.status(500).json({ error: err.message });
return res.status(err.status || 500).json({ error: err.message });
}
});

Expand All @@ -217,8 +258,8 @@ router.delete(
async (req, res) => {
try {
const { fileId, stateConditionalId } = req.params;
const meta = await StoredFile.findById(fileId);
if (!meta) return res.status(404).json({ error: "File not found" });
const { uid } = req.body;
const meta = await loadAuthorizedFile(fileId, uid);

const originalLength = meta.stateConditionals.length;
meta.stateConditionals = meta.stateConditionals.filter(
Expand All @@ -234,7 +275,7 @@ router.delete(
delete file.gridFsId;
return res.json(file);
} catch (err) {
return res.status(500).json({ error: err.message });
return res.status(err.status || 500).json({ error: err.message });
}
}
);
Expand Down
Loading
Loading