Public: anyone can read this; the crew can change it.

A test pins unlinking a parent-forge sign-in: refused when it is the account's last way in #7141

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7130-a-test-pins-unlinking-a-parent-forge-sig → main 1 commit fork

  • Merged. scout/7130-a-test-pins-unlinking-a-parent-forge-sig was merged into main as a69708a.

Review

Sign in to review this change.
1 file changed +102 −0
test/federation-last-way-in.test.ts added +102
@@ -0,0 +1,102 @@
1 /** Unlinking the parent forge is refused while it is the account's only way in (#7130), in the module and in Settings. */
2
3 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { LAST_WAY_IN, linkParent, linkedParent, unlinkParent } from "../src/core/federation.ts";
9 import { changePassword, createUser } from "../src/core/users.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15 setDefaultTimeout(30_000);
16
17 const ISSUER = "https://parent.test";
18
19 describe("unlinking the parent forge as the last way in", () => {
20 let dataDir = "";
21 let app: App;
22 let servers: Servers;
23
24 /** An account as a first parent sign-in makes it: no password, no passkey, one link. */
25 const parentOnly = async (username: string) => {
26 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test` });
27 if (!made.ok) throw new Error(made.error);
28 const id = made.value.id;
29 expect(linkParent(app.db, id, ISSUER, `${username}-sub`)).toEqual({ ok: true, value: "linked" });
30 return { id, cookie: `forge_session=${createSession(app.db, id).token}` };
31 };
32 const addPasskey = (userId: string) =>
33 app.db
34 .query("INSERT INTO passkey (id, user_id, credential_id, name, alg, key) VALUES (?1, ?2, ?3, 'k', 'ES256', '{}')")
35 .run(`pk-${userId}`, userId, `cred-${userId}`);
36 const unlink = (cookie: string) =>
37 fetch(`${servers.url}/settings/parent/unlink`, {
38 method: "POST",
39 redirect: "manual",
40 headers: { cookie, origin: servers.url },
41 body: "",
42 });
43 /** Settings as the next request shows it, carrying the flash the redirect set. */
44 const settingsAfter = async (res: Response, cookie: string) => {
45 const flash = res.headers.getSetCookie().map((c) => c.split(";")[0]!).find((c) => c.startsWith("forge_flash="));
46 return (await fetch(`${servers.url}/settings`, { headers: { cookie: flash ? `${cookie}; ${flash}` : cookie } })).text();
47 };
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-last-way-in-"));
51 app = createApp({ dataDir, port: 0, parentUrl: ISSUER });
52 servers = await serve(app);
53 }, 60_000);
54 afterAll(async () => {
55 servers.stop();
56 app.close();
57 await rm(dataDir, { recursive: true, force: true });
58 });
59
60 test("with no password and no passkey, unlinking is refused and the link stays", async () => {
61 const { id } = await parentOnly("pat");
62 expect(unlinkParent(app.db, id, ISSUER)).toEqual({ ok: false, error: "last_way_in" });
63 expect(linkedParent(app.db, id, ISSUER)).toBe("pat-sub");
64 });
65
66 test("after a passkey is added, unlinking succeeds", async () => {
67 const { id } = await parentOnly("pam");
68 expect(unlinkParent(app.db, id, ISSUER).ok).toBe(false);
69 addPasskey(id);
70 expect(unlinkParent(app.db, id, ISSUER)).toEqual({ ok: true, value: "unlinked" });
71 expect(linkedParent(app.db, id, ISSUER)).toBeUndefined();
72 });
73
74 test("after a password is set, unlinking succeeds", async () => {
75 const { id } = await parentOnly("pia");
76 expect(unlinkParent(app.db, id, ISSUER).ok).toBe(false);
77 expect((await changePassword(app.db, id, "a-long-federation-passphrase")).ok).toBe(true);
78 expect(unlinkParent(app.db, id, ISSUER)).toEqual({ ok: true, value: "unlinked" });
79 expect(linkedParent(app.db, id, ISSUER)).toBeUndefined();
80 });
81
82 test("Settings says the refusal in words and keeps the link", async () => {
83 const { id, cookie } = await parentOnly("pip");
84 const res = await unlink(cookie);
85 expect(res.status).toBe(303);
86 expect(linkedParent(app.db, id, ISSUER)).toBe("pip-sub");
87 const page = await settingsAfter(res, cookie);
88 expect(page).toContain(LAST_WAY_IN);
89 expect(page).not.toContain("not linked");
90 });
91
92 test("Settings unlinks once a passkey exists", async () => {
93 const { id, cookie } = await parentOnly("pru");
94 addPasskey(id);
95 const res = await unlink(cookie);
96 expect(res.status).toBe(303);
97 expect(linkedParent(app.db, id, ISSUER)).toBeUndefined();
98 const page = await settingsAfter(res, cookie);
99 expect(page).toContain("Unlinked.");
100 expect(page).not.toContain(LAST_WAY_IN);
101 });
102 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs