Public: anyone can read this; the crew can change it.
A signed-in person links and unlinks their open-platform.sh identity from account settings #2864
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2596-a-signed-in-person-links-and-unlinks-the → main 2 commits fork 6 threads · 6 unresolved
README.md +5
| @@ -368,6 +368,11 @@ matches; people then sign in with a password or a passkey. An admin with | ||
| 368 | 368 | neither is refused, so no one locks themselves out. Accounts the edge made |
| 369 | 369 | cannot sign in while it is off until they add a passkey (a password-less |
| 370 | 370 | account cannot set a password: `/settings/password` asks for the current one). |
| 371 | Account settings show "open-platform.sh: linked as <login>" or "not linked": Link | |
| 372 | records the login the request carries against the signed-in account, Unlink removes | |
| 373 | it (same-origin POSTs). An edge sign-in finds the account by its link first, then by | |
| 374 | name; one login links to one account, and a login that is another account's name | |
| 375 | cannot be linked. The terminal's "What I can do" screen shows the same line and `u` unlinks (SSH carries no edge header, so linking is on the web page only). | |
| 371 | 376 | Mail out |
| 372 | 377 | (the link that proves a newcomer's email) is SMTP submission over TLS, |
| 373 | 378 | configured by the forge-level secrets `MAIL_SMTP_URL` |
src/core/edge_links.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * The link between an account here and the login the platform edge vouches | |
| 3 | * for. The edge signs people in by login; a link is how a person says that | |
| 4 | * login is theirs, so an edge sign-in reaches their account whatever it is | |
| 5 | * called here. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 10 | import { getUserById, getUserByName, type User } from "./users.ts"; | |
| 11 | ||
| 12 | export type LinkError = "invalid_login" | "login_taken" | "already_linked"; | |
| 13 | ||
| 14 | /** The edge login linked to this account, if any. */ | |
| 15 | export function linkedLogin(db: Database, userId: string): string | undefined { | |
| 16 | const row = db | |
| 17 | .query<{ login: string }, [string]>("SELECT login FROM edge_link WHERE user_id = ?") | |
| 18 | .get(userId); | |
| 19 | return row?.login; | |
| 20 | } | |
| 21 | ||
| 22 | /** The account an edge login is linked to, if any. */ | |
| 23 | export function linkedUser(db: Database, login: string): User | undefined { | |
| 24 | const row = db | |
| 25 | .query<{ user_id: string }, [string]>("SELECT user_id FROM edge_link WHERE login = ?") | |
| 26 | .get(login); | |
| 27 | return row === null || row === undefined ? undefined : getUserById(db, row.user_id); | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * Record `login` against the account. Refused when the login is not a | |
| 32 | * plain name, when another account holds it or is called by it (the link | |
| 33 | * would take that account's sign-in), or when this account is linked to a | |
| 34 | * different login already. Linking the same login again is a no-op. | |
| 35 | */ | |
| 36 | export function linkEdgeLogin( | |
| 37 | db: Database, | |
| 38 | user: User, | |
| 39 | login: string, | |
| 40 | ): Result<"linked", LinkError> { | |
| 41 | if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(login)) return err("invalid_login"); | |
| 42 | const held = linkedUser(db, login); | |
| 43 | if (held !== undefined && held.id !== user.id) return err("login_taken"); | |
| 44 | const named = getUserByName(db, login); | |
| 45 | if (named !== undefined && named.id !== user.id) return err("login_taken"); | |
| 46 | const mine = linkedLogin(db, user.id); | |
| 47 | if (mine !== undefined) return mine.toLowerCase() === login.toLowerCase() ? ok("linked") : err("already_linked"); | |
| 48 | db.query("INSERT INTO edge_link (login, user_id) VALUES (?, ?)").run(login, user.id); | |
| 49 | return ok("linked"); | |
| 50 | } | |
| 51 | ||
| 52 | /** Remove the account's link; true when there was one. */ | |
| 53 | export function unlinkEdgeLogin(db: Database, userId: string): boolean { | |
| 54 | return db.query("DELETE FROM edge_link WHERE user_id = ?").run(userId).changes > 0; | |
| 55 | } |
src/core/migrations/20261019170000-edge-links.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | // A person links the platform edge's login to their account (#2596). One | |
| 2 | // edge login belongs to at most one account (the primary key); an account | |
| 3 | // has at most one link (the unique index). Case does not tell logins apart. | |
| 4 | export default ` | |
| 5 | CREATE TABLE edge_link ( | |
| 6 | login TEXT PRIMARY KEY COLLATE NOCASE, | |
| 7 | user_id TEXT NOT NULL UNIQUE REFERENCES user(id) ON DELETE CASCADE, | |
| 8 | at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 9 | ); | |
| 10 | `; |
src/core/users.ts +5 −2
| @@ -451,13 +451,16 @@ export function getUserByName( | ||
| 451 | 451 | |
| 452 | 452 | /** |
| 453 | 453 | * The person behind an identity the platform edge vouched for: found by |
| 454 | * name, never made. The edge vouches for a login, not for a right to an | |
| 454 | * its link first, then by name, never made. The edge vouches for a login, not for a right to an | |
| 455 | 455 | * account here, so an unknown login is signed out and goes to /signup, where |
| 456 | 456 | * the `signup` setting decides. Accounts the edge made before still sign in. |
| 457 | 457 | * A name that is an organization or a crew member here is nobody's login. |
| 458 | 458 | */ |
| 459 | 459 | export function ensureEdgeUser(db: Database, login: string): User | undefined { |
| 460 | const existing = getUserByName(db, login); | |
| 460 | const link = db | |
Low: this repeats |
||
| 461 | .query<{ user_id: string }, [string]>("SELECT user_id FROM edge_link WHERE login = ?") | |
| 462 | .get(login); | |
| 463 | const existing = (link === null ? undefined : getUserById(db, link.user_id)) ?? getUserByName(db, login); | |
| 461 | 464 | return existing && existing.kind === "user" && isActive(existing) |
| 462 | 465 | ? existing |
| 463 | 466 | : undefined; |
src/http/server.ts +4
| @@ -388,6 +388,8 @@ import { | ||
| 388 | 388 | import { |
| 389 | 389 | accountPage, |
| 390 | 390 | notifyUpdate, |
| 391 | edgeLink, | |
| 392 | edgeUnlink, | |
| 391 | 393 | accountUpdate, |
| 392 | 394 | keyCreate, |
| 393 | 395 | keyDelete, |
| @@ -755,6 +757,8 @@ export function buildRouter(): Router<Handler> { | ||
| 755 | 757 | router.add("GET", "/settings", accountPage); |
| 756 | 758 | router.add("POST", "/settings", accountUpdate); |
| 757 | 759 | router.add("POST", "/settings/notify", notifyUpdate); |
| 760 | router.add("POST", "/settings/edge/link", edgeLink); | |
| 761 | router.add("POST", "/settings/edge/unlink", edgeUnlink); | |
| 758 | 762 | router.add("POST", "/settings/email/verify", resendVerifyLink); |
| 759 | 763 | router.add("GET", "/settings/password", passwordPage); |
| 760 | 764 | router.add("POST", "/settings/password", passwordUpdate); |
src/tui/screens/can.ts +20 −5
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * "What I can do" in the terminal, as the account page and the `whoami_can` |
| 3 | * tool have it: the signed-in account's role and its row of the roles table. | |
| 4 | * Read only. | |
| 3 | * tool have it: the signed-in account's role and its row of the roles table, | |
| 4 | * and, where the platform edge signs people in, the open-platform.sh login | |
| 5 | * linked to the account; `u` unlinks it (SSH carries no edge header, so | |
| 6 | * linking is the web page's alone). | |
| 5 | 7 | */ |
| 6 | 8 | |
| 7 | 9 | import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; |
| 10 | import { linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts"; | |
| 11 | import { activeEdgeToken } from "../../http/edge.ts"; | |
| 8 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 13 | import { binding } from "../app/keys.ts"; |
| 10 | 14 | import type { Session } from "../session.ts"; |
| @@ -12,18 +16,29 @@ import { style } from "../term/style.ts"; | ||
| 12 | 16 | import { truncate } from "../term/width.ts"; |
| 13 | 17 | |
| 14 | 18 | export function canScreen(f: Session, ctx: Ctx): Screen { |
| 19 | const edgeLogin = (): string | undefined => { | |
| 20 | if (f.user === undefined || activeEdgeToken(f.app) === undefined) return undefined; | |
| 21 | const linked = linkedLogin(f.app.db, f.user.id); | |
| 22 | return linked === undefined ? "open-platform.sh: not linked" : `open-platform.sh: linked as ${linked}`; | |
| 23 | }; | |
| 15 | 24 | return { |
Low: the help bar always says |
||
| 16 | 25 | title: "What I can do", |
| 17 | keys: [binding(["esc"], "back")], | |
| 26 | keys: [binding(["u"], "unlink open-platform.sh"), binding(["esc"], "back")], | |
| 18 | 27 | render(area, s) { |
| 19 | 28 | const c = ctx.theme.c; |
| 20 | 29 | if (f.user === undefined) return; |
| 21 | 30 | const role = roleOf(f.app.db, f.user); |
| 22 | 31 | const row = rowOf(role); |
| 23 | [`You are ${role} here.`, ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)] | |
| 32 | const login = edgeLogin(); | |
| 33 | [`You are ${role} here.`, ...(login === undefined ? [] : [login]), ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)] | |
| 24 | 34 | .slice(0, Math.max(0, area.h)) |
| 25 | 35 | .forEach((l, i) => s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w)); |
| 26 | 36 | }, |
| 27 | key: () => false, | |
| 37 | key(e) { | |
| 38 | if (e.ctrl || e.alt || e.key !== "u" || f.user === undefined) return false; | |
| 39 | if (activeEdgeToken(f.app) !== undefined && unlinkEdgeLogin(f.app.db, f.user.id)) | |
| 40 | f.app.bus.append({ kind: "account.edge_unlinked", actorId: f.user.id }); | |
| 41 | return true; | |
| 42 | }, | |
| 28 | 43 | }; |
| 29 | 44 | } |
src/web/pages/account.ts +59 −1
| @@ -28,6 +28,8 @@ import { | ||
| 28 | 28 | } from "../../crew/notify.ts"; |
| 29 | 29 | import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts"; |
| 30 | 30 | import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; |
| 31 | import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_links.ts"; | |
| 32 | import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts"; | |
| 31 | 33 | import { listKeys } from "../../core/keys.ts"; |
| 32 | 34 | import { endAccountSession } from "../../actions/signins.ts"; |
| 33 | 35 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| @@ -125,6 +127,62 @@ function notifyView(ctx: Ctx, error?: string): Html { | ||
| 125 | 127 | ); |
| 126 | 128 | } |
| 127 | 129 | |
| 130 | /** open-platform.sh: linked as <login>, or not, with Link and Unlink. */ | |
| 131 | function edgeLinkView(ctx: Ctx): Html { | |
| 132 | const token = activeEdgeToken(ctx.app); | |
| 133 | if (token === undefined) return html``; | |
| 134 | const linked = linkedLogin(ctx.app.db, ctx.auth.user!.id); | |
| 135 | const carried = edgeIdentity(ctx.request, token); | |
| 136 | return card( | |
| 137 | linked !== undefined | |
| 138 | ? html`<p>open-platform.sh: linked as <strong>${linked}</strong></p> | |
| 139 | <form method="post" action="/settings/edge/unlink"> | |
| 140 | ${btn("Unlink", { type: "submit" })} | |
| 141 | </form>` | |
| 142 | : html`<p>open-platform.sh: not linked</p> | |
| 143 | ${carried === undefined | |
| 144 | ? html`<p class="hint">Sign in through open-platform.sh, then come back here to link that login.</p>` | |
| 145 | : html`<form method="post" action="/settings/edge/link"> | |
| 146 | ${btn(`Link ${carried}`, { type: "submit" })} | |
| 147 | </form>`}`, | |
| 148 | { head: html`<h3>open-platform.sh</h3>` }, | |
| 149 | ); | |
| 150 | } | |
| 151 | ||
| 152 | // POST /settings/edge/link | |
| 153 | export async function edgeLink(ctx: Ctx): Promise<Response> { | |
| 154 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 155 | const form = await readForm(ctx, "admin"); | |
| 156 | if (form instanceof Response) return form; | |
| 157 | const login = edgeIdentity(ctx.request, activeEdgeToken(ctx.app)); | |
| 158 | if (login === undefined) | |
| 159 | return flashTo(ctx, "/settings", { | |
| 160 | message: "Sign in through open-platform.sh first, then link.", | |
| 161 | }); | |
| 162 | const linked = linkEdgeLogin(ctx.app.db, ctx.auth.user, login); | |
| 163 | if (!linked.ok) | |
| 164 | return flashTo(ctx, "/settings", { | |
| 165 | message: | |
| 166 | linked.error === "already_linked" | |
| 167 | ? "This account is linked to another open-platform.sh login. Unlink it first." | |
| 168 | : linked.error === "login_taken" | |
| 169 | ? "That open-platform.sh login belongs to another account here." | |
| 170 | : "That open-platform.sh login cannot be linked.", | |
| 171 | }); | |
| 172 | ctx.app.bus.append({ kind: "account.edge_linked", actorId: ctx.auth.user.id }); | |
| 173 | return flashTo(ctx, "/settings", { message: `Linked as ${login}.` }); | |
| 174 | } | |
| 175 | ||
| 176 | // POST /settings/edge/unlink | |
| 177 | export async function edgeUnlink(ctx: Ctx): Promise<Response> { | |
The page's Unlink, and the linked/not-linked status, have no terminal counterpart. Add the status line and an unlink key to the terminal's account screen, test it, and add it to the README. |
||
| 178 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 179 | const form = await readForm(ctx, "admin"); | |
| 180 | if (form instanceof Response) return form; | |
| 181 | if (unlinkEdgeLogin(ctx.app.db, ctx.auth.user.id)) | |
| 182 | ctx.app.bus.append({ kind: "account.edge_unlinked", actorId: ctx.auth.user.id }); | |
| 183 | return flashTo(ctx, "/settings", { message: "Unlinked." }); | |
| 184 | } | |
| 185 | ||
| 128 | 186 | /** Shown above the profile until the email is proven. */ |
| 129 | 187 | function unverifiedNotice(ctx: Ctx): Html { |
| 130 | 188 | const user = ctx.auth.user!; |
| @@ -245,7 +303,7 @@ export function accountPage(ctx: Ctx): Response { | ||
| 245 | 303 | ctx, |
| 246 | 304 | "profile", |
| 247 | 305 | "Settings", |
| 248 | html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(ctx)}`, | |
| 306 | html`${pair}${profileView(ctx, currentProfile(ctx.auth.user))}${edgeLinkView(ctx)}${notifyView(ctx)}`, | |
| 249 | 307 | 200, |
| 250 | 308 | // The everyday form makes its account with a passkey (#1890). |
| 251 | 309 | pair ? passkeyJs : undefined, |
test/edge-link.test.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { linkedLogin } from "../src/core/edge_links.ts"; | |
| 10 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 11 | ||
| 12 | const TOKEN = "edge-secret-0123456789"; | |
| 13 | ||
| 14 | describe("linking an open-platform.sh login from account settings", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let base: string; | |
| 19 | let cookie: Record<string, string>; | |
| 20 | let bobCookie: Record<string, string>; | |
| 21 | let annId: string; | |
| 22 | const flash = (res: Response) => (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); | |
| 23 | const edge = (login: string) => ({ "x-plat-user": login, "x-plat-edge-token": TOKEN }); | |
| 24 | const post = (path: string, who: Record<string, string>, login?: string) => | |
| 25 | fetch(`${base}${path}`, { | |
| 26 | method: "POST", | |
| 27 | redirect: "manual", | |
| 28 | headers: { | |
| 29 | ...who, | |
| 30 | ...(login ? edge(login) : {}), | |
| 31 | origin: "https://forge.example", | |
| 32 | "content-type": "application/x-www-form-urlencoded", | |
| 33 | }, | |
| 34 | body: "", | |
| 35 | }); | |
| 36 | const whoAmI = async (login: string) => | |
| 37 | (await fetch(`${base}/api/v1/user`, { headers: edge(login) })).status === 200 | |
| 38 | ? ((await fetch(`${base}/api/v1/user`, { headers: edge(login) })).json() as Promise<{ username: string }>).then((u) => u.username) | |
| 39 | : undefined; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-edgelink-")); | |
| 43 | app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: TOKEN }); | |
| 44 | servers = await serve(app); | |
| 45 | base = servers.url; | |
| 46 | const root = await seedAdmin(app, { username: "olive", email: "[email protected]" }); | |
| 47 | if (!root.ok) throw new Error(root.error); | |
| 48 | const mk = async (username: string) => { | |
| 49 | const made = await createUser(app.db, { origin: "edge", username, email: `${username}@forge.example` }); | |
| 50 | if (!made.ok) throw new Error(made.error); | |
| 51 | return { id: made.value.id, cookie: `forge_session=${createSession(app.db, made.value.id).token}` }; | |
| 52 | }; | |
| 53 | const ann = await mk("ann"); | |
| 54 | annId = ann.id; | |
| 55 | cookie = { cookie: ann.cookie }; | |
| 56 | bobCookie = { cookie: (await mk("bob")).cookie }; | |
| 57 | }); | |
| 58 | ||
| 59 | afterAll(async () => { | |
| 60 | servers.stop(); | |
| 61 | app.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("settings say not linked, offer Link for the login the request carries", async () => { | |
| 66 | const page = await fetch(`${base}/settings`, { headers: { ...cookie, ...edge("octo") } }).then((r) => r.text()); | |
| 67 | expect(page).toContain("open-platform.sh: not linked"); | |
| 68 | expect(page).toContain("/settings/edge/link"); | |
| 69 | expect(page).toContain("Link octo"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a cross-origin link is refused", async () => { | |
| 73 | const res = await fetch(`${base}/settings/edge/link`, { | |
| 74 | method: "POST", | |
| 75 | redirect: "manual", | |
| 76 | headers: { ...cookie, ...edge("octo"), origin: "https://evil.example" }, | |
| 77 | body: "", | |
| 78 | }); | |
| 79 | expect(res.status).toBeGreaterThanOrEqual(400); | |
| 80 | expect(await whoAmI("octo")).toBeUndefined(); | |
| 81 | }); | |
| 82 | ||
| 83 | test("link, then an edge sign-in reaches the account; unlink, and it does not", async () => { | |
| 84 | expect(await whoAmI("octo")).toBeUndefined(); | |
| 85 | expect((await post("/settings/edge/link", cookie, "octo")).status).toBe(303); | |
| 86 | expect(await whoAmI("octo")).toBe("ann"); | |
| 87 | const page = await fetch(`${base}/settings`, { headers: cookie }).then((r) => r.text()); | |
| 88 | expect(page).toContain("open-platform.sh: linked as"); | |
| 89 | expect(page).toContain("octo"); | |
| 90 | expect((await post("/settings/edge/unlink", cookie)).status).toBe(303); | |
| 91 | expect(await whoAmI("octo")).toBeUndefined(); | |
| 92 | expect(linkedLogin(app.db, (await fetch(`${base}/api/v1/user`, { headers: cookie }).then((r) => r.json()) as { id: string }).id)).toBeUndefined(); | |
| 93 | }); | |
| 94 | ||
| 95 | test("one edge login cannot be linked to two accounts", async () => { | |
| 96 | await post("/settings/edge/link", cookie, "octo"); | |
| 97 | await post("/settings/edge/link", bobCookie, "octo"); | |
| 98 | expect(await whoAmI("octo")).toBe("ann"); | |
| 99 | await post("/settings/edge/unlink", cookie); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a login that names another account cannot be linked", async () => { | |
| 103 | await post("/settings/edge/link", cookie, "bob"); | |
| 104 | expect(await whoAmI("bob")).toBe("bob"); | |
| 105 | }); | |
| 106 | ||
| 107 | test("an account the edge made by name still signs in by name", async () => { | |
| 108 | expect(await whoAmI("bob")).toBe("bob"); | |
| 109 | }); | |
outdated
This passes whether or not anything was linked. Also assert that |
||
| 110 | ||
| 111 | test("a request without the edge's login links nothing", async () => { | |
| 112 | const res = await post("/settings/edge/link", cookie); | |
| 113 | expect(res.status).toBe(303); | |
| 114 | expect(linkedLogin(app.db, annId)).toBeUndefined(); | |
| 115 | const page = await fetch(`${base}/settings`, { headers: { ...cookie, cookie: `${cookie.cookie}; ${flash(res)}` } }).then((r) => r.text()); | |
| 116 | expect(page).toContain("Sign in through open-platform.sh first, then link."); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a second, different login while one is linked is refused", async () => { | |
| 120 | expect((await post("/settings/edge/link", cookie, "octo")).status).toBe(303); | |
| 121 | const res = await post("/settings/edge/link", cookie, "other"); | |
| 122 | expect(linkedLogin(app.db, annId)).toBe("octo"); | |
| 123 | const page = await fetch(`${base}/settings`, { headers: { cookie: `${cookie.cookie}; ${flash(res)}` } }).then((r) => r.text()); | |
| 124 | expect(page).toContain("linked to another open-platform.sh login"); | |
| 125 | expect(await whoAmI("other")).toBeUndefined(); | |
| 126 | }); | |
| 127 | ||
| 128 | test("login matching is case-insensitive", async () => { | |
| 129 | expect((await post("/settings/edge/link", cookie, "Octo")).status).toBe(303); | |
| 130 | expect(linkedLogin(app.db, annId)).toBe("octo"); | |
| 131 | expect(await whoAmI("octo")).toBe("ann"); | |
| 132 | await post("/settings/edge/unlink", cookie); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the card is absent while edge sign-in is off", async () => { | |
| 136 | const plain = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-edgelink-off-")), port: 0, url: "https://forge.example" }); | |
| 137 | const srv = await serve(plain); | |
| 138 | try { | |
| 139 | const made = await createUser(plain.db, { origin: "edge", username: "zed", email: "[email protected]" }); | |
| 140 | if (!made.ok) throw new Error(made.error); | |
| 141 | const page = await fetch(`${srv.url}/settings`, { headers: { cookie: `forge_session=${createSession(plain.db, made.value.id).token}` } }).then((r) => r.text()); | |
| 142 | expect(page).not.toContain("open-platform.sh: "); | |
| 143 | expect(page).not.toContain("/settings/edge/link"); | |
| 144 | } finally { | |
| 145 | srv.stop(); | |
| 146 | plain.close(); | |
| 147 | } | |
| 148 | }); | |
| 149 | }); |
test/tui-can.test.ts +19 −2
| @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | import { linkEdgeLogin, linkedLogin } from "../src/core/edge_links.ts"; | |
| 9 | 10 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 10 | 11 | import { setMember } from "../src/core/roles.ts"; |
| 11 | 12 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| @@ -21,7 +22,7 @@ describe("what I can do in the terminal", () => { | ||
| 21 | 22 | let dataDir: string; |
| 22 | 23 | let forge: Forge; |
| 23 | 24 | |
| 24 | async function shown(username: string): Promise<string> { | |
| 25 | async function shown(username: string, presses: string[] = []): Promise<string> { | |
| 25 | 26 | const terminal = headlessTerminal(140, 32); |
| 26 | 27 | const user = getUserByName(forge.db, username)!; |
| 27 | 28 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; |
| @@ -32,6 +33,8 @@ describe("what I can do in the terminal", () => { | ||
| 32 | 33 | for (const ch of "What I can do") key(ch); |
| 33 | 34 | key("enter"); |
| 34 | 35 | await settleApp(app); |
| 36 | for (const k of presses) key(k); | |
| 37 | await settleApp(app); | |
| 35 | 38 | const text = app.screen.toText(); |
| 36 | 39 | app.stop(); |
| 37 | 40 | return text; |
| @@ -39,7 +42,7 @@ describe("what I can do in the terminal", () => { | ||
| 39 | 42 | |
| 40 | 43 | beforeAll(async () => { |
| 41 | 44 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-can-")); |
| 42 | forge = createApp({ dataDir, port: 0 }); | |
| 45 | forge = createApp({ dataDir, port: 0, edgeToken: "edge-secret-0123456789" }); | |
| 43 | 46 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { |
| 44 | 47 | const made = await createUser(forge.db, { |
| 45 | 48 | origin: "seed", |
| @@ -70,4 +73,18 @@ describe("what I can do in the terminal", () => { | ||
| 70 | 73 | expect(admin).toContain("You are admin here."); |
| 71 | 74 | expect(admin).toContain("Administer the forge: yes"); |
| 72 | 75 | }); |
| 76 | ||
| 77 | test("the screen shows the open-platform.sh link, and u unlinks it and says so on the bus", async () => { | |
| 78 | const bob = getUserByName(forge.db, "bob")!; | |
| 79 | expect(await shown("bob")).toContain("open-platform.sh: not linked"); | |
| 80 | expect(linkEdgeLogin(forge.db, bob, "octo").ok).toBe(true); | |
| 81 | expect(await shown("bob")).toContain("open-platform.sh: linked as octo"); | |
| 82 | const seen: string[] = []; | |
| 83 | const stop = forge.bus.subscribe((e) => seen.push(e.kind)); | |
| 84 | const after = await shown("bob", ["u"]); | |
| 85 | stop(); | |
| 86 | expect(after).toContain("open-platform.sh: not linked"); | |
| 87 | expect(linkedLogin(forge.db, bob.id)).toBeUndefined(); | |
| 88 | expect(seen).toContain("account.edge_unlinked"); | |
| 89 | }); | |
| 73 | 90 | }); |
No test reaches the
already_linkedbranch or the case-insensitive compare. Add one where ann hasoctolinked, tries to linkzedand is refused, and another where linkingOCTOagain is a no-op.