penpot/backend/test/e2e/asset-download.test.mjs
Andrey Antukh 636bc22cc4
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>
2026-08-05 10:15:41 +02:00

146 lines
5.3 KiB
JavaScript

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