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

Sign up with a username and a password; email optional #2951

Merged opened this

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

Merged

scout/forge:scout/2923-sign-up-with-a-username-and-a-password-e → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/2923-sign-up-with-a-username-and-a-password-e was merged into main as 452655c.

Review

Sign in to review this change.
13 files changed +265 −33
README.md +1 −1
@@ -343,7 +343,7 @@ to `/recover`: give the email on your account and, if it is verified, a link
343343 that lasts 30 minutes and works once registers a new passkey and signs you in.
344344 The relying party is the address the browser used (or
345345 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
346 Sign-up is a username, an email address and a passkey (`/signup`, `src/web/pages/signup-passkey.ts`); the running server refuses a password-only sign-up on the form, `POST /api/v1/users` and the terminal (`passkeyOnlySignup`, set in `main.ts`). Accounts that already have a password still sign in. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link.
346 Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link.
347347
348348 A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned
349349 forge's `parent.json`) offers "Continue with <parent>" on `/signin`. The forge
src/actions/users.ts +33 −8
@@ -34,6 +34,7 @@ import {
3434 type ProfilePatch,
3535 type UpdateProfileError,
3636 type User,
37 isStandInEmail,
3738 } from "../core/users.ts";
3839 import { err, ok, type Result } from "../kernel/result.ts";
3940 import { requireUser } from "./access.ts";
@@ -41,7 +42,8 @@ import type { SignupSlot } from "./signup-window.ts";
4142
4243 export interface SignUpInput {
4344 username: string;
44 email: string;
45 /** Optional: absent or blank, the account has none and nothing is mailed. */
46 email?: string;
4547 password: string;
4648 /** The Turnstile token the browser's widget gave; read only when a secret is set. */
4749 humanToken?: string;
@@ -50,6 +52,7 @@ export interface SignUpInput {
5052 export type SignUpError =
5153 | CreateUserError
5254 | "human_check"
55 | "invalid_email"
5356 | "passkey_required"
5457 | "signup_closed";
5558
@@ -79,6 +82,19 @@ export function plausibleEmail(email: string): boolean {
7982 return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
8083 }
8184
85 /** The shortest password a sign-up takes. */
86 export const MIN_SIGNUP_PASSWORD = 12;
87
88 /**
89 * Stands in for the address of an account that gave none: the column is
90 * unique and never empty, and `.invalid` is a name no mail can reach
91 * (the same device the crew's addresses use).
92 */
outdated

Medium: updateProfile (core/users.ts) still accepts [email protected]. Another account can take this stand-in, and so can an old one after a rename, and the settings page shows it as the person's email. Refuse .invalid in updateProfile and render a stand-in as empty.

93 const noEmail = (username: string): string => `${username.toLowerCase()}@no-email.invalid`;
94
95 /** Whether an address is one a person could have given: plausible and not a stand-in. */
96 const givenEmail = (email: string): boolean => plausibleEmail(email) && !isStandInEmail(email);
97
8298 /** What a person is told when the check fails. */
8399 export const HUMAN_CHECK_MESSAGE =
84100 "We could not tell you are a person. Reload the page and try again.";
@@ -101,12 +117,15 @@ export async function signUp(
101117 address?: string | undefined;
102118 },
103119 ): Promise<Result<User, SignUpError>> {
104 // Password sign-in stays for the accounts that have a password. It can go
105 // when every such account has a passkey (Settings → Passkeys shows who is
106 // left) and recovery without a password exists (#1618): then delete this
107 // action's password path, `verifyPasswordThrottled`'s doors and the password column.
120 // A username and a password is a way in that stays, beside the passkey
121 // (#2923; it once was to go, #1618). Email is optional: given, the verify
122 // link goes to it. Either way the account starts unproven: adding and
123 // proving an address in settings is what lets it create and cost.
108124 if (signupClosed(app, from.invite)) return err("signup_closed");
109125 if (app.config.passkeyOnlySignup === true) return err("passkey_required");
126 const email = (input.email ?? "").trim();
127 if (email !== "" && !givenEmail(email)) return err("invalid_email");
128 if (input.password.length < MIN_SIGNUP_PASSWORD) return err("weak_password");
110129 const secret = app.config.turnstileSecret;
111130 // The terminal cannot render a widget, so with a secret set it brings no
112131 // token and is refused, the forge's first account too: sign up on the web page.
@@ -116,8 +135,14 @@ export async function signUp(
116135 if (!(await verify(input.humanToken ?? "", from.address)))
117136 return err("human_check");
118137 }
119 const { humanToken: _token, ...account } = input;
120 const created = await createUser(app.db, { ...account, origin: "signup", slot: from.slot, invite: from.invite });
138 const created = await createUser(app.db, {
139 username: input.username,
140 password: input.password,
141 email: email === "" ? noEmail(input.username) : email,
outdated

High: no email means no verification needed, so leaving out one field skips the verify gate (requireVerified, crew dispatch). Leave these accounts unproven. Adding and proving an email should be what unlocks creating and spending.

142 origin: "signup",
143 slot: from.slot,
144 invite: from.invite,
145 });
121146 if (!created.ok) return created;
122147 app.bus.append({
123148 kind: "user.created",
@@ -130,7 +155,7 @@ export async function signUp(
130155 auditInvite(app, created.value.id);
131156 // The address is proven by the link it is sent; until then nothing it
132157 // does may create or cost.
133 await welcome(app, created.value);
158 if (email !== "") await welcome(app, created.value);
134159 return created;
135160 }
136161
src/app.ts +2 −3
@@ -137,9 +137,8 @@ export interface ForgeConfig {
137137 /**
138138 * Refuse every sign-up that would make an account with only a password:
139139 * the web form, `POST /api/v1/users` and the terminal answer in words,
140 * and `signUpWithPasskey` is the one way in. `main.ts` sets it; the
141 * library default is off so a test can still seed accounts with a
142 * password.
140 * and `signUpWithPasskey` is the one way in. An operator's choice, off
141 * by default: a username and a password sign up (#2923).
143142 */
144143 passkeyOnlySignup?: boolean;
145144 /**
src/core/users.ts +8 −1
@@ -388,6 +388,9 @@ export type UpdateProfileError =
388388 | "invalid_commit_email"
389389 | "no_such_user";
390390
391 /** Whether an address is a stand-in (`.invalid`): no mail reaches it, no person gave it. */
392 export const isStandInEmail = (email: string): boolean => /\.invalid$/i.test(email.trim());
393
391394 /** Change the mutable profile fields. Absent keys are left untouched. */
392395 export function updateProfile(
393396 db: Database,
@@ -396,10 +399,14 @@ export function updateProfile(
396399 ): Result<User, UpdateProfileError> {
397400 const current = getUserById(db, userId);
398401 if (!current || current.kind === "org") return err("no_such_user");
399 const email = patch.email?.trim() ?? current.email;
402 let email = patch.email?.trim() ?? current.email;
403 // The form shows a stand-in as empty; leaving it so keeps it.
404 if (email === "" && isStandInEmail(current.email)) email = current.email;
400405 if (email === "" || email.length > MAX_EMAIL || !email.includes("@"))
401406 return err("invalid_email");
402407 if (email.toLowerCase() !== current.email.toLowerCase()) {
408 // Stand-ins belong to the accounts that gave no address; nobody may take one.
409 if (isStandInEmail(email)) return err("invalid_email");
403410 const taken = db
404411 .query("SELECT 1 FROM user WHERE email = ?1 COLLATE NOCASE AND id != ?2")
405412 .get(email, userId);
src/http/api.ts +2
@@ -1215,6 +1215,8 @@ export function apiRoutes(router: Router<Handler>): void {
12151215 // ── accounts ─────────────────────────────────────────────────────────
12161216
12171217 router.add("POST", "/api/v1/users", async (ctx) => {
1218 // A page on another site cannot sign a visitor up from here.
1219 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
12181220 const slot = signupSlot(ctx);
12191221 if (typeof slot === "number")
12201222 return json(
src/main.ts −2
@@ -219,8 +219,6 @@ const app = createApp({
219219 Number(process.env.FORGE_MAIL_DAILY) > 0
220220 ? { mailDailyCap: Number(process.env.FORGE_MAIL_DAILY) }
221221 : {}),
222 // No door makes a password-only account (#1996).
223 passkeyOnlySignup: true,
224222 // Signup asks Cloudflare Turnstile when its secret is set.
225223 ...(turnstileSecret === ""
226224 ? {}
src/tui/screens/common.ts +1 −1
@@ -840,7 +840,7 @@ const MESSAGES: Record<string, string> = {
840840 email_taken: "that email address is taken",
841841 key_exists: "that key is already registered",
842842 taken: "that name is taken",
843 weak_password: "the password must be at least 10 characters",
843 weak_password: "the password is too short (10 characters at least)",
outdated

Low: password change in the terminal shows this message too, but its minimum is still 10. Give the sign-up refusal its own message.

844844 bad_credentials: "wrong username or password",
845845 throttled: "too many attempts: try again in a minute",
846846 already_forked: "you already have a fork of this repo",
src/tui/screens/signin.ts +8 −3
@@ -35,10 +35,13 @@ const SIGN_IN: Field[] = [
3535
3636 const CREATE: Field[] = [
3737 { label: "Username" },
38 { label: "Email" },
39 { label: "Password", secret: true, hint: "At least 10 characters." },
38 { label: "Email", hint: "Optional." },
39 { label: "Password", secret: true, hint: "At least 12 characters." },
4040 ];
4141
42 /** What sign-up says to a short password; the password change form keeps its own wording. */
43 export const SIGNUP_WEAK_MESSAGE = "the password is too short (sign-up needs 12 characters)";
44
4245 /** While signup is `invite` the create form also asks for the link's token. */
4346 const INVITE_BLANK_MESSAGE = "Paste the token from your invite link.";
4447 const INVITE: Field = { label: "Invite", secret: true, hint: "The token from your invite link." };
@@ -109,7 +112,9 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
109112 );
outdated

This nested ternary with a string literal inline is hard to read. Lift it into a named constant beside INVITE_SPENT_MESSAGE, for example const SIGNUP_WEAK_MESSAGE = "the password is too short (sign-up needs 12 characters)", and branch on it in an if. Then the new terminal test can assert on the constant.

110113 if (!created.ok) {
111114 // With signup = invite a refusal means the token did not open the door.
112 error = needsInvite && created.error === "signup_closed" ? INVITE_SPENT_MESSAGE : errorText(created.error);
115 if (needsInvite && created.error === "signup_closed") error = INVITE_SPENT_MESSAGE;
116 else if (created.error === "weak_password") error = SIGNUP_WEAK_MESSAGE;
117 else error = errorText(created.error);
113118 return;
114119 }
115120 const adopted = f.adopt(created.value);
src/web/pages/account.ts +10 −5
@@ -36,6 +36,7 @@ import { endAccountSession } from "../../actions/signins.ts";
3636 import { listSessions, type SessionInfo } from "../../core/sessions.ts";
3737 import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts";
3838 import type { CommitEmail, User } from "../../core/users.ts";
39 import { isStandInEmail } from "../../core/users.ts";
3940 import { cookieValue, type Ctx } from "../../http/context.ts";
4041 import { sshCommand } from "../../ssh/command.ts";
4142 import {
@@ -100,7 +101,7 @@ interface ProfileForm {
100101 /** The profile form as the account stands now. */
101102 const currentProfile = (user: User): ProfileForm => ({
102103 displayName: user.displayName,
103 email: user.email,
104 email: isStandInEmail(user.email) ? "" : user.email,
104105 commitEmail: user.commitEmail,
105106 });
106107
@@ -224,12 +225,16 @@ function unverifiedNotice(ctx: Ctx): Html {
224225 return card(
225226 html`<p>
226227 Your email is not verified yet. Until it is, you can read and talk,
227 but not make repositories, apps or runs. Follow the link we sent to
228 <strong>${user.email}</strong>, or ask for another.
228 but not make repositories, apps or runs.
229 ${isStandInEmail(user.email)
230 ? html`Add your email below and follow the link we send to it.`
231 : html`Follow the link we sent to <strong>${user.email}</strong>, or ask for another.`}
229232 </p>
230 <form method="post" action="/settings/email/verify">
233 ${isStandInEmail(user.email)
234 ? html``
235 : html`<form method="post" action="/settings/email/verify">
231236 ${btn("Send another link", { type: "submit" })}
232 </form>`,
237 </form>`}`,
233238 { tone: "attn" },
234239 );
235240 }
src/web/pages/site.ts +20 −8
@@ -278,8 +278,8 @@ function authForm(
278278 </div>`,
279279 );
280280
281 // No door makes a password-only account: sign-up is a name, an address and
282 // a passkey, made in the browser (`signup-passkey.ts`).
281 // Only an operator who set `passkeyOnlySignup` gets a form with no
282 // password: a name, an address and a passkey (`signup-passkey.ts`).
283283 const byPasskey = kind === "signup" && ctx.app.config.passkeyOnlySignup === true;
284284
285285 // Sign-in leads with the passkey: one press, nothing typed; the password
@@ -326,7 +326,7 @@ function authForm(
326326 type: "email",
327327 value: values.email ?? "",
328328 autocomplete: "email",
329 required: true,
329 hint: "Optional. Given, we send a link to prove it.",
330330 // A pinned name is not editable, so the keyboard goes to the email.
331331 ...(vouched !== undefined ? { autofocus: true } : {}),
332332 ...(errors.email ? { error: errors.email } : {}),
@@ -340,7 +340,7 @@ function authForm(
340340 autocomplete: kind === "signin" ? "current-password" : "new-password",
341341 required: true,
342342 ...(kind === "signup"
343 ? { minlength: 10, hint: "At least 10 characters." }
343 ? { minlength: 12, hint: "At least 12 characters." }
344344 : {}),
345345 ...(errors.password ? { error: errors.password } : {}),
346346 }),
@@ -359,6 +359,17 @@ function authForm(
359359 <p class="error-note mb" id="passkey-signup-note" role="alert">${errors.password ?? ""}</p>
360360 <noscript><p class="muted small">Signing up needs JavaScript for the passkey.</p></noscript>`,
361361 )}
362 ${when(
363 kind === "signup" && !byPasskey,
364 () => html`<div class="passkey-on">
365 <p class="muted small mb">
366 Or skip the password: a passkey signs you in with a fingerprint,
367 face or device PIN. It needs an email address above.
368 </p>
369 <p class="error-note mb" id="passkey-signup-note" role="alert"></p>
370 <button type="button" id="passkey-signup" class="btn" data-variant="outline">Create account with a passkey</button>

Low: this button is hand-rolled markup rather than the named btn() component. The other passkey buttons on this page do the same because they need an id, so this matches the file and does not block on its own. If btn() can take an id, use it here.

Non-blocking: this is a hand-rolled button. It matches the existing passkey button, but both should move onto btn() once it can take an id.

371 </div>`,
372 )}
362373 ${when(
363374 errors.human,
364375 (message) => html`<p class="error mb" role="alert">${message}</p>`,
@@ -469,7 +480,7 @@ export function signupPage(ctx: Ctx): Response {
469480 title: "Sign up",
470481 body: authForm(ctx, "signup"),
471482 turnstile: siteKey(ctx) !== undefined,
472 ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}),
483 scriptJs: passkeyJs,
473484 });
474485 }
475486
@@ -627,7 +638,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
627638 },
628639 }),
629640 turnstile: siteKey(ctx) !== undefined,
630 ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}),
641 scriptJs: passkeyJs,
631642 },
632643 429,
633644 );
@@ -653,7 +664,8 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
653664 username_taken: { username: "That username is taken." },
654665 username_reserved: { username: "That name is reserved." },
655666 email_taken: { email: "That email already has an account." },
656 weak_password: { password: "Passwords need at least 10 characters." },
667 weak_password: { password: "Passwords need at least 12 characters." },
668 invalid_email: { email: "That does not look like an email address." },
657669 human_check: { human: HUMAN_CHECK_MESSAGE },
658670 passkey_required: { human: PASSKEY_REQUIRED_MESSAGE },
659671 }[created.error] ?? { username: "Something went wrong." };
@@ -663,7 +675,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
663675 title: "Sign up",
664676 body: authForm(ctx, "signup", { values: { username, email }, errors }),
665677 turnstile: siteKey(ctx) !== undefined,
666 ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}),
678 scriptJs: passkeyJs,
667679 },
668680 422,
669681 );
test/signup-password.test.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * Sign-up with a username and a password, email optional (#2923): the
3 * account is made with neither passkey nor email and signs in; a short
4 * password, a closed forge and a cross-origin post are refused; none of it
5 * needs an edge or origin configured.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { requireVerified } from "../src/actions/verify.ts";
13 import { updateProfile } from "../src/core/users.ts";
14 import { emailVerified } from "../src/core/verify.ts";
15 import { getUserByName, verifyPasswordThrottled } from "../src/core/users.ts";
16 import { setLimit } from "../src/settings/limits.ts";
17 import { setSignup } from "../src/settings/signup.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19
20 describe("password sign-up", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let base: string;
25 const mails: { to: string }[] = [];
26
27 const post = (body: unknown, headers: Record<string, string> = {}) =>
28 fetch(`${base}/api/v1/users`, {
29 method: "POST",
30 headers: { "content-type": "application/json", ...headers },
31 body: JSON.stringify(body),
32 });
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-password-"));
36 delete process.env.FORGE_EDGE_TOKEN;
37 app = createApp({
38 dataDir,
39 port: 0,
40 mailer: async (m) => {
41 mails.push({ to: m.to });
42 return true;
43 },
44 });
45 servers = await serve(app);
46 base = servers.url;
47 setLimit(app.db, "signup.per_source", 100, "system");
48 });
49 afterAll(async () => {
50 servers.stop();
51 app.close();
52 await rm(dataDir, { recursive: true, force: true });
53 });
54
55 test("an account with neither passkey nor email signs up and signs in", async () => {
56 const res = await post({ username: "quinn", password: "a-long-passphrase" });
57 expect(res.status).toBe(201);
58 const user = getUserByName(app.db, "quinn")!;
59 expect(user.email.endsWith(".invalid")).toBe(true);
60 expect(emailVerified(app.db, user.id)).toBe(false);
61 expect(requireVerified(app, user)).toEqual({ ok: false, error: "unverified" });
62 expect(mails.length).toBe(0);
63 const hash = app.db.query<{ password_hash: string }, [string]>("SELECT password_hash FROM user WHERE id = ?1").get(user.id)!;
64 expect(hash.password_hash.startsWith("$argon2id$")).toBe(true);
65 const verified = await verifyPasswordThrottled(app.db, { username: "quinn", password: "a-long-passphrase", source: "test" });
66 expect(verified.ok).toBe(true);
67 const basic = await fetch(`${base}/api/v1/user`, {
68 headers: { authorization: `Basic ${btoa("quinn:a-long-passphrase")}` },
69 });
70 expect(basic.status).toBe(200);
71 });
72
73 test("a second account with no email does not collide with the first", async () => {
74 expect((await post({ username: "rae", password: "another-passphrase" })).status).toBe(201);
75 });
76
77 test("a given email gets the verify mail and starts unproven", async () => {
78 const res = await post({ username: "sol", password: "a-long-passphrase", email: "[email protected]" });
79 expect(res.status).toBe(201);
80 expect(mails.map((m) => m.to)).toEqual(["[email protected]"]);
81 expect(emailVerified(app.db, getUserByName(app.db, "sol")!.id)).toBe(false);
82 });
83
84 test("a stand-in address cannot be taken in settings, and shows empty", () => {
85 const rae = getUserByName(app.db, "quinn")!;
86 const other = getUserByName(app.db, "sol") ?? rae;
87 expect(updateProfile(app.db, other.id, { email: rae.email }).ok).toBe(false);
88 expect(updateProfile(app.db, other.id, { email: "[email protected]" }).ok).toBe(false);
89 const kept = updateProfile(app.db, rae.id, { email: "", displayName: "Quinn" });
90 expect(kept.ok && kept.value.email).toBe(rae.email);
91 });
92
93 test("a stand-in address cannot be given, and a malformed one is refused", async () => {
94 expect((await post({ username: "tam", password: "a-long-passphrase", email: "[email protected]" })).status).toBe(422);
95 expect((await post({ username: "tam", password: "a-long-passphrase", email: "nope" })).status).toBe(422);
96 });
97
98 test("a password under 12 characters is refused", async () => {
99 const res = await post({ username: "uma", password: "elevenchars" });
100 expect(res.status).toBe(422);
101 expect(((await res.json()) as { error: string }).error).toBe("weak_password");
102 expect(getUserByName(app.db, "uma")).toBeUndefined();
103 });
104
105 test("the form signs up with no email and sets an HttpOnly session", async () => {
106 const page = await fetch(`${base}/signup`);
107 const text = await page.text();
108 expect(text).toContain('id="passkey-signup"');
109 const res = await fetch(`${base}/signup`, {
110 method: "POST",
111 redirect: "manual",
112 headers: { "content-type": "application/x-www-form-urlencoded", origin: base },
113 body: new URLSearchParams({ username: "vic", email: "", password: "a-long-passphrase" }),
114 });
115 expect(res.status).toBe(303);
116 expect(res.headers.get("set-cookie")).toContain("HttpOnly");
117 expect(getUserByName(app.db, "vic")).toBeDefined();
118 });
119
120 test("a cross-origin post is refused", async () => {
121 const api = await post({ username: "wes", password: "a-long-passphrase" }, { origin: "https://evil.example" });
122 expect(api.status).toBe(403);
123 const form = await fetch(`${base}/signup`, {
124 method: "POST",
125 redirect: "manual",
126 headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" },
127 body: new URLSearchParams({ username: "wes", password: "a-long-passphrase" }),
128 });
129 expect(form.status).toBe(403);
130 expect(getUserByName(app.db, "wes")).toBeUndefined();
131 });
132
133 test("a closed forge refuses", async () => {
134 setSignup(app.db, "closed", "system");
135 const res = await post({ username: "xan", password: "a-long-passphrase" });
136 expect(res.status).toBe(403);
137 expect(((await res.json()) as { error: string }).error).toBe("signup_closed");
138 expect(getUserByName(app.db, "xan")).toBeUndefined();
139 setSignup(app.db, "open", "system");
140 });
141 });
test/tui-signup-edge.test.ts +37
@@ -8,6 +8,8 @@ import { tmpdir } from "node:os";
88 import { join } from "node:path";
99 import { createApp, type App as Forge } from "../src/app.ts";
1010 import { getUserByName } from "../src/core/users.ts";
11 import { emailVerified } from "../src/core/verify.ts";
12 import { SIGNUP_WEAK_MESSAGE } from "../src/tui/screens/signin.ts";
1113 import { createForgeApp } from "../src/tui/forge.ts";
1214 import type { TuiIdentity } from "../src/tui/session.ts";
1315 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -57,3 +59,38 @@ test("with an edge token the terminal sign-up makes no row", async () => {
5759 app.stop();
5860 }
5961 });
62
63 test("without an edge the terminal signs up with a blank email, unverified, and refuses 11 characters", async () => {
64 const plain = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-tui-signup-")), port: 0, url: "https://forge.example", disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
65 const identity: TuiIdentity = { sshUsername: "git", remoteAddress: "198.51.100.8", fingerprint: "SHA256:y", publicKey: KEY };
66 const { app } = createForgeApp(plain, identity, { terminal: headlessTerminal(120, 32), exitHooks: false });
67 app.start();
68 const key = (k: string, ctrl = false): void =>
69 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
70 const type = (t: string): void => [...t].forEach((c) => key(c));
71 try {
72 await settleApp(app);
73 key("s");
74 await settleApp(app);
75 // The forge has no accounts, so the form opens in create mode.
76 expect(app.screen.toText()).toContain("Email");
77 type("nora");
78 key("tab");
79 key("tab"); // email left blank
80 type("elevenchars");
81 key("enter");
82 await settleApp(app);
83 expect(app.screen.toText()).toContain(SIGNUP_WEAK_MESSAGE);
84 expect(getUserByName(plain.db, "nora")).toBeFalsy();
85 type("1"); // twelve now
86 key("enter");
87 await settleApp(app);
88 const user = getUserByName(plain.db, "nora");
89 expect(user).toBeTruthy();
90 expect(emailVerified(plain.db, user!.id)).toBe(false);
91 expect(app.screen.toText()).toContain("signed in as nora");
92 } finally {
93 app.stop();
94 plain.close();
95 }
96 });
test/turnstile.test.ts +2 −1
@@ -192,7 +192,8 @@ describe("turnstile doors", () => {
192192 test("off, the page has no widget and the policy is unchanged", async () => {
193193 const { base } = await boot();
194194 const response = await fetch(`${base}/signup`);
195 expect(await response.text()).not.toContain("cf-turnstile");
195 // The passkey script always names the token field; the widget is the div.
196 expect(await response.text()).not.toContain("class=\"cf-turnstile");
196197 expect(response.headers.get("content-security-policy")).not.toContain("cloudflare");
197198 });
198199

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs