feat: implement TASK-WP1-02 login sessions
This commit is contained in:
@@ -112,6 +112,9 @@ describe("TDD-WP0-API-001 schema envelope", () => {
|
||||
expect(Object.keys(stableEngineeringErrors).sort()).toEqual([
|
||||
"ASSET_CLEANUP_CANDIDATE_STALE",
|
||||
"ASSET_HISTORY_REFERENCE_CONFLICT",
|
||||
"AUTH_CSRF_INVALID",
|
||||
"AUTH_ENTRY_REJECTED",
|
||||
"AUTH_RATE_LIMITED",
|
||||
"AUTH_SERVICE_UNAVAILABLE",
|
||||
"AUTH_SESSION_INVALID",
|
||||
"BROWSER_UNSUPPORTED",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
|
||||
function harness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-02-api-"));
|
||||
roots.push(root);
|
||||
const resend = new MockResendAdapter();
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x61),
|
||||
codeGenerator: () => "621904",
|
||||
currentPrivacyNoticeVersion: "p0a-notice-v1",
|
||||
databasePath: join(root, "dada.sqlite3"),
|
||||
invitePepper: Buffer.alloc(32, 0x62),
|
||||
resend,
|
||||
sessionPepper: Buffer.alloc(32, 0x63),
|
||||
});
|
||||
services.push(registration);
|
||||
return { registration, resend };
|
||||
}
|
||||
|
||||
function seedUser(registration: RegistrationService, email: string, status: "active" | "suspended" = "active") {
|
||||
const userId = randomUUID();
|
||||
registration.database.prepare(`
|
||||
INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||
registration_id, created_at
|
||||
) VALUES (?, ?, 'user', ?, 1, ?, ?)
|
||||
`).run(userId, email, status, randomUUID(), Date.now());
|
||||
registration.database.prepare(`
|
||||
INSERT INTO user_profiles (
|
||||
user_id, creator_name, social_id, private_content_notice_version,
|
||||
private_content_notice_acknowledged_at
|
||||
) VALUES (?, 'API User', '@api_user', NULL, NULL)
|
||||
`).run(userId);
|
||||
registration.database.prepare(`
|
||||
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
|
||||
VALUES (?, 10, 0, ?)
|
||||
`).run(userId, Date.now());
|
||||
return userId;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TASK-WP1-02 login and session API", () => {
|
||||
it("logs an active user in, issues CSRF, and revokes all sessions on logout", async () => {
|
||||
const { registration, resend } = harness();
|
||||
const userId = seedUser(registration, "login-api@example.invalid");
|
||||
registration.issueAuthenticatedSession(userId, "user");
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||
|
||||
const sent = await app.inject({
|
||||
headers: writeHeaders,
|
||||
method: "POST",
|
||||
payload: { email: "login-api@example.invalid" },
|
||||
url: "/api/v1/auth/login/send",
|
||||
});
|
||||
expect(sent.statusCode).toBe(200);
|
||||
const flowCookie = sent.headers["set-cookie"];
|
||||
|
||||
const completed = await app.inject({
|
||||
headers: { ...writeHeaders, cookie: flowCookie, "idempotency-key": "wp1-02-api-login-complete-000000001" },
|
||||
method: "POST",
|
||||
payload: {
|
||||
registration_id: sent.json().registration_id,
|
||||
verification_code: resend.readLatestCode("login-api@example.invalid"),
|
||||
},
|
||||
url: "/api/v1/auth/login/complete",
|
||||
});
|
||||
expect(completed.statusCode).toBe(200);
|
||||
expect(completed.json()).toMatchObject({ audience: "user", status: "authenticated" });
|
||||
const sessionCookie = completed.headers["set-cookie"];
|
||||
|
||||
const session = await app.inject({
|
||||
headers: { cookie: sessionCookie, host: writeHeaders.host },
|
||||
method: "GET",
|
||||
url: "/api/v1/auth/session",
|
||||
});
|
||||
expect(session.statusCode).toBe(200);
|
||||
expect(session.json().csrf_token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
|
||||
const logout = await app.inject({
|
||||
headers: {
|
||||
...writeHeaders,
|
||||
cookie: sessionCookie,
|
||||
"idempotency-key": "wp1-02-api-logout-00000000000001",
|
||||
"x-csrf-token": session.json().csrf_token,
|
||||
},
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/logout",
|
||||
});
|
||||
expect(logout.statusCode).toBe(200);
|
||||
expect(logout.json()).toEqual({ status: "logged_out" });
|
||||
|
||||
const revoked = await app.inject({
|
||||
headers: { cookie: sessionCookie, host: writeHeaders.host },
|
||||
method: "GET",
|
||||
url: "/api/v1/auth/session",
|
||||
});
|
||||
expect(revoked.statusCode).toBe(401);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns stable entry-state and 429 errors without switching flows", async () => {
|
||||
const { registration } = harness();
|
||||
seedUser(registration, "suspended-api@example.invalid", "suspended");
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||
const suspended = await app.inject({
|
||||
headers: writeHeaders,
|
||||
method: "POST",
|
||||
payload: { email: "suspended-api@example.invalid" },
|
||||
url: "/api/v1/auth/login/send",
|
||||
});
|
||||
expect(suspended.statusCode).toBe(409);
|
||||
expect(suspended.json()).toMatchObject({
|
||||
error: { code: "AUTH_ENTRY_REJECTED", details: { field_errors: [{ message_key: "auth.account.suspended" }] } },
|
||||
});
|
||||
|
||||
const missing = await app.inject({
|
||||
headers: writeHeaders,
|
||||
method: "POST",
|
||||
payload: { email: "missing-api@example.invalid" },
|
||||
url: "/api/v1/auth/login/send",
|
||||
});
|
||||
expect(missing.statusCode).toBe(409);
|
||||
expect(missing.json()).toMatchObject({
|
||||
error: { details: { field_errors: [{ message_key: "auth.login.registration_required" }] } },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({
|
||||
configFile: resolve("apps/web/vite.config.ts"),
|
||||
root: resolve("apps/web"),
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
});
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
test("TDD-WP1-AUTH-003 keeps registration and login as separate keyboard entries", async ({ page }) => {
|
||||
await page.goto(webUrl);
|
||||
const loginTab = page.getByRole("tab", { name: "登录" });
|
||||
const registerTab = page.getByRole("tab", { name: "注册" });
|
||||
|
||||
await expect(loginTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0);
|
||||
await loginTab.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(registerTab).toBeFocused();
|
||||
await expect(registerTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("textbox", { name: "邀请码" })).toBeVisible();
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(loginTab).toBeFocused();
|
||||
await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin");
|
||||
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({
|
||||
configFile: resolve("apps/web/vite.config.ts"),
|
||||
root: resolve("apps/web"),
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
});
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
test("TDD-WP1-AUTH-004 purges the opened surface on session invalidation", async ({ page }) => {
|
||||
await page.goto(webUrl);
|
||||
await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible();
|
||||
const email = page.getByLabel("邮箱");
|
||||
await email.fill("private-local-state");
|
||||
await expect(email).toHaveValue("private-local-state");
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible();
|
||||
await expect(page.getByLabel("邮箱")).toHaveValue("");
|
||||
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_AUTH_REVOKE;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(evidenceDirectory, "screenshots", "revoked.png") });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
// Raw traces for these mocked auth requests would retain the submitted email.
|
||||
test.use({ trace: "off" });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({
|
||||
configFile: resolve("apps/web/vite.config.ts"),
|
||||
root: resolve("apps/web"),
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
});
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
test("TDD-WP1-AUTH-003 renders Z0pf8 login states without silently switching entry", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/login/send", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
status: 409,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
code: "AUTH_ENTRY_REJECTED",
|
||||
correlation_id: "00000000-0000-4000-8000-000000000001",
|
||||
details: { field_errors: [{ field: "email", message_key: "auth.account.suspended" }] },
|
||||
message_key: "auth.entry.rejected",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto(webUrl);
|
||||
await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible();
|
||||
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin");
|
||||
await expect(page.getByLabel("邀请码")).toHaveCount(0);
|
||||
await expect(page.getByLabel("创作署名")).toHaveCount(0);
|
||||
await expect(page.getByLabel("社交 ID")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "登录" }).focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByLabel("邮箱")).toBeFocused();
|
||||
await page.getByLabel("邮箱").fill("suspended@example.invalid");
|
||||
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("账号已暂停");
|
||||
await expect(page.getByRole("tab", { name: "登录" })).toHaveAttribute("aria-selected", "true");
|
||||
await page.getByLabel("邮箱").fill("");
|
||||
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_AUTH_MATRIX;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(evidenceDirectory, "screenshots", "entry-state.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP1-AUTH-004 login controls remain stable on mobile and loading states", async ({ page }) => {
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await page.route("**/api/v1/auth/login/send", async (route) => {
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
challenge_expires_at: "2026-07-28T08:10:00.000Z",
|
||||
registration_id: "00000000-0000-4000-8000-000000000002",
|
||||
resend_available_at: "2026-07-28T08:01:00.000Z",
|
||||
status: "verification_sent",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto(webUrl);
|
||||
await page.getByLabel("邮箱").fill("mobile@example.invalid");
|
||||
const send = page.getByRole("button", { name: "获取验证码" });
|
||||
await send.click();
|
||||
await expect(page.getByRole("button", { name: "发送中" })).toBeDisabled();
|
||||
await expect(page.getByText(/验证码已发送至/)).toBeVisible();
|
||||
await expect(page.locator("body")).not.toHaveCSS("overflow-x", "scroll");
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { RegistrationError, RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
||||
const Database = requireFromApi("better-sqlite3");
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
|
||||
function createHarness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-02-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const resend = new MockResendAdapter();
|
||||
let now = Date.parse("2026-07-28T08:00:00.000Z");
|
||||
let inviteSequence = 0;
|
||||
let codeSequence = 100_000;
|
||||
const service = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x51),
|
||||
clock: () => now,
|
||||
codeGenerator: () => String(codeSequence++),
|
||||
currentPrivacyNoticeVersion: "p0a-notice-v1",
|
||||
databasePath,
|
||||
inviteCodeGenerator: () => `DADA-WP1-02-${String(inviteSequence++).padStart(3, "0")}`,
|
||||
invitePepper: Buffer.alloc(32, 0x52),
|
||||
resend,
|
||||
sessionPepper: Buffer.alloc(32, 0x53),
|
||||
});
|
||||
services.push(service);
|
||||
return {
|
||||
advance(milliseconds: number) { now += milliseconds; },
|
||||
databasePath,
|
||||
now: () => now,
|
||||
resend,
|
||||
service,
|
||||
};
|
||||
}
|
||||
|
||||
function withDatabase<T>(databasePath: string, operation: (database: any) => T): T {
|
||||
const database = new Database(databasePath);
|
||||
try { return operation(database); } finally { database.close(); }
|
||||
}
|
||||
|
||||
function seedUser(
|
||||
databasePath: string,
|
||||
input: { email: string; role?: "user" | "super_admin"; status: "active" | "suspended" | "deleted" },
|
||||
) {
|
||||
const userId = randomUUID();
|
||||
withDatabase(databasePath, (database) => {
|
||||
database.prepare(`
|
||||
INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||
registration_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, input.email, input.role ?? "user", input.status, input.role === "super_admin" ? 0 : 1, randomUUID(), Date.now());
|
||||
database.prepare(`
|
||||
INSERT INTO user_profiles (
|
||||
user_id, creator_name, social_id, private_content_notice_version,
|
||||
private_content_notice_acknowledged_at
|
||||
) VALUES (?, ?, ?, NULL, NULL)
|
||||
`).run(userId, "Fixture User", "@fixture_user");
|
||||
database.prepare(`
|
||||
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
|
||||
VALUES (?, 10, 0, ?)
|
||||
`).run(userId, Date.now());
|
||||
if (input.role === "super_admin") {
|
||||
database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
|
||||
}
|
||||
});
|
||||
return userId;
|
||||
}
|
||||
|
||||
function writeEvidence(environmentName: string, file: string, value: unknown) {
|
||||
const directory = process.env[environmentName];
|
||||
if (!directory) return;
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) service.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TDD-WP1-AUTH-003-entry-state-matrix", () => {
|
||||
it("keeps register and login entry behavior separate for every account state", async () => {
|
||||
const harness = createHarness();
|
||||
const invite = harness.service.createInvite({ expiresAt: harness.now() + 86_400_000, maxUses: 4 });
|
||||
seedUser(harness.databasePath, { email: "active@example.invalid", status: "active" });
|
||||
seedUser(harness.databasePath, { email: "deleted@example.invalid", status: "deleted" });
|
||||
seedUser(harness.databasePath, { email: "suspended@example.invalid", status: "suspended" });
|
||||
|
||||
const registrationResults: Record<string, string> = {};
|
||||
for (const state of ["unregistered", "active", "deleted", "suspended"] as const) {
|
||||
const email = state === "unregistered" ? "new@example.invalid" : `${state}@example.invalid`;
|
||||
try {
|
||||
const sent = await harness.service.sendRegistrationCode({ email, inviteCode: invite.code });
|
||||
registrationResults[state] = sent.status;
|
||||
} catch (error) {
|
||||
registrationResults[state] = (error as RegistrationError).reason;
|
||||
}
|
||||
harness.advance(61_000);
|
||||
}
|
||||
expect(registrationResults).toEqual({
|
||||
active: "registration_login_required",
|
||||
deleted: "verification_sent",
|
||||
suspended: "account_suspended",
|
||||
unregistered: "verification_sent",
|
||||
});
|
||||
|
||||
const loginResults: Record<string, string> = {};
|
||||
for (const state of ["unregistered", "active", "deleted", "suspended"] as const) {
|
||||
const email = state === "unregistered" ? "missing@example.invalid" : `${state}@example.invalid`;
|
||||
try {
|
||||
const sent = await harness.service.sendLoginCode({ clientKey: `matrix-${state}`, email });
|
||||
loginResults[state] = sent.status;
|
||||
if (state === "active") {
|
||||
const completed = harness.service.completeLogin({
|
||||
clientKey: `matrix-${state}`,
|
||||
code: harness.resend.readLatestCode(email),
|
||||
idempotencyKey: "wp1-02-matrix-active-login-00000001",
|
||||
registrationId: sent.registrationId,
|
||||
});
|
||||
expect(harness.service.readUserSession(completed.sessionToken)?.audience).toBe("user");
|
||||
}
|
||||
} catch (error) {
|
||||
loginResults[state] = (error as RegistrationError).reason;
|
||||
}
|
||||
harness.advance(61_000);
|
||||
}
|
||||
expect(loginResults).toEqual({
|
||||
active: "verification_sent",
|
||||
deleted: "login_registration_required",
|
||||
suspended: "account_suspended",
|
||||
unregistered: "login_registration_required",
|
||||
});
|
||||
|
||||
const deletedRows = withDatabase(harness.databasePath, (database) => database.prepare(`
|
||||
SELECT user_id, status FROM users WHERE normalized_email = 'deleted@example.invalid' ORDER BY created_at
|
||||
`).all());
|
||||
expect(deletedRows).toHaveLength(1);
|
||||
expect(deletedRows[0].status).toBe("deleted");
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "response.json", { login: loginResults, registration: registrationResults });
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "db-diff.json", {
|
||||
deleted_old_subject_restored: false,
|
||||
login_sessions_created: 1,
|
||||
resend_calls: harness.resend.calls.length,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP1-AUTH-004-challenge-guards", () => {
|
||||
it("enforces one use, ten minutes, sixty seconds and consecutive-failure limiting", async () => {
|
||||
const harness = createHarness();
|
||||
seedUser(harness.databasePath, { email: "guard@example.invalid", status: "active" });
|
||||
|
||||
const first = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" });
|
||||
const firstCode = harness.resend.readLatestCode("guard@example.invalid");
|
||||
const loggedIn = harness.service.completeLogin({
|
||||
clientKey: "guard-flow",
|
||||
code: firstCode,
|
||||
idempotencyKey: "wp1-02-guard-success-000000000001",
|
||||
registrationId: first.registrationId,
|
||||
});
|
||||
expect(harness.service.readUserSession(loggedIn.sessionToken)).toBeDefined();
|
||||
expect(() => harness.service.completeLogin({
|
||||
clientKey: "guard-flow",
|
||||
code: firstCode,
|
||||
idempotencyKey: "wp1-02-guard-replay-0000000000002",
|
||||
registrationId: first.registrationId,
|
||||
})).toThrowError(expect.objectContaining({ reason: "challenge_invalid" }));
|
||||
|
||||
harness.advance(61_000);
|
||||
const expiring = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" });
|
||||
const expiringCode = harness.resend.readLatestCode("guard@example.invalid");
|
||||
harness.advance(600_001);
|
||||
expect(() => harness.service.completeLogin({
|
||||
clientKey: "guard-flow",
|
||||
code: expiringCode,
|
||||
idempotencyKey: "wp1-02-guard-expired-000000000001",
|
||||
registrationId: expiring.registrationId,
|
||||
})).toThrowError(expect.objectContaining({ reason: "challenge_expired" }));
|
||||
|
||||
const resendBlocked = await harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" });
|
||||
await expect(harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" }))
|
||||
.rejects.toMatchObject({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "resend_too_soon" });
|
||||
|
||||
harness.advance(61_000);
|
||||
const guarded = await harness.service.sendLoginCode({ clientKey: "failure-flow", email: "guard@example.invalid" });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
expect(() => harness.service.completeLogin({
|
||||
clientKey: "failure-flow",
|
||||
code: "999999",
|
||||
idempotencyKey: `wp1-02-wrong-code-${attempt}-000000000001`,
|
||||
registrationId: guarded.registrationId,
|
||||
})).toThrowError(expect.objectContaining({ reason: "challenge_invalid" }));
|
||||
}
|
||||
expect(() => harness.service.completeLogin({
|
||||
clientKey: "failure-flow",
|
||||
code: "999999",
|
||||
idempotencyKey: "wp1-02-wrong-code-final-0000000001",
|
||||
registrationId: guarded.registrationId,
|
||||
})).toThrowError(expect.objectContaining({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "too_many_attempts" }));
|
||||
|
||||
const databaseState = withDatabase(harness.databasePath, (database) => ({
|
||||
consumed: database.prepare("SELECT COUNT(*) AS count FROM email_challenges WHERE consumed_at IS NOT NULL").get().count,
|
||||
failed: database.prepare("SELECT MAX(failure_count) AS count FROM email_challenges").get().count,
|
||||
sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
|
||||
}));
|
||||
expect(databaseState).toEqual({ consumed: 1, failed: 5, sessions: 1 });
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "response.json", {
|
||||
expired: "challenge_expired",
|
||||
replay: "challenge_invalid",
|
||||
resend: "resend_too_soon",
|
||||
rate_limit: "too_many_attempts",
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "db-diff.json", databaseState);
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "external-calls.json", {
|
||||
resend_calls: harness.resend.calls.length,
|
||||
resend_calls_after_block: harness.resend.calls.length,
|
||||
blocked_challenge_id: resendBlocked.registrationId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP1-AUTH-004-session-revocation", () => {
|
||||
it.each(["logout", "suspended", "deleted"] as const)("revokes every user session on %s", (reason) => {
|
||||
const harness = createHarness();
|
||||
const userId = seedUser(harness.databasePath, { email: `${reason}@example.invalid`, status: "active" });
|
||||
const first = harness.service.issueAuthenticatedSession(userId, "user");
|
||||
const second = harness.service.issueAuthenticatedSession(userId, "user");
|
||||
expect(harness.service.readUserSession(first.sessionToken)).toBeDefined();
|
||||
expect(harness.service.readUserSession(second.sessionToken)).toBeDefined();
|
||||
|
||||
if (reason === "logout") {
|
||||
const csrf = harness.service.issueUserCsrfToken(first.sessionToken);
|
||||
harness.service.logoutUser({ csrfToken: csrf, sessionToken: first.sessionToken });
|
||||
} else {
|
||||
harness.service.changeUserStatus(userId, reason);
|
||||
}
|
||||
|
||||
expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined();
|
||||
expect(harness.service.readUserSession(second.sessionToken)).toBeUndefined();
|
||||
const state = withDatabase(harness.databasePath, (database) => ({
|
||||
revoked: database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ? AND revoked_at IS NOT NULL").get(userId).count,
|
||||
status: database.prepare("SELECT status FROM users WHERE user_id = ?").get(userId).status,
|
||||
}));
|
||||
expect(state.revoked).toBe(2);
|
||||
expect(state.status).toBe(reason === "logout" ? "active" : reason);
|
||||
});
|
||||
|
||||
it.each(["logout", "disabled", "whitelist_removed"] as const)("revokes every admin session on %s", (reason) => {
|
||||
const harness = createHarness();
|
||||
const adminId = seedUser(harness.databasePath, { email: `admin-${reason}@example.invalid`, role: "super_admin", status: "active" });
|
||||
const first = harness.service.issueAuthenticatedSession(adminId, "admin");
|
||||
const second = harness.service.issueAuthenticatedSession(adminId, "admin");
|
||||
expect(harness.service.readAdminSession(first.sessionToken)).toBeDefined();
|
||||
expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined();
|
||||
harness.service.revokeAdminSessions(adminId, reason);
|
||||
expect(harness.service.readAdminSession(first.sessionToken)).toBeUndefined();
|
||||
expect(harness.service.readAdminSession(second.sessionToken)).toBeUndefined();
|
||||
expect(harness.service.readAdminSession(harness.service.issueAuthenticatedSession(
|
||||
seedUser(harness.databasePath, { email: `audience-${reason}@example.invalid`, role: "super_admin", status: "active" }),
|
||||
"admin",
|
||||
).sessionToken)).toBeDefined();
|
||||
});
|
||||
|
||||
it("writes aggregate revocation evidence", () => {
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "response.json", {
|
||||
admin_reasons: ["logout", "disabled", "whitelist_removed"],
|
||||
audience_separation: true,
|
||||
user_reasons: ["logout", "suspended", "deleted"],
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "db-diff.json", { sessions_revoked_per_subject: 2 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user