Public: anyone can read this; the crew can change it.
When signup is closed, no new account is made by any door, except the forge's first #2193
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2166-when-signup-is-closed-no-new-account-is → main 8 commits fork 5 threads · 5 unresolved
README.md +1 −1
Diff not shown — a line 4,879 characters wide (limit 2,000). +1 −1
View filesrc/actions/users.ts +21 −1
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | type User, |
| 36 | 36 | } from "../core/users.ts"; |
| 37 | 37 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 38 | import { getSignup } from "../settings/signup.ts"; | |
| 38 | 39 | import { requireUser } from "./access.ts"; |
| 39 | 40 | |
| 40 | 41 | export interface SignUpInput { |
| @@ -45,7 +46,24 @@ export interface SignUpInput { | ||
| 45 | 46 | humanToken?: string; |
| 46 | 47 | } |
| 47 | 48 | |
| 48 | export type SignUpError = CreateUserError | "human_check" | "passkey_required"; | |
| 49 | export type SignUpError = | |
| 50 | | CreateUserError | |
| 51 | | "human_check" | |
| 52 | | "passkey_required" | |
| 53 | | "signup_closed"; | |
| 54 | ||
| 55 | /** What anyone is told when the forge makes no new accounts. */ | |
| 56 | export const SIGNUP_CLOSED_MESSAGE = "Signup is closed on this forge."; | |
| 57 | ||
| 58 | /** | |
| 59 | * Whether no new account may be made now. Read from the store on every call, | |
| 60 | * so a change takes effect on the next request. `invite` acts as closed until | |
| 61 | * invitations exist (#1143). The forge's first account is never refused: the | |
| 62 | * owner cannot be locked out of a fresh forge by a setting. | |
| 63 | */ | |
| 64 | export function signupClosed(app: App): boolean { | |
| 65 | return getSignup(app.db) !== "open" && hasAnyUser(app.db); | |
| 66 | } | |
| 49 | 67 | |
| 50 | 68 | /** What anyone is told at a door that still offers a password-only sign-up. */ |
| 51 | 69 | export const PASSKEY_REQUIRED_MESSAGE = |
| @@ -84,6 +102,7 @@ export async function signUp( | ||
| 84 | 102 | // when every such account has a passkey (Settings → Passkeys shows who is |
| 85 | 103 | // left) and recovery without a password exists (#1618): then delete this |
| 86 | 104 | // action's password path, `verifyPassword`'s doors and the password column. |
| 105 | if (signupClosed(app)) return err("signup_closed"); | |
The terminal reaches this refusal through |
||
| 87 | 106 | if (app.config.passkeyOnlySignup === true) return err("passkey_required"); |
| 88 | 107 | const secret = app.config.turnstileSecret; |
| 89 | 108 | const bootstrap = from.door === "terminal" && !hasAnyUser(app.db); |
| @@ -131,6 +150,7 @@ export async function signUpWithPasskey( | ||
| 131 | 150 | input: PasskeySignUpInput, |
| 132 | 151 | from: { address?: string | undefined } = {}, |
| 133 | 152 | ): Promise<Result<User, SignUpError | "passkey_taken">> { |
| 153 | if (signupClosed(app)) return err("signup_closed"); | |
| 134 | 154 | const secret = app.config.turnstileSecret; |
| 135 | 155 | if (secret !== undefined) { |
| 136 | 156 | const verify = app.config.verifyHuman ?? turnstileVerifier(secret); |
src/http/api.ts +3
| @@ -154,6 +154,7 @@ import { | ||
| 154 | 154 | HUMAN_CHECK_MESSAGE, |
| 155 | 155 | PASSKEY_REQUIRED_MESSAGE, |
| 156 | 156 | signUp, |
| 157 | SIGNUP_CLOSED_MESSAGE, | |
| 157 | 158 | updateProfile, |
| 158 | 159 | } from "../actions/users.ts"; |
| 159 | 160 | import { listEvents } from "../core/events.ts"; |
| @@ -1203,6 +1204,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1203 | 1204 | }, |
| 1204 | 1205 | { address: clientAddress(ctx.request) }, |
| 1205 | 1206 | ); |
| 1207 | if (!created.ok && created.error === "signup_closed") | |
| 1208 | return apiError("signup_closed", 403, SIGNUP_CLOSED_MESSAGE); | |
| 1206 | 1209 | if (!created.ok && created.error === "passkey_required") |
| 1207 | 1210 | return apiError("passkey_required", 422, PASSKEY_REQUIRED_MESSAGE); |
| 1208 | 1211 | if (!created.ok && created.error === "human_check") |
src/http/server.ts +6 −1
| @@ -60,6 +60,7 @@ import { | ||
| 60 | 60 | verifyPassword, |
| 61 | 61 | } from "../core/users.ts"; |
| 62 | 62 | import { edgeIdentity } from "./edge.ts"; |
| 63 | import { signupClosed } from "../actions/users.ts"; | |
| 63 | 64 | import { liveResponse, readyResponse } from "./health.ts"; |
| 64 | 65 | import { runGit } from "../git/env.ts"; |
| 65 | 66 | import { noteRefusedInternal } from "../kernel/internal_refused.ts"; |
| @@ -490,7 +491,11 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 490 | 491 | const login = edgeIdentity(request, app.config.edgeToken); |
| 491 | 492 | if (login !== undefined) { |
| 492 | 493 | const existed = getUserByName(app.db, login) !== undefined; |
| 493 | const user = await ensureEdgeUser(app.db, login, forgeHost(app.config)); | |
| 494 | // Existing accounts sign in; a new one is made only while signup allows. | |
Good: an unknown edge name is not created while closed. The audit event below still fires only for a new account, so it stays accurate. |
||
| 495 | const user = | |
| 496 | !existed && signupClosed(app) | |
| 497 | ? undefined | |
| 498 | : await ensureEdgeUser(app.db, login, forgeHost(app.config)); | |
| 494 | 499 | if (user) { |
| 495 | 500 | // An account the edge made is audited like one made at /signup. |
| 496 | 501 | if (!existed) { |
src/settings/signup.ts +2 −1
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * Who may make an account: `open`, `invite` or `closed`. Stored in |
| 3 | 3 | * `forge_setting` under `signup`, and every change is kept in |
| 4 | 4 | * `forge_setting_change` (who, when, from what to what). Default `open`. |
| 5 | * Nothing reads it yet: no sign-in or signup path asks it. | |
| 5 | * `/signup`, `POST /api/v1/users`, passkey signup and a first edge sign-in ask | |
| 6 | * it (`signupClosed` in `actions/users.ts`); `invite` acts as closed until #1143. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import type { Database } from "bun:sqlite"; |
src/tui/screens/common.ts +2
| @@ -11,6 +11,7 @@ import type { Ctx, Screen } from "../app/app.ts"; | ||
| 11 | 11 | import { binding, type Binding } from "../app/keys.ts"; |
| 12 | 12 | import type { KeyEvent } from "../term/input.ts"; |
| 13 | 13 | import type { RepoCounts } from "../../core/repos.ts"; |
| 14 | import { SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts"; | |
| 14 | 15 | import type { Session } from "../session.ts"; |
| 15 | 16 | import { G, Screen as Surface, type Rect } from "../term/screen.ts"; |
| 16 | 17 | import { style } from "../term/style.ts"; |
| @@ -814,6 +815,7 @@ export function slug(path: string): string { | ||
| 814 | 815 | |
| 815 | 816 | const MESSAGES: Record<string, string> = { |
| 816 | 817 | not_found: "not found", |
| 818 | signup_closed: SIGNUP_CLOSED_MESSAGE, | |
| 817 | 819 | human_check: "we could not tell you are a person; sign up on the web page", |
| 818 | 820 | passkey_required: "new accounts sign up with a passkey and an email address: open the sign-up page in a browser", |
| 819 | 821 | unauthorized: "sign in first", |
src/tui/screens/signin.ts +13 −5
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * which is the admin — the terminal equivalent of the web's first-run signup. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { signUp } from "../../actions/users.ts"; | |
| 11 | import { SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 12 | 12 | import { hasAnyUser } from "../../core/users.ts"; |
| 13 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 14 | import { binding } from "../app/keys.ts"; |
| @@ -40,6 +40,8 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 40 | 40 | // "Is this forge empty?" is a existence question, not a count: `COUNT(*)` |
| 41 | 41 | // walks the whole user table on the one screen every session paints. |
| 42 | 42 | const first = !hasAnyUser(f.db); |
| 43 | // Closed signup offers no create mode at all, as the page offers no form. | |
| 44 | const closed = signupClosed(f.app); | |
This is read once, when the screen opens. That is safe, because |
||
| 43 | 45 | let mode: "signin" | "create" = first ? "create" : "signin"; |
| 44 | 46 | let values = ["", "", ""]; |
| 45 | 47 | let focus = 0; |
| @@ -50,6 +52,10 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 50 | 52 | |
| 51 | 53 | const submit = (): void => { |
| 52 | 54 | if (busy) return; |
| 55 | if (closed && mode === "create") { | |
| 56 | error = SIGNUP_CLOSED_MESSAGE; | |
| 57 | return; | |
| 58 | } | |
| 53 | 59 | busy = true; |
| 54 | 60 | error = undefined; |
| 55 | 61 | runAsync(ctx, async () => { |
| @@ -100,21 +106,23 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 100 | 106 | keys: [ |
| 101 | 107 | binding(["tab"], "next field"), |
| 102 | 108 | binding(["enter"], mode === "create" ? "create" : "sign in"), |
| 103 | binding(["ctrl+t"], first ? "sign in instead" : "create account"), | |
| 109 | ...(closed ? [] : [binding(["ctrl+t"], first ? "sign in instead" : "create account")]), | |
| 104 | 110 | binding(["esc"], "back"), |
| 105 | 111 | ], |
| 106 | 112 | render(area, s) { |
| 107 | 113 | const c = ctx.theme.c; |
| 108 | 114 | const [head, body] = split(area, "v", [len(2), fill(1)]); |
| 109 | 115 | segmented(s, head!, c, { |
| 110 | items: ["Sign in", "Create account"], | |
| 116 | items: closed ? ["Sign in"] : ["Sign in", "Create account"], | |
| 111 | 117 | active: mode === "create" ? 1 : 0, |
| 112 | 118 | }); |
| 113 | 119 | s.text( |
| 114 | 120 | head!.x, |
| 115 | 121 | head!.y + 1, |
| 116 | 122 | truncate( |
| 117 | first | |
| 123 | closed | |
| 124 | ? SIGNUP_CLOSED_MESSAGE | |
| 125 | : first | |
| 118 | 126 | ? "This forge has no accounts yet — the first one is the admin." |
| 119 | 127 | : `Connected from ${f.identity.remoteAddress} with ${f.identity.fingerprint ?? "no key"}`, |
| 120 | 128 | head!.w, |
| @@ -151,7 +159,7 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 151 | 159 | ctx.setCursor(cursor); |
| 152 | 160 | }, |
| 153 | 161 | key(e) { |
| 154 | if (e.ctrl && e.key === "t") { | |
| 162 | if (e.ctrl && e.key === "t" && !closed) { | |
| 155 | 163 | mode = mode === "create" ? "signin" : "create"; |
| 156 | 164 | values = ["", "", ""]; |
| 157 | 165 | focus = 0; |
src/web/pages/signup-passkey.ts +5 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { newChallenge, verifyRegistration } from "../../auth/webauthn.ts"; |
| 10 | import { plausibleEmail, signUpWithPasskey, HUMAN_CHECK_MESSAGE } from "../../actions/users.ts"; | |
| 10 | import { plausibleEmail, signUpWithPasskey, signupClosed, HUMAN_CHECK_MESSAGE, SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts"; | |
| 11 | 11 | import { createSession } from "../../core/sessions.ts"; |
| 12 | 12 | import { getUserByName } from "../../core/users.ts"; |
| 13 | 13 | import { isSafeName } from "../../git/guard.ts"; |
| @@ -30,6 +30,7 @@ const WORDS: Record<string, string> = { | ||
| 30 | 30 | invalid_email: "That does not look like an email address.", |
| 31 | 31 | human_check: HUMAN_CHECK_MESSAGE, |
| 32 | 32 | passkey_taken: "That passkey is already registered.", |
| 33 | signup_closed: SIGNUP_CLOSED_MESSAGE, | |
| 33 | 34 | }; |
| 34 | 35 | |
| 35 | 36 | /** Whether the name and address are fit to sign up with; else the words. */ |
| @@ -52,6 +53,7 @@ function guard(ctx: Ctx): Response | undefined { | ||
| 52 | 53 | export async function signupOptions(ctx: Ctx): Promise<Response> { |
| 53 | 54 | const denied = guard(ctx); |
| 54 | 55 | if (denied) return denied; |
| 56 | if (signupClosed(ctx.app)) return refuse(403, SIGNUP_CLOSED_MESSAGE); | |
| 55 | 57 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 56 | 58 | if (data === "too_large") return refuse(413, "payload too large"); |
| 57 | 59 | const username = str(data.username).trim(); |
| @@ -71,6 +73,7 @@ export async function signupOptions(ctx: Ctx): Promise<Response> { | ||
| 71 | 73 | export async function signupPasskey(ctx: Ctx): Promise<Response> { |
| 72 | 74 | const denied = guard(ctx); |
| 73 | 75 | if (denied) return denied; |
| 76 | if (signupClosed(ctx.app)) return refuse(403, SIGNUP_CLOSED_MESSAGE); | |
| 74 | 77 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| 75 | 78 | if (data === "too_large") return refuse(413, "payload too large"); |
| 76 | 79 | const username = str(data.username).trim(); |
| @@ -91,7 +94,7 @@ export async function signupPasskey(ctx: Ctx): Promise<Response> { | ||
| 91 | 94 | { address: clientAddress(ctx.request) }, |
| 92 | 95 | ); |
| 93 | 96 | if (!created.ok) |
| 94 | return refuse(created.error === "human_check" ? 422 : 409, WORDS[created.error] ?? "Sign-up did not work. Try again."); | |
| 97 | return refuse(created.error === "human_check" ? 422 : created.error === "signup_closed" ? 403 : 409, WORDS[created.error] ?? "Sign-up did not work. Try again."); | |
| 95 | 98 | const session = createSession(ctx.app.db, created.value.id); |
| 96 | 99 | return json( |
| 97 | 100 | { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" }, |
src/web/pages/site.ts +15 −1
| @@ -15,7 +15,7 @@ import { clientAddress } from "../../http/peer.ts"; | ||
| 15 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 17 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; |
| 18 | import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts"; | |
| 18 | import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; | |
| 19 | 19 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 20 | 20 | import { listPins } from "../../core/pins.ts"; |
| 21 | 21 | import { getReposByIds } from "../../core/repos.ts"; |
| @@ -392,7 +392,20 @@ function siteKey(ctx: Ctx): string | undefined { | ||
| 392 | 392 | return turnstileSecret === undefined ? undefined : turnstileSiteKey; |
| 393 | 393 | } |
| 394 | 394 | |
| 395 | /** The whole of `/signup` while the forge makes no new accounts: a sentence, no form. */ | |
| 396 | function signupClosedPage(ctx: Ctx): Response { | |
| 397 | return page( | |
| 398 | { | |
| 399 | ctx, | |
| 400 | title: "Sign up", | |
| 401 | body: html`<div class="stack"><h1>Sign up</h1><p>${SIGNUP_CLOSED_MESSAGE}</p><p><a href="/signin">Sign in</a></p></div>`, | |
| 402 | }, | |
| 403 | 403, | |
| 404 | ); | |
| 405 | } | |
| 406 | ||
| 395 | 407 | export function signupPage(ctx: Ctx): Response { |
| 408 | if (signupClosed(ctx.app)) return signupClosedPage(ctx); | |
| 396 | 409 | return page({ |
| 397 | 410 | ctx, |
| 398 | 411 | title: "Sign up", |
| @@ -521,6 +534,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 521 | 534 | { address: clientAddress(ctx.request) }, |
| 522 | 535 | ); |
| 523 | 536 | if (!created.ok) { |
| 537 | if (created.error === "signup_closed") return signupClosedPage(ctx); | |
| 524 | 538 | const errors: AuthErrors = { |
| 525 | 539 | invalid_username: { |
| 526 | 540 | username: "Usernames are letters, digits, dots, dashes.", |
test/signup.test.ts +126 −1
| @@ -13,7 +13,7 @@ import { configProblem } from "../src/kernel/preflight.ts"; | ||
| 13 | 13 | import { BOOT_ACTOR, getSignup, setSignup, signupChanges, signupFromEnv } from "../src/settings/signup.ts"; |
| 14 | 14 | import { createSession } from "../src/core/sessions.ts"; |
| 15 | 15 | import { createToken } from "../src/core/tokens.ts"; |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | 18 | |
| 19 | 19 | describe("signup setting", () => { |
| @@ -157,3 +157,128 @@ describe("signup setting", () => { | ||
| 157 | 157 | expect(signupChanges(app.db)[0]).toMatchObject({ to: "open", actorName: "FORGE_SIGNUP at boot" }); |
| 158 | 158 | }); |
| 159 | 159 | }); |
| 160 | ||
| 161 | describe("signup closed (#2166)", () => { | |
| 162 | let dataDir: string; | |
| 163 | let app: App; | |
| 164 | let servers: Servers; | |
| 165 | const EDGE = "edge-secret-0123456789"; | |
| 166 | const post = (body: unknown) => | |
| 167 | fetch(`${servers.url}/api/v1/users`, { | |
| 168 | method: "POST", | |
| 169 | headers: { "content-type": "application/json" }, | |
| 170 | body: JSON.stringify(body), | |
| 171 | }); | |
| 172 | const edge = (user: string) => | |
| 173 | fetch(`${servers.url}/api/v1/user`, { | |
| 174 | headers: { "x-plat-user": user, "x-plat-edge-token": EDGE }, | |
| 175 | }); | |
| 176 | let n = 0; | |
| 177 | const newcomer = () => { | |
| 178 | n += 1; | |
| 179 | return { username: `new${n}`, email: `new${n}@example.com`, password: "a-good-passphrase" }; | |
| 180 | }; | |
| 181 | ||
| 182 | beforeAll(async () => { | |
| 183 | dataDir = await mkdtemp(join(tmpdir(), "forge-signup-closed-")); | |
| 184 | app = createApp({ dataDir, port: 0, edgeToken: EDGE }); | |
| 185 | servers = await serve(app); | |
| 186 | }); | |
| 187 | afterAll(async () => { | |
| 188 | await servers.stop(); | |
| 189 | await rm(dataDir, { recursive: true, force: true }); | |
| 190 | }); | |
| 191 | ||
| 192 | test("the first account is made whatever the setting, by every door", async () => { | |
| 193 | setSignup(app.db, "closed", "boot"); | |
| 194 | const res = await post({ ...newcomer(), username: "founder", email: "[email protected]" }); | |
| 195 | expect(res.status).toBe(201); | |
| 196 | expect(getUserByName(app.db, "founder")?.isAdmin).toBe(true); | |
| 197 | }); | |
| 198 | ||
| 199 | test("a fresh forge's first edge sign-in is made even when closed", async () => { | |
| 200 | const fresh = await mkdtemp(join(tmpdir(), "forge-signup-edge-")); | |
| 201 | const a = createApp({ dataDir: fresh, port: 0, edgeToken: EDGE }); | |
| 202 | const s = await serve(a); | |
| 203 | try { | |
| 204 | setSignup(a.db, "closed", "boot"); | |
| 205 | const res = await fetch(`${s.url}/api/v1/user`, { | |
| 206 | headers: { "x-plat-user": "ada", "x-plat-edge-token": EDGE }, | |
| 207 | }); | |
| 208 | expect(res.status).toBe(200); | |
| 209 | } finally { | |
| 210 | await s.stop(); | |
| 211 | await rm(fresh, { recursive: true, force: true }); | |
| 212 | } | |
| 213 | }); | |
| 214 | ||
| 215 | for (const mode of ["closed", "invite"] as const) { | |
| 216 | test(`${mode}: page is one sentence, API is 403, edge makes no account`, async () => { | |
| 217 | setSignup(app.db, mode, "boot"); | |
| 218 | const page = await fetch(`${servers.url}/signup`); | |
| 219 | const text = await page.text(); | |
| 220 | expect(page.status).toBe(403); | |
| 221 | expect(text).toContain("Signup is closed on this forge."); | |
| 222 | const main = text.slice(text.indexOf("<main"), text.indexOf("</main>")); | |
| 223 | expect(main).not.toContain("<form"); | |
| 224 | expect(main).not.toContain("<input"); | |
| 225 | ||
| 226 | const body = newcomer(); | |
| 227 | const res = await post(body); | |
| 228 | expect(res.status).toBe(403); | |
| 229 | const err = (await res.json()) as { error: string; message: string }; | |
| 230 | expect(err.error).toBe("signup_closed"); | |
| 231 | expect(err.message.length).toBeGreaterThan(0); | |
| 232 | expect(getUserByName(app.db, body.username)).toBeUndefined(); | |
| 233 | ||
| 234 | const form = await fetch(`${servers.url}/signup`, { | |
| 235 | method: "POST", | |
| 236 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 237 | body: new URLSearchParams({ ...newcomer() }), | |
| 238 | }); | |
| 239 | expect(form.status).toBe(403); | |
| 240 | ||
| 241 | const opts = await fetch(`${servers.url}/signup/passkey/options`, { | |
| 242 | method: "POST", | |
| 243 | headers: { "content-type": "application/json", origin: servers.url }, | |
| 244 | body: JSON.stringify({ username: `pk${mode}`, email: `pk${mode}@forge.local` }), | |
| 245 | }); | |
| 246 | expect(opts.status).toBe(403); | |
| 247 | expect(await opts.text()).toContain("closed"); | |
| 248 | const pk = await fetch(`${servers.url}/signup/passkey`, { | |
| 249 | method: "POST", | |
| 250 | headers: { "content-type": "application/json", origin: servers.url }, | |
| 251 | body: JSON.stringify({ username: `pk${mode}`, email: `pk${mode}@forge.local` }), | |
| 252 | }); | |
| 253 | expect(pk.status).toBe(403); | |
| 254 | expect(getUserByName(app.db, `pk${mode}`)).toBeUndefined(); | |
| 255 | ||
| 256 | const stranger = `edge${mode}`; | |
| 257 | expect((await edge(stranger)).status).toBe(401); | |
| 258 | expect(getUserByName(app.db, stranger)).toBeUndefined(); | |
| 259 | }); | |
| 260 | } | |
| 261 | ||
| 262 | test("existing accounts still sign in by the edge while closed", async () => { | |
| 263 | setSignup(app.db, "open", "boot"); | |
| 264 | expect((await edge("returning")).status).toBe(200); | |
| 265 | setSignup(app.db, "closed", "boot"); | |
| 266 | expect((await edge("returning")).status).toBe(200); | |
| 267 | expect((await edge("founder")).status).toBe(200); | |
| 268 | }); | |
| 269 | ||
| 270 | test("open: page has the form and the API makes the account", async () => { | |
| 271 | setSignup(app.db, "open", "boot"); | |
| 272 | const text = await fetch(`${servers.url}/signup`).then((r) => r.text()); | |
| 273 | expect(text).toContain('name="password"'); | |
| 274 | expect((await post(newcomer())).status).toBe(201); | |
| 275 | }); | |
| 276 | ||
| 277 | test("switching back to open takes effect on the next request", async () => { | |
| 278 | setSignup(app.db, "closed", "boot"); | |
| 279 | expect((await post(newcomer())).status).toBe(403); | |
| 280 | setSignup(app.db, "open", "boot"); | |
| 281 | expect((await post(newcomer())).status).toBe(201); | |
| 282 | expect((await edge("latecomer")).status).toBe(200); | |
| 283 | }); | |
| 284 | }); |
test/tui-screens.test.ts +21
| @@ -31,6 +31,7 @@ import { listEvents } from "../src/core/events.ts"; | ||
| 31 | 31 | import { getIssue, listComments } from "../src/core/issues.ts"; |
| 32 | 32 | import { getPull } from "../src/core/pulls.ts"; |
| 33 | 33 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 34 | import { setSignup } from "../src/settings/signup.ts"; | |
| 34 | 35 | import { getUserSetting } from "../src/core/settings.ts"; |
| 35 | 36 | import { noteProtection, resetProtection } from "../src/kernel/protection.ts"; |
| 36 | 37 | import { getUserByName, type User } from "../src/core/users.ts"; |
| @@ -443,6 +444,26 @@ describe("the terminal UI, against a real forge", () => { | ||
| 443 | 444 | h.stop(); |
| 444 | 445 | }); |
| 445 | 446 | |
| 447 | test("a closed forge's terminal offers no Create account and makes none", async () => { | |
| 448 | setSignup(forge.db, "closed", alice.id); | |
| 449 | const h = boot(forge, asGuest()); | |
| 450 | try { | |
| 451 | await h.settle(); | |
| 452 | await h.press("s"); | |
| 453 | expect(h.text()).toContain("Sign in"); | |
| 454 | expect(h.text()).not.toContain("Create account"); | |
| 455 | await h.press("ctrl+t"); | |
| 456 | expect(h.text()).not.toContain("Create account"); | |
| 457 | expect(h.text()).toContain("Username"); | |
| 458 | const refused = await signUp(forge, { username: "carol", email: "[email protected]", password: "a-long-passphrase" }, { address: "127.0.0.1" }); | |
| 459 | expect(refused).toMatchObject({ ok: false, error: "signup_closed" }); | |
| 460 | } finally { | |
| 461 | h.stop(); | |
| 462 | setSignup(forge.db, "open", alice.id); | |
| 463 | } | |
| 464 | expect(getUserByName(forge.db, "carol")).toBeFalsy(); | |
| 465 | }); | |
| 466 | ||
| 446 | 467 | test("signing in links the presenting key and unlocks the private repo", async () => { |
| 447 | 468 | const h = boot(forge, asGuest()); |
| 448 | 469 | // The key this session presents becomes alice's on success, and the |
One predicate for every door, read on each call, with the fresh-forge exception. This is the right shape.