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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user