diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..4098d74 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,58 @@ +name: Audit + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + +jobs: + api-audit: + name: API audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: api/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - run: pnpm audit + + web-audit: + name: Web audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - run: pnpm audit diff --git a/audit.sh b/audit.sh new file mode 100755 index 0000000..1c3b661 --- /dev/null +++ b/audit.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -e + +ROOT="$(cd "$(dirname "$0")" && pwd)" +FAILED=0 + +run() { + echo "" + echo "=== $1 ===" + shift + if ! "$@"; then + FAILED=1 + fi +} + +run "API audit" bash -c "cd '$ROOT/api' && pnpm install --frozen-lockfile && pnpm audit" +run "Web audit" bash -c "cd '$ROOT/web' && pnpm install --frozen-lockfile && pnpm audit" + +echo "" +if [[ $FAILED -eq 0 ]]; then + echo "No vulnerabilities found." +else + echo "One or more audits found vulnerabilities." + exit 1 +fi diff --git a/web/e2e/editor-markdown-csv.spec.ts b/web/e2e/editor-markdown-csv.spec.ts new file mode 100644 index 0000000..4a94e50 --- /dev/null +++ b/web/e2e/editor-markdown-csv.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, type Page } from "@playwright/test"; + +const PROJECT = { + id: "proj-mc1", + name: "Markdown & CSV Project", + main_path: "main.tex", + shell_escape: false, + engine: "auto", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-06-01T10:00:00Z", +}; + +const FILES = [ + { + path: "main.tex", + content: "\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}", + is_binary: false, + updated_at: "2024-06-01T10:00:00Z", + }, + { + path: "notes.md", + content: "# Title\n\nSome **bold** text and a [link](https://example.com).", + is_binary: false, + updated_at: "2024-06-01T09:30:00Z", + }, + { + path: "data.csv", + content: "name,age\nAlice,30\nBob,25", + is_binary: false, + updated_at: "2024-06-01T09:00:00Z", + }, +]; + +async function mockEditorApi(page: Page) { + await page.route("/api/projects", async (route) => { + await route.fulfill({ json: [PROJECT] }); + }); + + await page.route("/api/projects/proj-mc1/files", async (route) => { + await route.fulfill({ json: FILES }); + }); + + await page.route("/api/projects/proj-mc1/files/**", async (route) => { + if (route.request().method() === "PUT") { + await route.fulfill({ status: 200, body: "" }); + } else if (route.request().method() === "DELETE") { + await route.fulfill({ status: 204, body: "" }); + } + }); + + await page.route("/api/projects/proj-mc1", async (route) => { + const body = route.request().postDataJSON(); + await route.fulfill({ json: { ...PROJECT, ...body } }); + }); + + await page.route("/api/projects/proj-mc1/compile", async (route) => { + await route.fulfill({ json: { ok: true, log: "Output written on main.pdf." } }); + }); + + await page.route("/api/projects/proj-mc1/pdf**", async (route) => { + await route.fulfill({ status: 200, body: "%PDF-1.4 mock" }); + }); +} + +test.describe("Editor — Markdown preview", () => { + test("shows rendered markdown preview by default when opening a .md file", async ({ page }) => { + await mockEditorApi(page); + await page.goto("/p/proj-mc1"); + + await page.locator("button", { hasText: "notes.md" }).click(); + + await expect(page.getByRole("heading", { name: "Title" })).toBeVisible(); + await expect(page.locator(".markdown-preview strong", { hasText: "bold" })).toBeVisible(); + await expect(page.getByRole("link", { name: "link" })).toHaveAttribute( + "href", + "https://example.com" + ); + }); + + test("toggles between rendered preview and raw source", async ({ page }) => { + await mockEditorApi(page); + await page.goto("/p/proj-mc1"); + + await page.locator("button", { hasText: "notes.md" }).click(); + await expect(page.getByRole("heading", { name: "Title" })).toBeVisible(); + + await page.getByRole("button", { name: "Hide preview" }).click(); + await expect(page.getByRole("heading", { name: "Title" })).not.toBeVisible(); + + await page.getByRole("button", { name: "Show preview" }).click(); + await expect(page.getByRole("heading", { name: "Title" })).toBeVisible(); + }); + + test("does not show a preview toggle for non-markdown files", async ({ page }) => { + await mockEditorApi(page); + await page.goto("/p/proj-mc1"); + + await expect(page.getByRole("button", { name: /preview/i })).toHaveCount(0); + }); +}); + +test.describe("Editor — CSV editing", () => { + test("renders CSV content as an editable table", async ({ page }) => { + await mockEditorApi(page); + await page.goto("/p/proj-mc1"); + + await page.locator("button", { hasText: "data.csv" }).click(); + + await expect(page.locator("input[value='name']")).toBeVisible(); + await expect(page.locator("input[value='Alice']")).toBeVisible(); + await expect(page.locator("input[value='Bob']")).toBeVisible(); + }); + + test("editing a cell persists the updated CSV via save", async ({ page }) => { + await mockEditorApi(page); + + let savedBody: string | undefined; + await page.route("/api/projects/proj-mc1/files/**", async (route) => { + if (route.request().method() === "PUT") { + savedBody = route.request().postData() ?? undefined; + await route.fulfill({ status: 200, body: "" }); + } else if (route.request().method() === "DELETE") { + await route.fulfill({ status: 204, body: "" }); + } + }); + + await page.goto("/p/proj-mc1"); + await page.locator("button", { hasText: "data.csv" }).click(); + + const cell = page.locator("input[value='Alice']"); + await cell.fill("Alicia"); + + await expect(page.getByText("Saved")).toBeVisible({ timeout: 5000 }); + expect(savedBody).toContain("Alicia"); + }); + + test("adds a new row with the '+ row' button", async ({ page }) => { + await mockEditorApi(page); + await page.goto("/p/proj-mc1"); + await page.locator("button", { hasText: "data.csv" }).click(); + + const rowsBefore = await page.locator("table tr").count(); + await page.getByRole("button", { name: "+ row" }).click(); + + await expect(page.locator("table tr")).toHaveCount(rowsBefore + 1); + }); + + test("deletes a row via the row delete button", async ({ page }) => { + await mockEditorApi(page); + await page.goto("/p/proj-mc1"); + await page.locator("button", { hasText: "data.csv" }).click(); + + await expect(page.locator("input[value='Bob']")).toBeVisible(); + + const bobRow = page.locator("table tr").filter({ has: page.locator("input[value='Bob']") }); + await bobRow.hover(); + await bobRow.getByTitle("Delete row").click(); + + await expect(page.locator("input[value='Bob']")).toHaveCount(0); + await expect(page.locator("input[value='Alice']")).toBeVisible(); + }); +}); diff --git a/web/package.json b/web/package.json index 612c0fe..7d91e62 100644 --- a/web/package.json +++ b/web/package.json @@ -18,8 +18,11 @@ "@monaco-editor/react": "^4.6.0", "dictionary-en": "^4.0.0", "dictionary-es": "^4.0.0", + "dompurify": "^3.4.8", "jszip": "^3.10.1", + "marked": "^18.0.5", "monaco-editor": "^0.52.0", + "papaparse": "^5.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0", @@ -35,6 +38,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/papaparse": "^5.5.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 498756e..b469fc7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,12 +17,21 @@ importers: dictionary-es: specifier: ^4.0.0 version: 4.0.0 + dompurify: + specifier: ^3.4.8 + version: 3.4.8 jszip: specifier: ^3.10.1 version: 3.10.1 + marked: + specifier: ^18.0.5 + version: 18.0.5 monaco-editor: specifier: ^0.52.0 version: 0.52.2 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -48,6 +57,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^18.3.3 version: 18.3.29 @@ -56,7 +68,7 @@ importers: version: 18.3.7(@types/react@18.3.29) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@6.4.3(jiti@1.21.7)) + version: 4.7.0(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) '@vitest/coverage-v8': specifier: ^4.1.0 version: 4.1.8(vitest@4.1.8) @@ -77,10 +89,10 @@ importers: version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.3(jiti@1.21.7) + version: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) vitest: specifier: ^4.1.0 - version: 4.1.8(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) packages: @@ -633,6 +645,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -644,6 +662,9 @@ packages: '@types/react@18.3.29': resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -826,6 +847,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.4.8: + resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -1034,6 +1058,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -1086,6 +1115,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -1367,6 +1399,9 @@ packages: typo-js@1.3.2: resolution: {integrity: sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.27.0: resolution: {integrity: sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==} engines: {node: '>=20.18.1'} @@ -1935,6 +1970,14 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 25.9.2 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.29)': @@ -1946,7 +1989,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@1.21.7))': + '@types/trusted-types@2.0.7': + optional: true + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -1954,7 +2000,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) transitivePeerDependencies: - supports-color @@ -1970,7 +2016,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)) + vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) '@vitest/expect@4.1.8': dependencies: @@ -1981,13 +2027,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@6.4.3(jiti@1.21.7))': + '@vitest/mocker@4.1.8(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) '@vitest/pretty-format@4.1.8': dependencies: @@ -2131,6 +2177,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.4.8: + optionalDependencies: + '@types/trusted-types': 2.0.7 + electron-to-chromium@1.5.364: {} entities@8.0.0: {} @@ -2346,6 +2396,8 @@ snapshots: dependencies: semver: 7.8.1 + marked@18.0.5: {} + mdn-data@2.27.1: {} merge2@1.4.1: {} @@ -2381,6 +2433,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -2674,6 +2728,8 @@ snapshots: typo-js@1.3.2: {} + undici-types@7.24.6: {} + undici@7.27.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -2684,7 +2740,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@6.4.3(jiti@1.21.7): + vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -2693,13 +2749,14 @@ snapshots: rollup: 4.60.4 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 25.9.2 fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.8(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(jiti@1.21.7)): + vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@6.4.3(jiti@1.21.7)) + '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@25.9.2)(jiti@1.21.7)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -2716,9 +2773,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.3(jiti@1.21.7) + vite: 6.4.3(@types/node@25.9.2)(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 25.9.2 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) jsdom: 29.1.1 transitivePeerDependencies: diff --git a/web/src/__tests__/Editor.compile.test.tsx b/web/src/__tests__/Editor.compile.test.tsx new file mode 100644 index 0000000..b3f9110 --- /dev/null +++ b/web/src/__tests__/Editor.compile.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import Editor from "../pages/Editor"; +import { api } from "../api"; +import type { Project, ProjectFile, CompileResult } from "../api"; + +vi.mock("@monaco-editor/react", () => ({ + default: ({ value, onChange }: any) => ( +