diff --git a/backend/src/routes/api/__tests__/filesApi.test.js b/backend/src/routes/api/__tests__/filesApi.test.js index 03147db6c..8157242e4 100644 --- a/backend/src/routes/api/__tests__/filesApi.test.js +++ b/backend/src/routes/api/__tests__/filesApi.test.js @@ -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"; @@ -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", @@ -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 () => { @@ -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( diff --git a/backend/src/routes/api/files.js b/backend/src/routes/api/files.js index 472e52603..4ec2697b1 100644 --- a/backend/src/routes/api/files.js +++ b/backend/src/routes/api/files.js @@ -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 { @@ -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 @@ -145,6 +161,33 @@ 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" }); + } + 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 }); + } +}); + /** * @route DELETE /api/files/:fileId * @desc Delete a stored file and its GridFS data @@ -152,15 +195,15 @@ router.post("/upload", upload.array("files"), async (req, res) => { 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 }); } }); @@ -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 }); } }); @@ -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 @@ -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 }); } }); @@ -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( @@ -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 }); } } ); diff --git a/frontend/src/features/resources/ManageResourcesPage.jsx b/frontend/src/features/resources/ManageResourcesPage.jsx index 071966f9d..744b1f297 100644 --- a/frontend/src/features/resources/ManageResourcesPage.jsx +++ b/frontend/src/features/resources/ManageResourcesPage.jsx @@ -10,6 +10,8 @@ import { UsersIcon, PlusIcon, XIcon, + PencilIcon, + CheckIcon, } from "lucide-react"; import AddGroup from "./components/AddGroup"; import StateConditionalMenu from "../../components/StateVariables/StateConditionalMenu"; @@ -65,6 +67,8 @@ export default function ManageResourcesPage() { const [groups, setGroups] = useState([]); const [selectedGroup, setSelectedGroup] = useState(null); const [selectedFile, setSelectedFile] = useState(null); + const [renamingFileId, setRenamingFileId] = useState(null); + const [renameInput, setRenameInput] = useState(""); // Load groups and files useEffect(() => { @@ -101,10 +105,6 @@ export default function ManageResourcesPage() { async function addFilesTo(groupId, files) { try { const user = getAuth().currentUser; - if (!user) { - toast.error("You must be logged in to upload."); - return; - } const idToken = await user.getIdToken(); const fd = new FormData(); @@ -147,13 +147,57 @@ export default function ManageResourcesPage() { } } + async function renameFile(fileId, newName) { + try { + const user = getAuth().currentUser; + const idToken = await user.getIdToken(); + const { data } = await axios.patch( + `/api/files/${fileId}`, + { name: newName }, + { headers: { Authorization: `Bearer ${idToken}` } } + ); + + setGroups((prev) => + prev.map((g) => ({ + ...g, + files: (g.files || []).map((f) => + f.id === fileId ? { ...f, name: data.name } : f + ), + })) + ); + + setSelectedFile((prev) => + prev?.id === fileId ? { ...prev, name: data.name } : prev + ); + + toast.success("Renamed"); + return true; + } catch (err) { + console.error(err); + toast.error(err?.response?.data?.error || "Rename failed"); + return false; + } + } + + async function handleRenameSubmit() { + const trimmed = renameInput.trim(); + if (!trimmed) { + toast.error("Name cannot be empty"); + return; + } + const current = groups + .flatMap((g) => g.files) + .find((f) => f.id === renamingFileId); + if (current && trimmed !== current.name) { + const ok = await renameFile(renamingFileId, trimmed); + if (!ok) return; + } + setRenamingFileId(null); + } + async function removeFile(fileId) { try { const user = getAuth().currentUser; - if (!user) { - toast.error("You must be logged in to delete."); - return; - } const idToken = await user.getIdToken(); await axios.delete(`/api/files/${fileId}`, { headers: { Authorization: `Bearer ${idToken}` }, @@ -181,10 +225,6 @@ export default function ManageResourcesPage() { if (!ok) return; try { const user = getAuth().currentUser; - if (!user) { - toast.error("You must be logged in to delete."); - return; - } const idToken = await user.getIdToken(); await axios.delete(`/api/collections/groups/${groupId}`, { headers: { Authorization: `Bearer ${idToken}` }, @@ -279,7 +319,7 @@ export default function ManageResourcesPage() {