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 <niwi@niwi.nz>
This commit is contained in:
Andrey Antukh 2026-08-05 10:15:41 +02:00 committed by GitHub
parent aeedb96260
commit 636bc22cc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 525 additions and 1 deletions

View File

@ -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"
}
}

View File

@ -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

View File

@ -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");
});
});

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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"
);
});
});

View File

@ -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 };
}

View File

@ -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);
}

View File

@ -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;
}