@desplega.ai/agent-swarm 1.78.0 → 1.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +542 -1
- package/package.json +1 -1
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/pages/SKILL.md +274 -0
- package/src/artifact-sdk/browser-sdk.ts +105 -20
- package/src/be/db.ts +239 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/commands/artifact.ts +17 -11
- package/src/http/index.ts +7 -1
- package/src/http/page-proxy.ts +208 -0
- package/src/http/pages-public.ts +466 -0
- package/src/http/pages.ts +608 -0
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/server.ts +10 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/error-tracker.test.ts +30 -0
- package/src/tests/fixtures/sample-json-page.json +52 -0
- package/src/tests/launch-password-rejection.test.ts +139 -0
- package/src/tests/page-proxy-authed.test.ts +146 -0
- package/src/tests/page-proxy.test.ts +266 -0
- package/src/tests/page-session.test.ts +164 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +207 -0
- package/src/tests/pages-http.test.ts +193 -0
- package/src/tests/pages-list-endpoint.test.ts +149 -0
- package/src/tests/pages-password-hash.test.ts +57 -0
- package/src/tests/pages-password-mode.test.ts +265 -0
- package/src/tests/pages-public-authed-401.test.ts +102 -0
- package/src/tests/pages-public-html.test.ts +151 -0
- package/src/tests/pages-public-json-redirect.test.ts +86 -0
- package/src/tests/pages-storage.test.ts +196 -0
- package/src/tests/pages-versioning.test.ts +231 -0
- package/src/tests/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +3 -0
- package/src/types.ts +54 -0
- package/src/utils/error-tracker.ts +55 -1
- package/src/utils/page-session.ts +254 -0
- package/plugin/skills/artifacts/skill.md +0 -70
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
createServer as createHttpServer,
|
|
6
|
+
type IncomingMessage,
|
|
7
|
+
type Server,
|
|
8
|
+
type ServerResponse,
|
|
9
|
+
} from "node:http";
|
|
10
|
+
import { closeDb, initDb } from "../be/db";
|
|
11
|
+
import { handlePages } from "../http/pages";
|
|
12
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
13
|
+
import type { Page } from "../types";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = "./test-pages-list-endpoint.sqlite";
|
|
16
|
+
const TEST_PORT = 13046;
|
|
17
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
18
|
+
|
|
19
|
+
type PageWithUrls = Page & { app_url: string; api_url: string };
|
|
20
|
+
type ListResponse = { pages: PageWithUrls[]; total: number };
|
|
21
|
+
|
|
22
|
+
function createTestServer(): Server {
|
|
23
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
24
|
+
res.setHeader("Content-Type", "application/json");
|
|
25
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
26
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
27
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
28
|
+
const handled = await handlePages(req, res, pathSegments, queryParams, myAgentId);
|
|
29
|
+
if (!handled) {
|
|
30
|
+
res.writeHead(404);
|
|
31
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Helper: POST /api/pages as `agentId`. Returns the created id.
|
|
38
|
+
*/
|
|
39
|
+
async function seedPage(opts: { agentId: string; slug: string; title: string }): Promise<string> {
|
|
40
|
+
const res = await fetch(`${baseUrl}/api/pages`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"X-Agent-ID": opts.agentId,
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
slug: opts.slug,
|
|
48
|
+
title: opts.title,
|
|
49
|
+
contentType: "text/html",
|
|
50
|
+
authMode: "public",
|
|
51
|
+
body: `<h1>${opts.title}</h1>`,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(201);
|
|
55
|
+
const json = (await res.json()) as { id: string };
|
|
56
|
+
return json.id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("GET /api/pages — listing endpoint", () => {
|
|
60
|
+
let server: Server;
|
|
61
|
+
const agentA = crypto.randomUUID();
|
|
62
|
+
const agentB = crypto.randomUUID();
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
try {
|
|
66
|
+
await unlink(TEST_DB_PATH);
|
|
67
|
+
} catch {}
|
|
68
|
+
initDb(TEST_DB_PATH);
|
|
69
|
+
server = createTestServer();
|
|
70
|
+
await new Promise<void>((resolve) => {
|
|
71
|
+
server.listen(TEST_PORT, () => resolve());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Seed: two pages under agentA, one under agentB.
|
|
75
|
+
await seedPage({ agentId: agentA, slug: "a-1", title: "A One" });
|
|
76
|
+
await seedPage({ agentId: agentA, slug: "a-2", title: "A Two" });
|
|
77
|
+
await seedPage({ agentId: agentB, slug: "b-1", title: "B One" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterAll(async () => {
|
|
81
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
82
|
+
closeDb();
|
|
83
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
84
|
+
try {
|
|
85
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns all pages with share URLs when no filter is supplied", async () => {
|
|
91
|
+
const res = await fetch(`${baseUrl}/api/pages`, {
|
|
92
|
+
headers: { "X-Agent-ID": agentA },
|
|
93
|
+
});
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
const body = (await res.json()) as ListResponse;
|
|
96
|
+
expect(body.pages.length).toBe(3);
|
|
97
|
+
expect(body.total).toBe(3);
|
|
98
|
+
// Every row carries share URLs.
|
|
99
|
+
for (const page of body.pages) {
|
|
100
|
+
expect(page.app_url).toMatch(/\/pages\//);
|
|
101
|
+
expect(page.api_url).toMatch(/\/p\//);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("filters by agentId when query supplied", async () => {
|
|
106
|
+
const aRes = await fetch(`${baseUrl}/api/pages?agentId=${agentA}`, {
|
|
107
|
+
headers: { "X-Agent-ID": agentA },
|
|
108
|
+
});
|
|
109
|
+
const aBody = (await aRes.json()) as ListResponse;
|
|
110
|
+
expect(aBody.pages.length).toBe(2);
|
|
111
|
+
expect(aBody.pages.every((p) => p.agentId === agentA)).toBe(true);
|
|
112
|
+
|
|
113
|
+
const bRes = await fetch(`${baseUrl}/api/pages?agentId=${agentB}`, {
|
|
114
|
+
headers: { "X-Agent-ID": agentA },
|
|
115
|
+
});
|
|
116
|
+
const bBody = (await bRes.json()) as ListResponse;
|
|
117
|
+
expect(bBody.pages.length).toBe(1);
|
|
118
|
+
expect(bBody.pages[0]?.agentId).toBe(agentB);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("pagination via limit + offset", async () => {
|
|
122
|
+
const firstPage = await fetch(`${baseUrl}/api/pages?limit=2&offset=0`, {
|
|
123
|
+
headers: { "X-Agent-ID": agentA },
|
|
124
|
+
});
|
|
125
|
+
const firstBody = (await firstPage.json()) as ListResponse;
|
|
126
|
+
expect(firstBody.pages.length).toBe(2);
|
|
127
|
+
|
|
128
|
+
const secondPage = await fetch(`${baseUrl}/api/pages?limit=2&offset=2`, {
|
|
129
|
+
headers: { "X-Agent-ID": agentA },
|
|
130
|
+
});
|
|
131
|
+
const secondBody = (await secondPage.json()) as ListResponse;
|
|
132
|
+
expect(secondBody.pages.length).toBe(1);
|
|
133
|
+
|
|
134
|
+
// No overlap between the two slices.
|
|
135
|
+
const firstIds = new Set(firstBody.pages.map((p) => p.id));
|
|
136
|
+
expect(secondBody.pages.every((p) => !firstIds.has(p.id))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("results are ordered by updatedAt DESC (most recently created first)", async () => {
|
|
140
|
+
const res = await fetch(`${baseUrl}/api/pages`, {
|
|
141
|
+
headers: { "X-Agent-ID": agentA },
|
|
142
|
+
});
|
|
143
|
+
const body = (await res.json()) as ListResponse;
|
|
144
|
+
const times = body.pages.map((p) => new Date(p.updatedAt).getTime());
|
|
145
|
+
for (let i = 0; i < times.length - 1; i++) {
|
|
146
|
+
expect(times[i]).toBeGreaterThanOrEqual(times[i + 1]!);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test for `Bun.password.{hash,verify}` on bcrypt — the primitive used by
|
|
3
|
+
* `auth_mode='password'` pages (step-5). These tests pin the assumptions:
|
|
4
|
+
*
|
|
5
|
+
* 1. A bcrypt hash of a known input verifies successfully.
|
|
6
|
+
* 2. A close-but-wrong input fails verification.
|
|
7
|
+
* 3. Hashes are unique per call (bcrypt salts internally) — same plaintext,
|
|
8
|
+
* different hash, both still verify.
|
|
9
|
+
*
|
|
10
|
+
* Constant-time-comparison assumption: `Bun.password.verify` uses bcrypt's
|
|
11
|
+
* internal constant-time compare, so password verification cannot leak the
|
|
12
|
+
* password via timing side-channels at the API surface. We pin behaviour, not
|
|
13
|
+
* timings.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
|
|
17
|
+
describe("Bun.password — bcrypt assumptions for password-mode pages", () => {
|
|
18
|
+
test("hash() produces a $2-prefixed bcrypt hash", async () => {
|
|
19
|
+
const hash = await Bun.password.hash("swordfish", "bcrypt");
|
|
20
|
+
expect(hash.startsWith("$2")).toBe(true);
|
|
21
|
+
// bcrypt hashes are 60 characters.
|
|
22
|
+
expect(hash.length).toBe(60);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("verify() succeeds on the exact plaintext", async () => {
|
|
26
|
+
const hash = await Bun.password.hash("swordfish", "bcrypt");
|
|
27
|
+
expect(await Bun.password.verify("swordfish", hash)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("verify() fails on a close-but-wrong input (one char off)", async () => {
|
|
31
|
+
const hash = await Bun.password.hash("swordfish", "bcrypt");
|
|
32
|
+
expect(await Bun.password.verify("swordfisH", hash)).toBe(false);
|
|
33
|
+
expect(await Bun.password.verify("swordfish ", hash)).toBe(false);
|
|
34
|
+
expect(await Bun.password.verify("Swordfish", hash)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("verify() fails on the empty string", async () => {
|
|
38
|
+
const hash = await Bun.password.hash("swordfish", "bcrypt");
|
|
39
|
+
expect(await Bun.password.verify("", hash)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("two hashes of the same plaintext differ (random salt), both verify", async () => {
|
|
43
|
+
const a = await Bun.password.hash("swordfish", "bcrypt");
|
|
44
|
+
const b = await Bun.password.hash("swordfish", "bcrypt");
|
|
45
|
+
expect(a).not.toBe(b);
|
|
46
|
+
expect(await Bun.password.verify("swordfish", a)).toBe(true);
|
|
47
|
+
expect(await Bun.password.verify("swordfish", b)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("verify() against a malformed hash string throws UnsupportedAlgorithm", async () => {
|
|
51
|
+
// Bun.password.verify throws for unrecognised hash prefixes — the
|
|
52
|
+
// password-mode handler wraps the call in try/catch so a corrupt
|
|
53
|
+
// passwordHash column surfaces as a 401, NOT a 500. This test pins the
|
|
54
|
+
// throw behaviour so the handler's try/catch is intentional (not vestigial).
|
|
55
|
+
await expect(Bun.password.verify("swordfish", "not-a-real-hash")).rejects.toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end coverage for `auth_mode='password'` on `/p/:id` (step-5).
|
|
3
|
+
*
|
|
4
|
+
* Constant-time-comparison assumption: `Bun.password.verify` (bcrypt) is
|
|
5
|
+
* documented constant-time. We rely on it; do NOT replace with a
|
|
6
|
+
* naive-string-equality short-circuit on the password column.
|
|
7
|
+
*
|
|
8
|
+
* Scenarios:
|
|
9
|
+
* 1. Plaintext password is hashed (DB row != "swordfish").
|
|
10
|
+
* 2. GET /p/:id → 401 + WWW-Authenticate: Basic.
|
|
11
|
+
* 3. GET /p/:id?key=wrong → 401.
|
|
12
|
+
* 4. GET /p/:id?key=swordfish → 200 + Set-Cookie + body served + SDK injected.
|
|
13
|
+
* 5. GET /p/:id with `Authorization: Basic <base64(x:swordfish)>` → 200 + Set-Cookie.
|
|
14
|
+
* 6. GET /p/:id with the issued cookie → 200 (no re-prompt).
|
|
15
|
+
* 7. GET /p/:id.json after cookie → 200 with metadata.
|
|
16
|
+
* 8. Cross-page cookie reuse → 403 (cookie scoped to id).
|
|
17
|
+
*/
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
19
|
+
import crypto from "node:crypto";
|
|
20
|
+
import { unlink } from "node:fs/promises";
|
|
21
|
+
import {
|
|
22
|
+
createServer as createHttpServer,
|
|
23
|
+
type IncomingMessage,
|
|
24
|
+
type Server,
|
|
25
|
+
type ServerResponse,
|
|
26
|
+
} from "node:http";
|
|
27
|
+
import { closeDb, getPage, initDb } from "../be/db";
|
|
28
|
+
import { handlePages } from "../http/pages";
|
|
29
|
+
import { handlePagesPublic } from "../http/pages-public";
|
|
30
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
31
|
+
|
|
32
|
+
const TEST_DB_PATH = "./test-pages-password-mode.sqlite";
|
|
33
|
+
const TEST_PORT = 13051;
|
|
34
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
35
|
+
|
|
36
|
+
function createTestServer(): Server {
|
|
37
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
38
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
39
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
40
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
41
|
+
if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
|
|
42
|
+
if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
43
|
+
res.writeHead(404);
|
|
44
|
+
res.end("not found");
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractCookieValue(setCookie: string | null): string {
|
|
49
|
+
expect(setCookie).toBeTruthy();
|
|
50
|
+
const match = /page_session=([^;]+)/.exec(setCookie!);
|
|
51
|
+
expect(match).toBeTruthy();
|
|
52
|
+
return match![1]!;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("GET /p/:id — password mode (step-5)", () => {
|
|
56
|
+
let server: Server;
|
|
57
|
+
const agentId = crypto.randomUUID();
|
|
58
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
process.env.PAGE_SESSION_SECRET = "test-password-mode-secret";
|
|
62
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
63
|
+
try {
|
|
64
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
initDb(TEST_DB_PATH);
|
|
68
|
+
server = createTestServer();
|
|
69
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
74
|
+
closeDb();
|
|
75
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
76
|
+
try {
|
|
77
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function createPasswordPage(
|
|
83
|
+
slug: string,
|
|
84
|
+
password: string,
|
|
85
|
+
body = "<h1>vault</h1>",
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers,
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
slug,
|
|
92
|
+
title: `Locked ${slug}`,
|
|
93
|
+
contentType: "text/html",
|
|
94
|
+
authMode: "password",
|
|
95
|
+
password,
|
|
96
|
+
body,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
expect(post.status).toBe(201);
|
|
100
|
+
const { id } = (await post.json()) as { id: string };
|
|
101
|
+
return id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test("password is hashed in the DB row (passwordHash != plaintext)", async () => {
|
|
105
|
+
const id = await createPasswordPage("hashed", "swordfish");
|
|
106
|
+
const row = getPage(id);
|
|
107
|
+
expect(row).toBeTruthy();
|
|
108
|
+
// passwordHash field is private; should be bcrypt and clearly not the plaintext.
|
|
109
|
+
expect(row!.passwordHash).toBeTruthy();
|
|
110
|
+
expect(row!.passwordHash).not.toBe("swordfish");
|
|
111
|
+
// bcrypt hashes start with $2 (e.g. $2a$, $2b$, $2y$).
|
|
112
|
+
expect(row!.passwordHash!.startsWith("$2")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("GET /p/:id without anything → 401 + WWW-Authenticate Basic", async () => {
|
|
116
|
+
const id = await createPasswordPage("no-creds", "swordfish");
|
|
117
|
+
const res = await fetch(`${BASE}/p/${id}`);
|
|
118
|
+
expect(res.status).toBe(401);
|
|
119
|
+
const wa = res.headers.get("www-authenticate") || "";
|
|
120
|
+
expect(wa.toLowerCase()).toContain("basic");
|
|
121
|
+
expect(wa).toContain(`page ${id}`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("GET /p/:id?key=wrong → 401 (with WWW-Authenticate for re-prompt)", async () => {
|
|
125
|
+
const id = await createPasswordPage("wrong-key", "swordfish");
|
|
126
|
+
const res = await fetch(`${BASE}/p/${id}?key=wrong`);
|
|
127
|
+
expect(res.status).toBe(401);
|
|
128
|
+
const wa = res.headers.get("www-authenticate") || "";
|
|
129
|
+
expect(wa.toLowerCase()).toContain("basic");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("GET /p/:id?key=<right> → 200 + Set-Cookie + body served + SDK injected", async () => {
|
|
133
|
+
const id = await createPasswordPage(
|
|
134
|
+
"right-key",
|
|
135
|
+
"swordfish",
|
|
136
|
+
"<!doctype html><body><h1>open sesame</h1></body>",
|
|
137
|
+
);
|
|
138
|
+
const res = await fetch(`${BASE}/p/${id}?key=swordfish`, { redirect: "manual" });
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("text/html");
|
|
141
|
+
const cookieHeader = res.headers.get("set-cookie");
|
|
142
|
+
expect(cookieHeader).toContain("page_session=");
|
|
143
|
+
const text = await res.text();
|
|
144
|
+
expect(text).toContain("<h1>open sesame</h1>");
|
|
145
|
+
// BROWSER_SDK_JS sentinel.
|
|
146
|
+
expect(text).toContain("class SwarmSDK");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("GET /p/:id with Authorization: Basic <base64(x:swordfish)> → 200 + Set-Cookie", async () => {
|
|
150
|
+
const id = await createPasswordPage(
|
|
151
|
+
"basic-auth",
|
|
152
|
+
"swordfish",
|
|
153
|
+
"<!doctype html><body><h1>via basic</h1></body>",
|
|
154
|
+
);
|
|
155
|
+
const basic = Buffer.from("x:swordfish", "utf-8").toString("base64");
|
|
156
|
+
const res = await fetch(`${BASE}/p/${id}`, {
|
|
157
|
+
headers: { Authorization: `Basic ${basic}` },
|
|
158
|
+
});
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
expect(res.headers.get("set-cookie")).toContain("page_session=");
|
|
161
|
+
const text = await res.text();
|
|
162
|
+
expect(text).toContain("<h1>via basic</h1>");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("Basic auth with wrong password → 401 (constant-time path)", async () => {
|
|
166
|
+
const id = await createPasswordPage("basic-wrong", "swordfish");
|
|
167
|
+
const basic = Buffer.from("x:nope", "utf-8").toString("base64");
|
|
168
|
+
const res = await fetch(`${BASE}/p/${id}`, {
|
|
169
|
+
headers: { Authorization: `Basic ${basic}` },
|
|
170
|
+
});
|
|
171
|
+
expect(res.status).toBe(401);
|
|
172
|
+
expect(res.headers.get("www-authenticate")?.toLowerCase()).toContain("basic");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("GET /p/:id with issued cookie → 200 (no re-prompt; cookie is proof)", async () => {
|
|
176
|
+
const id = await createPasswordPage("cookie-reuse", "swordfish");
|
|
177
|
+
const first = await fetch(`${BASE}/p/${id}?key=swordfish`);
|
|
178
|
+
expect(first.status).toBe(200);
|
|
179
|
+
const cookieValue = extractCookieValue(first.headers.get("set-cookie"));
|
|
180
|
+
|
|
181
|
+
// Subsequent request — no ?key=, no Basic, just the cookie.
|
|
182
|
+
const second = await fetch(`${BASE}/p/${id}`, {
|
|
183
|
+
headers: { Cookie: `page_session=${cookieValue}` },
|
|
184
|
+
});
|
|
185
|
+
expect(second.status).toBe(200);
|
|
186
|
+
const text = await second.text();
|
|
187
|
+
expect(text).toContain("<h1>vault</h1>");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("GET /p/:id.json with issued cookie → 200 + JSON metadata", async () => {
|
|
191
|
+
const id = await createPasswordPage("json-meta", "swordfish");
|
|
192
|
+
const first = await fetch(`${BASE}/p/${id}?key=swordfish`);
|
|
193
|
+
expect(first.status).toBe(200);
|
|
194
|
+
const cookieValue = extractCookieValue(first.headers.get("set-cookie"));
|
|
195
|
+
|
|
196
|
+
const res = await fetch(`${BASE}/p/${id}.json`, {
|
|
197
|
+
headers: { Cookie: `page_session=${cookieValue}` },
|
|
198
|
+
});
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
|
|
201
|
+
const json = (await res.json()) as {
|
|
202
|
+
id: string;
|
|
203
|
+
authMode: string;
|
|
204
|
+
contentType: string;
|
|
205
|
+
body: string;
|
|
206
|
+
};
|
|
207
|
+
expect(json.id).toBe(id);
|
|
208
|
+
expect(json.authMode).toBe("password");
|
|
209
|
+
// passwordHash MUST NOT leak through the JSON endpoint.
|
|
210
|
+
expect(JSON.stringify(json)).not.toContain("$2");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("GET /p/:id.json without cookie → 401 + WWW-Authenticate", async () => {
|
|
214
|
+
const id = await createPasswordPage("json-401", "swordfish");
|
|
215
|
+
const res = await fetch(`${BASE}/p/${id}.json`);
|
|
216
|
+
expect(res.status).toBe(401);
|
|
217
|
+
expect(res.headers.get("www-authenticate")?.toLowerCase()).toContain("basic");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("GET /p/:id.json?key=<right> → 200 + Set-Cookie (SPA metadata fetch path)", async () => {
|
|
221
|
+
const id = await createPasswordPage("json-key", "swordfish");
|
|
222
|
+
const res = await fetch(`${BASE}/p/${id}.json?key=swordfish`);
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
expect(res.headers.get("set-cookie")).toContain("page_session=");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("cross-page cookie reuse → 401 + password prompt (cookie is silently ignored for password mode)", async () => {
|
|
228
|
+
const idA = await createPasswordPage("scope-a", "swordfish");
|
|
229
|
+
const idB = await createPasswordPage("scope-b", "swordfish"); // same password, different id
|
|
230
|
+
|
|
231
|
+
// Unlock A → get cookie.
|
|
232
|
+
const unlockA = await fetch(`${BASE}/p/${idA}?key=swordfish`);
|
|
233
|
+
expect(unlockA.status).toBe(200);
|
|
234
|
+
const cookieForA = extractCookieValue(unlockA.headers.get("set-cookie"));
|
|
235
|
+
|
|
236
|
+
// Use cookie from A on page B → 401 + WWW-Authenticate so the password
|
|
237
|
+
// flow can recover (user re-enters the password). The cookie is stale
|
|
238
|
+
// from the user's perspective; surfacing 403 here would trap the user
|
|
239
|
+
// in a "scoped to different page" state with no way to recover.
|
|
240
|
+
const res = await fetch(`${BASE}/p/${idB}`, {
|
|
241
|
+
headers: { Cookie: `page_session=${cookieForA}` },
|
|
242
|
+
});
|
|
243
|
+
expect(res.status).toBe(401);
|
|
244
|
+
expect(res.headers.get("www-authenticate")).toContain(`Basic realm="page ${idB}"`);
|
|
245
|
+
const body = (await res.json()) as { error: string };
|
|
246
|
+
expect(body.error).toBe("password required");
|
|
247
|
+
|
|
248
|
+
// …and re-submitting with `?key=<right>` on page B should still work,
|
|
249
|
+
// even with the stale cookie in flight.
|
|
250
|
+
const recover = await fetch(`${BASE}/p/${idB}?key=swordfish`, {
|
|
251
|
+
headers: { Cookie: `page_session=${cookieForA}` },
|
|
252
|
+
});
|
|
253
|
+
expect(recover.status).toBe(200);
|
|
254
|
+
expect(recover.headers.get("set-cookie")).toContain("page_session=");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("malformed Basic header (no colon) → 401 (no crash)", async () => {
|
|
258
|
+
const id = await createPasswordPage("malformed-basic", "swordfish");
|
|
259
|
+
const malformed = Buffer.from("no-colon-here", "utf-8").toString("base64");
|
|
260
|
+
const res = await fetch(`${BASE}/p/${id}`, {
|
|
261
|
+
headers: { Authorization: `Basic ${malformed}` },
|
|
262
|
+
});
|
|
263
|
+
expect(res.status).toBe(401);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authed-mode pages return 401 from `/p/:id` in step-3 — the cookie path
|
|
3
|
+
* is added in step-4 (which will narrow this test by also accepting a
|
|
4
|
+
* valid `page_session` cookie).
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
9
|
+
import {
|
|
10
|
+
createServer as createHttpServer,
|
|
11
|
+
type IncomingMessage,
|
|
12
|
+
type Server,
|
|
13
|
+
type ServerResponse,
|
|
14
|
+
} from "node:http";
|
|
15
|
+
import { closeDb, initDb } from "../be/db";
|
|
16
|
+
import { handlePages } from "../http/pages";
|
|
17
|
+
import { handlePagesPublic } from "../http/pages-public";
|
|
18
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
19
|
+
|
|
20
|
+
const TEST_DB_PATH = "./test-pages-public-authed-401.sqlite";
|
|
21
|
+
const TEST_PORT = 13044;
|
|
22
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
23
|
+
|
|
24
|
+
function createTestServer(): Server {
|
|
25
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
26
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
27
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
28
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
29
|
+
if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
|
|
30
|
+
if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
31
|
+
res.writeHead(404);
|
|
32
|
+
res.end("not found");
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("GET /p/:id — authed mode returns 401 in step-3", () => {
|
|
37
|
+
let server: Server;
|
|
38
|
+
const agentId = crypto.randomUUID();
|
|
39
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
43
|
+
try {
|
|
44
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
initDb(TEST_DB_PATH);
|
|
48
|
+
server = createTestServer();
|
|
49
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
54
|
+
closeDb();
|
|
55
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("authMode='authed' returns 401 with cookie-required guidance", async () => {
|
|
63
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers,
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
slug: "authed-stub",
|
|
68
|
+
title: "Authed",
|
|
69
|
+
contentType: "text/html",
|
|
70
|
+
authMode: "authed",
|
|
71
|
+
body: "<h1>secret</h1>",
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
expect(post.status).toBe(201);
|
|
75
|
+
const { id } = (await post.json()) as { id: string };
|
|
76
|
+
|
|
77
|
+
const res = await fetch(`${BASE}/p/${id}`);
|
|
78
|
+
expect(res.status).toBe(401);
|
|
79
|
+
const body = (await res.json()) as { error: string };
|
|
80
|
+
expect(body.error).toContain("cookie");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("authMode='password' returns 401 stub (step-5 lands the unlock)", async () => {
|
|
84
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers,
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
slug: "pw-stub",
|
|
89
|
+
title: "Password",
|
|
90
|
+
contentType: "text/html",
|
|
91
|
+
authMode: "password",
|
|
92
|
+
password: "swordfish",
|
|
93
|
+
body: "<h1>secret</h1>",
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
expect(post.status).toBe(201);
|
|
97
|
+
const { id } = (await post.json()) as { id: string };
|
|
98
|
+
|
|
99
|
+
const res = await fetch(`${BASE}/p/${id}`);
|
|
100
|
+
expect(res.status).toBe(401);
|
|
101
|
+
});
|
|
102
|
+
});
|