From 636bc22cc41e1629d934f3d73fdc71a2e9132219 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 10:15:41 +0200 Subject: [PATCH] :sparkles: Add Node.js E2E API tests for backend (#10787) Add end-to-end HTTP tests under backend/test/e2e/ using Node.js built-in test runner (node:test) and native fetch. Tests run through the devenv nginx proxy on port 3450. Test suites (19 tests total): - auth-flow: demo profile creation, login, session cookies, access tokens - export-binfile: file creation, export to asset URL via SSE - asset-download: download with cookie/token auth, 401 without auth, S3 redirect behavior, full export-to-download flow Key findings documented in tests: - nginx @handle_redirect intercepts backend 307 and proxies to S3 directly, stripping the client Authorization header (bug does not reproduce in devenv) - SSE end event uses ~#uri tagged format for URLs - Unauthenticated RPC returns uuid/zero profile (not null) AI-assisted-by: mimo-v2.5-pro Signed-off-by: Andrey Antukh --- backend/package.json | 4 +- backend/pnpm-lock.yaml | 9 ++ backend/test/e2e/asset-download.test.mjs | 145 +++++++++++++++++++++++ backend/test/e2e/auth-flow.test.mjs | 78 ++++++++++++ backend/test/e2e/config.mjs | 7 ++ backend/test/e2e/export-binfile.test.mjs | 87 ++++++++++++++ backend/test/e2e/helpers/auth.mjs | 38 ++++++ backend/test/e2e/helpers/client.mjs | 86 ++++++++++++++ backend/test/e2e/helpers/sse.mjs | 72 +++++++++++ 9 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 backend/test/e2e/asset-download.test.mjs create mode 100644 backend/test/e2e/auth-flow.test.mjs create mode 100644 backend/test/e2e/config.mjs create mode 100644 backend/test/e2e/export-binfile.test.mjs create mode 100644 backend/test/e2e/helpers/auth.mjs create mode 100644 backend/test/e2e/helpers/client.mjs create mode 100644 backend/test/e2e/helpers/sse.mjs diff --git a/backend/package.json b/backend/package.json index a49ad4a8a7..c6baf43f73 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "url": "https://github.com/penpot/penpot" }, "dependencies": { + "eventsource-parser": "^3.0.6", "luxon": "^3.7.2", "sax": "^1.6.1" }, @@ -21,6 +22,7 @@ "scripts": { "lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/", "check-fmt:clj": "cljfmt check --parallel=true src/ test/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/" + "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs" } } diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index cb48006797..a0b16465e9 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + eventsource-parser: + specifier: ^3.0.6 + version: 3.1.0 luxon: specifier: ^3.7.2 version: 3.7.2 @@ -63,6 +66,10 @@ packages: supports-color: optional: true + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -216,6 +223,8 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + eventsource-parser@3.1.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 diff --git a/backend/test/e2e/asset-download.test.mjs b/backend/test/e2e/asset-download.test.mjs new file mode 100644 index 0000000000..99bb0446ba --- /dev/null +++ b/backend/test/e2e/asset-download.test.mjs @@ -0,0 +1,145 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { + setupTestProfile, + createAccessToken, +} from "./helpers/auth.mjs"; +import { rpcPost, getAsset } from "./helpers/client.mjs"; +import { parseSSE, extractResult } from "./helpers/sse.mjs"; + +async function createAndExport(cookie, projectId) { + const createRes = await rpcPost( + "create-file", + { name: "E2E Asset Test", projectId }, + { cookieToken: cookie } + ); + assert.equal(createRes.status, 200); + const fileId = createRes.body.id; + + const exportRes = await rpcPost( + "export-binfile", + { fileId, includeLibraries: false, embedAssets: true }, + { cookieToken: cookie } + ); + assert.equal(exportRes.status, 200); + const assetUrl = extractResult(parseSSE(exportRes.body)); + return assetUrl; +} + +function extractAssetId(assetUrl) { + const match = assetUrl.match(/\/assets\/by-id\/([0-9a-f-]+)/); + return match ? match[1] : null; +} + +describe("asset download", () => { + let profile, cookie, assetUrl, assetId; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + + assetUrl = await createAndExport(cookie, profile.defaultProjectId); + assetId = extractAssetId(assetUrl); + assert.ok(assetId, `should extract asset id from URL: ${assetUrl}`); + }); + + it("asset download with cookie auth succeeds", async () => { + // In devenv, nginx's @handle_redirect intercepts the backend's 307 and + // proxies to S3 directly. The client sees 200 with file content, not 307. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.ok( + res.body.length > 0 || typeof res.body === "object", + "response should have content" + ); + }); + + it("asset download with access token auth succeeds", async () => { + const tokenObj = await createAccessToken(cookie, "e2e-asset-test"); + const accessToken = tokenObj.token; + + const res = await getAsset(assetId, { accessToken }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + }); + + it("asset download without auth returns 401", async () => { + const res = await getAsset(assetId, {}); + assert.equal(res.status, 401, `expected 401, got ${res.status}`); + }); + + it("asset download returns file content through nginx proxy", async () => { + // The full flow: backend returns 307 with S3 presigned URL, + // nginx intercepts and proxies to S3, client gets 200 with content. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200); + // Response should be a .penpot file (binary/zip content) + assert.ok(res.body, "response should have body"); + }); + + it("follow S3 redirect WITH auth header (bug repro)", async () => { + // In devenv, nginx's @handle_redirect intercepts the 307 and proxies to + // S3 server-side, only forwarding the Host header from X-Host. The client's + // Authorization header is NOT forwarded to S3, so the request succeeds. + // + // In production (no nginx proxy), the backend returns 307 directly. The HTTP + // client follows the redirect and forwards the Authorization: Token header to + // S3, which conflicts with the presigned URL's X-Amz-* params and returns + // 400 InvalidArgument. + // + // This test documents the devenv behavior: nginx strips the auth header + // when proxying to S3, so the download succeeds. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200, "through nginx, download succeeds"); + assert.ok(res.body, "should have file content"); + }); + + it("full export-to-download flow works end-to-end", async () => { + const url = await createAndExport(cookie, profile.defaultProjectId); + const id = extractAssetId(url); + assert.ok(id); + + const res = await getAsset(id, { cookieToken: cookie }); + assert.equal(res.status, 200); + }); + + it("asset URL is accessible immediately after export", async () => { + const url = await createAndExport(cookie, profile.defaultProjectId); + const id = extractAssetId(url); + assert.ok(id); + + const res = await getAsset(id, { cookieToken: cookie }); + assert.equal(res.status, 200, "asset should be accessible right after export"); + }); + + it("token-only: export then download asset with same token", async () => { + const tokenObj = await createAccessToken(cookie, "e2e-token-export-test"); + const token = tokenObj.token; + + const createRes = await rpcPost( + "create-file", + { name: "E2E Token Export Test", projectId: profile.defaultProjectId }, + { accessToken: token } + ); + assert.equal(createRes.status, 200); + const fileId = createRes.body.id; + + const exportRes = await rpcPost( + "export-binfile", + { fileId, includeLibraries: false, embedAssets: true }, + { accessToken: token } + ); + assert.equal(exportRes.status, 200); + + const events = parseSSE(exportRes.body); + const url = extractResult(events); + assert.ok(url, "should get an asset URL from export"); + + const id = extractAssetId(url); + assert.ok(id, `should extract asset id from URL: ${url}`); + + const res = await getAsset(id, { accessToken: token }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.ok(res.body, "response should have file content"); + }); +}); diff --git a/backend/test/e2e/auth-flow.test.mjs b/backend/test/e2e/auth-flow.test.mjs new file mode 100644 index 0000000000..7f9fbb34fd --- /dev/null +++ b/backend/test/e2e/auth-flow.test.mjs @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + createDemoProfile, + login, + setupTestProfile, +} from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +describe("auth flow", () => { + it("creates a demo profile", async () => { + const { email, password } = await createDemoProfile(); + assert.match(email, /^demo-.*\.demo@example\.com$/); + assert.ok(password.length > 0); + }); + + it("logs in with valid credentials", async () => { + const { email, password } = await createDemoProfile(); + const { profile, cookie } = await login(email, password); + + assert.equal(profile.email, email); + assert.equal(profile.isDemo, true); + assert.ok(profile.id, "profile should have id"); + assert.ok(profile.defaultProjectId, "profile should have defaultProjectId"); + assert.ok(profile.defaultTeamId, "profile should have defaultTeamId"); + assert.ok(cookie, "cookie should be set"); + }); + + it("login sets session cookie", async () => { + const { email, password } = await createDemoProfile(); + const { cookie } = await login(email, password); + assert.ok(cookie, "auth-token cookie should be extracted"); + assert.ok(cookie.length > 10, "cookie should have meaningful length"); + }); + + it("login fails with wrong password", async () => { + const { email } = await createDemoProfile(); + try { + await login(email, "wrong-password"); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e.message.includes("Login failed")); + } + }); + + it("login fails with non-existent email", async () => { + try { + await login("nonexistent@example.com", "some-password"); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e.message.includes("Login failed")); + } + }); + + it("authenticated RPC with cookie", async () => { + const { profile, cookie } = await setupTestProfile(); + const res = await rpcPost("get-profile", {}, { cookieToken: cookie }); + assert.equal(res.status, 200); + assert.equal(res.body.id, profile.id); + assert.equal(res.body.email, profile.email); + }); + + it("unauthenticated RPC returns anonymous profile", async () => { + const res = await rpcPost("get-profile", {}); + assert.equal(res.status, 200); + // Anonymous profile has uuid/zero as id + assert.equal(res.body.id, "00000000-0000-0000-0000-000000000000"); + }); + + it("setupTestProfile returns all fields", async () => { + const { profile, cookie, email, password } = await setupTestProfile(); + assert.ok(profile.id); + assert.ok(profile.defaultProjectId); + assert.ok(cookie); + assert.ok(email); + assert.ok(password); + }); +}); diff --git a/backend/test/e2e/config.mjs b/backend/test/e2e/config.mjs new file mode 100644 index 0000000000..697f20f8c4 --- /dev/null +++ b/backend/test/e2e/config.mjs @@ -0,0 +1,7 @@ +const config = Object.freeze({ + baseUrl: process.env.PENPOT_BASE_URL || "http://localhost:3450", + email: process.env.PENPOT_EMAIL || null, + password: process.env.PENPOT_PASSWORD || null, +}); + +export default config; diff --git a/backend/test/e2e/export-binfile.test.mjs b/backend/test/e2e/export-binfile.test.mjs new file mode 100644 index 0000000000..f6be598ec5 --- /dev/null +++ b/backend/test/e2e/export-binfile.test.mjs @@ -0,0 +1,87 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; +import { parseSSE, extractResult } from "./helpers/sse.mjs"; + +async function createFile(cookie, projectId, name = "E2E Test File") { + const res = await rpcPost( + "create-file", + { name, projectId }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`); + return res.body; +} + +async function exportFile(cookie, fileId) { + const res = await rpcPost( + "export-binfile", + { + fileId, + includeLibraries: false, + embedAssets: true, + }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `export-binfile failed: ${JSON.stringify(res.body)}`); + + const events = parseSSE(res.body); + const assetUrl = extractResult(events); + return assetUrl; +} + +describe("export-binfile", () => { + let profile, cookie; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + }); + + it("creates a file via API", async () => { + const file = await createFile(cookie, profile.defaultProjectId); + assert.ok(file.id, "file should have an id"); + assert.equal(file.name, "E2E Test File"); + }); + + it("export returns an asset URL", async () => { + const file = await createFile(cookie, profile.defaultProjectId); + const assetUrl = await exportFile(cookie, file.id); + assert.ok( + typeof assetUrl === "string" && assetUrl.includes("/assets/by-id/"), + `asset URL should contain /assets/by-id/, got: ${assetUrl}` + ); + assert.match(assetUrl, /\/assets\/by-id\/[0-9a-f-]+$/); + }); + + it("export with invalid file-id returns error", async () => { + const fakeId = "00000000-0000-0000-0000-000000000000"; + const res = await rpcPost( + "export-binfile", + { + fileId: fakeId, + includeLibraries: false, + embedAssets: true, + }, + { cookieToken: cookie } + ); + assert.ok( + res.body.type || res.status !== 200, + "should return error for non-existent file" + ); + }); + + it("export requires authentication", async () => { + const res = await rpcPost("export-binfile", { + fileId: "00000000-0000-0000-0000-000000000000", + includeLibraries: false, + embedAssets: true, + }); + assert.ok( + res.body.type || res.status !== 200, + "should require authentication" + ); + }); +}); diff --git a/backend/test/e2e/helpers/auth.mjs b/backend/test/e2e/helpers/auth.mjs new file mode 100644 index 0000000000..07d34ef0f0 --- /dev/null +++ b/backend/test/e2e/helpers/auth.mjs @@ -0,0 +1,38 @@ +import { rpcPost, extractCookie } from "./client.mjs"; + +export async function createDemoProfile() { + const res = await rpcPost("create-demo-profile", {}); + if (res.body.type === "validation" || res.body.type === "restriction") { + throw new Error( + `Failed to create demo profile: ${res.body.code} - ${res.body.hint || ""}` + ); + } + return { email: res.body.email, password: res.body.password }; +} + +export async function login(email, password) { + const res = await rpcPost("login-with-password", { email, password }); + if (res.status !== 200 || res.body.type) { + throw new Error( + `Login failed: ${JSON.stringify(res.body)}` + ); + } + const cookie = extractCookie(res.setCookie); + return { profile: res.body, cookie }; +} + +export async function createAccessToken(cookie, name = "e2e-test-token") { + const res = await rpcPost("create-access-token", { name }, { cookieToken: cookie }); + if (res.status !== 200 || res.body.type) { + throw new Error( + `Create access token failed: ${JSON.stringify(res.body)}` + ); + } + return res.body; +} + +export async function setupTestProfile() { + const { email, password } = await createDemoProfile(); + const { profile, cookie } = await login(email, password); + return { profile, cookie, email, password }; +} diff --git a/backend/test/e2e/helpers/client.mjs b/backend/test/e2e/helpers/client.mjs new file mode 100644 index 0000000000..1bfe8750d6 --- /dev/null +++ b/backend/test/e2e/helpers/client.mjs @@ -0,0 +1,86 @@ +import config from "../config.mjs"; + +async function parseResponse(response) { + const contentType = response.headers.get("content-type") || ""; + const setCookie = response.headers.get("set-cookie") || null; + + let body; + if (contentType.includes("application/json")) { + body = await response.json(); + } else { + body = await response.text(); + } + + return { + status: response.status, + headers: response.headers, + body, + setCookie, + }; +} + +export function extractCookie(setCookieHeader, name = "auth-token") { + if (!setCookieHeader) return null; + const match = setCookieHeader.match(new RegExp(`${name}=([^;]+)`)); + return match ? match[1] : null; +} + +export async function rpcPost(method, body = {}, { cookieToken, accessToken } = {}) { + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + if (accessToken) { + headers.Authorization = `Token ${accessToken}`; + } + + const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + return parseResponse(response); +} + +export async function multipartPost(method, formData, { cookieToken } = {}) { + const headers = { + Accept: "application/json", + }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + + const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, { + method: "POST", + headers, + body: formData, + }); + + return parseResponse(response); +} + +export async function getAsset( + id, + { cookieToken, accessToken, redirect = "manual" } = {} +) { + const headers = { Accept: "application/json" }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + if (accessToken) { + headers.Authorization = `Token ${accessToken}`; + } + + const response = await fetch(`${config.baseUrl}/assets/by-id/${id}`, { + method: "GET", + headers, + redirect, + }); + + return parseResponse(response); +} + diff --git a/backend/test/e2e/helpers/sse.mjs b/backend/test/e2e/helpers/sse.mjs new file mode 100644 index 0000000000..02f3845d20 --- /dev/null +++ b/backend/test/e2e/helpers/sse.mjs @@ -0,0 +1,72 @@ +import { createParser } from "eventsource-parser"; + +export function parseSSE(text) { + const events = []; + const parser = createParser({ + onEvent(event) { + events.push({ event: event.event || "message", data: event.data }); + }, + }); + parser.feed(text); + return events; +} + +export function extractResult(events) { + const endEvent = events.find((e) => e.event === "end"); + if (!endEvent) { + const errEvent = events.find((e) => e.event === "error"); + if (errEvent) { + throw new Error(`SSE error: ${errEvent.data}`); + } + throw new Error(`No end event found in SSE stream. Events: ${JSON.stringify(events)}`); + } + + const raw = JSON.parse(endEvent.data); + + // Transit JSON verbose format: + // For URIs (e.g. asset URL): {"~#uri":"https://..."} + // For objects: {"~:key":"val",...} or ["^ ","~:key","val",...] + // For strings: plain string + if (raw && typeof raw === "object") { + // Tagged URI + if ("~#uri" in raw) { + return raw["~#uri"]; + } + // Transit map with ~:value key + if ("~:value" in raw) { + const value = raw["~:value"]; + if (Array.isArray(value)) { + return transitArrayToObj(value); + } + return value; + } + // Direct transit map (keys starting with ~:) + const firstKey = Object.keys(raw)[0]; + if (firstKey && firstKey.startsWith("~:")) { + return transitMapToObj(raw); + } + } + + return raw; +} + +function transitArrayToObj(arr) { + // Transit verbose object: ["^ ","~:key1","val1","~:key2","val2",...] + const obj = {}; + for (let i = 1; i < arr.length; i += 2) { + const key = arr[i].replace(/^~:/, ""); + const val = arr[i + 1]; + obj[key] = val; + } + return obj; +} + +function transitMapToObj(map) { + // Transit verbose map: {"~:key1":"val1","~:key2":"val2",...} + const obj = {}; + for (const [key, val] of Object.entries(map)) { + const cleanKey = key.replace(/^~:/, ""); + obj[cleanKey] = val; + } + return obj; +}