feat: implement TASK-WP1-02 login sessions

This commit is contained in:
suyx
2026-07-28 16:28:30 +08:00
parent f467e7c09f
commit ee70001d44
20 changed files with 2480 additions and 28 deletions
+3
View File
@@ -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",
+145
View File
@@ -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();
});
});
+40
View File
@@ -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();
});
+43
View File
@@ -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") });
}
});
+88
View File
@@ -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");
});
+283
View File
@@ -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 });
});
});