mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
Adds two assertions to the auth-flow integration test that exercise the
audit-log path:
- after register: expect exactly 1 REGISTER row in auth.security_events
for the new user
- after login: expect exactly 1 LOGIN_SUCCESS row for the same user
This locks in the fix from the previous commit (security.ts ?? null
guard for optional fields) and catches any future regression where
security.logEvent silently swallows a SQL error and the audit log goes
into the void.
Verified by reverting security.ts to the broken pre-fix version and
re-running — the test fails with `Expected: 1, Received: 0` at the
register-audit assert in 211ms instead of taking hours of production
debugging.
Also adds an explicit DELETE FROM auth.security_events to the afterAll
cleanup. The FK from security_events.user_id to auth.users(id) is
ON DELETE CASCADE so it would clean up implicitly anyway, but listing
it explicitly makes the cleanup intent obvious from the test source.
Net: 24 → 26 expects per run. Still ~22s end-to-end on a warm cache.
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
/**
|
|
* End-to-end auth flow integration test.
|
|
*
|
|
* Spins up nothing on its own — assumes the docker-compose.test.yml stack
|
|
* is already running. Run via `./scripts/run-integration-tests.sh` which
|
|
* brings up the stack, applies the encryption-vault SQL migrations, runs
|
|
* this test, then tears the stack down.
|
|
*
|
|
* What this test covers, in order:
|
|
*
|
|
* 1. POST /api/v1/auth/register → user created
|
|
* 2. mana-auth → mana-notify → mailpit → email arrives
|
|
* 3. Extract verify URL from email body
|
|
* 4. GET <verify URL> → email_verified
|
|
* 5. POST /api/v1/auth/login → JWT minted
|
|
* 6. POST /api/v1/auth/validate(JWT) → claims valid
|
|
* 7. GET /api/v1/me/data → user summary
|
|
* 8. POST /api/v1/me/encryption-vault/init → master key
|
|
* 9. GET /api/v1/me/encryption-vault/key → unwrap roundtrip
|
|
* 10. POST /api/v1/auth/logout → success
|
|
* 11. (cleanup) DELETE the test user from postgres
|
|
*
|
|
* Every regression we hit on 2026-04-08 would have been caught here:
|
|
* - missing nanoid dep → step 1 → 500
|
|
* - missing MANA_AUTH_KEK → mana-auth never starts
|
|
* - missing encryption_vaults table → step 8 → 500
|
|
* - wrong cookie name in /login → step 5 → no accessToken
|
|
* - mana-notify SMTP auth fails → step 2 → mailpit times out
|
|
*/
|
|
|
|
import { test, expect, beforeAll, afterAll } from 'bun:test';
|
|
|
|
const AUTH_URL = process.env.AUTH_URL ?? 'http://localhost:3091';
|
|
const MAILPIT_URL = process.env.MAILPIT_URL ?? 'http://localhost:8026';
|
|
|
|
// Postgres connection (for the cleanup step). Reads the env that
|
|
// run-integration-tests.sh sets, falls back to the test stack defaults.
|
|
const PG_DSN =
|
|
process.env.TEST_DATABASE_URL ?? 'postgresql://mana:testpassword@localhost:5443/mana_platform';
|
|
|
|
// Generated per test run so reruns don't collide.
|
|
const TEST_EMAIL = `auth-flow-${Date.now()}@manatest.local`;
|
|
const TEST_PASSWORD = 'TestPassword123!';
|
|
const TEST_NAME = 'Auth Flow';
|
|
|
|
let createdUserId: string | null = null;
|
|
|
|
// ─── Tiny helpers ────────────────────────────────────────────────────
|
|
|
|
async function postJson<T = unknown>(path: string, body: unknown, headers?: HeadersInit) {
|
|
const res = await fetch(`${AUTH_URL}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...headers },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const json = (await res.json().catch(() => ({}))) as T;
|
|
return { status: res.status, json };
|
|
}
|
|
|
|
async function getJson<T = unknown>(url: string, headers?: HeadersInit) {
|
|
const res = await fetch(url, { headers });
|
|
const json = (await res.json().catch(() => ({}))) as T;
|
|
return { status: res.status, json };
|
|
}
|
|
|
|
async function waitForMail(to: string, timeoutMs = 15000): Promise<{ html: string; text: string }> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const list = await fetch(
|
|
`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${to}`)}`
|
|
);
|
|
if (list.ok) {
|
|
const data = (await list.json()) as { messages?: Array<{ ID: string }> };
|
|
if (data.messages && data.messages.length > 0) {
|
|
const id = data.messages[0].ID;
|
|
const full = await fetch(`${MAILPIT_URL}/api/v1/message/${id}`);
|
|
if (full.ok) {
|
|
const msg = (await full.json()) as { HTML?: string; Text?: string };
|
|
return { html: msg.HTML ?? '', text: msg.Text ?? '' };
|
|
}
|
|
}
|
|
}
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
throw new Error(`No email to ${to} arrived within ${timeoutMs}ms`);
|
|
}
|
|
|
|
function extractVerifyUrl(html: string): string {
|
|
// Better Auth's verify URL is /api/auth/verify-email?token=...&callbackURL=...
|
|
// We allow either http://mana-auth:3001 or http://localhost:3091 since the
|
|
// test runs against the host but mana-auth's BASE_URL might be either.
|
|
const match = html.match(/https?:\/\/[^\s"'<>]+\/api\/auth\/verify-email\?[^\s"'<>]+/);
|
|
if (!match) throw new Error(`No verify URL found in email body: ${html.slice(0, 200)}`);
|
|
return match[0];
|
|
}
|
|
|
|
async function pgExec(sql: string): Promise<string> {
|
|
// Shell out to docker exec rather than pulling in a postgres client lib —
|
|
// the test container is the only place this runs and it has docker.
|
|
const proc = Bun.spawn(
|
|
[
|
|
'docker',
|
|
'exec',
|
|
'mana-test-postgres',
|
|
'psql',
|
|
'-U',
|
|
'mana',
|
|
'-d',
|
|
'mana_platform',
|
|
'-t',
|
|
'-A',
|
|
'-c',
|
|
sql,
|
|
],
|
|
{ stdout: 'pipe', stderr: 'pipe' }
|
|
);
|
|
const out = await new Response(proc.stdout).text();
|
|
const err = await new Response(proc.stderr).text();
|
|
const code = await proc.exited;
|
|
if (code !== 0) throw new Error(`psql failed (${code}): ${err}`);
|
|
return out.trim();
|
|
}
|
|
|
|
// Returns the count of audit rows for (user, eventType). Used to assert
|
|
// that the security_events INSERT actually committed — historically this
|
|
// was silently broken because the SQL builder collapsed undefined params
|
|
// into literal nothing, which the catch swallowed as "(non-critical)".
|
|
async function countSecurityEvents(userId: string, eventType: string): Promise<number> {
|
|
const out = await pgExec(
|
|
`SELECT COUNT(*) FROM auth.security_events WHERE user_id = '${userId}' AND event_type = '${eventType}';`
|
|
);
|
|
return parseInt(out, 10);
|
|
}
|
|
|
|
// ─── Cleanup at the end so failed runs don't leak ────────────────────
|
|
|
|
afterAll(async () => {
|
|
if (!createdUserId) return;
|
|
try {
|
|
// security_events would also CASCADE on auth.users delete, but we
|
|
// drop it explicitly so the cleanup is obvious from reading.
|
|
await pgExec(
|
|
`DELETE FROM auth.security_events WHERE user_id = '${createdUserId}';
|
|
DELETE FROM auth.encryption_vault_audit WHERE user_id = '${createdUserId}';
|
|
DELETE FROM auth.encryption_vaults WHERE user_id = '${createdUserId}';
|
|
DELETE FROM auth.users WHERE id = '${createdUserId}';`
|
|
);
|
|
} catch (err) {
|
|
console.warn('cleanup failed:', err);
|
|
}
|
|
});
|
|
|
|
// ─── The test ────────────────────────────────────────────────────────
|
|
|
|
test('full register → verify → login → vault → logout flow', async () => {
|
|
// 1. Register
|
|
const reg = await postJson<{ user?: { id: string } }>('/api/v1/auth/register', {
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
name: TEST_NAME,
|
|
});
|
|
expect(reg.status).toBe(200);
|
|
expect(reg.json.user?.id).toBeTruthy();
|
|
createdUserId = reg.json.user!.id;
|
|
|
|
// 1a. Audit row was actually committed.
|
|
// Catches the bug class where security.logEvent silently swallows
|
|
// a SQL error (e.g. undefined params collapsing the values list)
|
|
// and the audit log goes into the void.
|
|
const registerAuditCount = await countSecurityEvents(createdUserId, 'REGISTER');
|
|
expect(registerAuditCount).toBe(1);
|
|
|
|
// 2. Wait for the verification email to land in mailpit
|
|
const mail = await waitForMail(TEST_EMAIL);
|
|
expect(mail.html.length).toBeGreaterThan(0);
|
|
|
|
// 3. Extract the verify URL
|
|
const verifyUrl = extractVerifyUrl(mail.html);
|
|
|
|
// 4. Hit the verify URL. Better Auth issues a 302 redirect on success.
|
|
// Use manual redirect handling so we can assert the redirect itself.
|
|
//
|
|
// Sometimes Better Auth's verify URL points at the internal docker
|
|
// hostname `http://mana-auth:3001/...`. Rewrite that to the host-bound
|
|
// port so we can actually reach it from outside the docker network.
|
|
const reachableVerifyUrl = verifyUrl.replace('http://mana-auth:3001', AUTH_URL);
|
|
const verifyRes = await fetch(reachableVerifyUrl, { redirect: 'manual' });
|
|
expect([200, 302, 303]).toContain(verifyRes.status);
|
|
|
|
// Belt-and-suspenders: confirm the DB row actually flipped.
|
|
const verified = await pgExec(
|
|
`SELECT email_verified FROM auth.users WHERE id = '${createdUserId}';`
|
|
);
|
|
expect(verified).toBe('t');
|
|
|
|
// 5. Login. Expect accessToken (the JWT) and refreshToken (the session token).
|
|
const login = await postJson<{
|
|
user?: { id: string };
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
}>('/api/v1/auth/login', {
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
});
|
|
expect(login.status).toBe(200);
|
|
expect(login.json.user?.id).toBe(createdUserId);
|
|
expect(login.json.accessToken).toBeTruthy();
|
|
expect(login.json.accessToken!.split('.').length).toBe(3); // JWT has 3 segments
|
|
expect(login.json.refreshToken).toBeTruthy();
|
|
const jwt = login.json.accessToken!;
|
|
|
|
// 5a. Audit row for the successful login was committed too.
|
|
const loginAuditCount = await countSecurityEvents(createdUserId, 'LOGIN_SUCCESS');
|
|
expect(loginAuditCount).toBe(1);
|
|
|
|
// 6. Validate the JWT against the same service that minted it
|
|
const validate = await postJson<{ valid: boolean; payload?: { sub: string; email: string } }>(
|
|
'/api/v1/auth/validate',
|
|
{ token: jwt }
|
|
);
|
|
expect(validate.status).toBe(200);
|
|
expect(validate.json.valid).toBe(true);
|
|
expect(validate.json.payload?.sub).toBe(createdUserId);
|
|
expect(validate.json.payload?.email).toBe(TEST_EMAIL);
|
|
|
|
// 7. /me/data round-trip — exercises JWT auth middleware end-to-end
|
|
const me = await getJson<{ user?: { id: string; email: string } }>(`${AUTH_URL}/api/v1/me/data`, {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(me.status).toBe(200);
|
|
expect(me.json.user?.id).toBe(createdUserId);
|
|
|
|
// 8. Encryption vault init — exercises nanoid + MANA_AUTH_KEK + the
|
|
// auth.encryption_vaults / auth.encryption_vault_audit tables
|
|
const vaultInit = await postJson<{ masterKey?: string; kekId?: string; formatVersion?: number }>(
|
|
'/api/v1/me/encryption-vault/init',
|
|
{},
|
|
{ Authorization: `Bearer ${jwt}` }
|
|
);
|
|
expect(vaultInit.status).toBe(200);
|
|
expect(vaultInit.json.masterKey).toBeTruthy();
|
|
expect(vaultInit.json.formatVersion).toBe(1);
|
|
expect(vaultInit.json.kekId).toBeTruthy();
|
|
const mintedKey = vaultInit.json.masterKey!;
|
|
|
|
// 9. Vault key retrieval — should return the same master key
|
|
const vaultKey = await getJson<{ masterKey?: string }>(
|
|
`${AUTH_URL}/api/v1/me/encryption-vault/key`,
|
|
{ Authorization: `Bearer ${jwt}` }
|
|
);
|
|
expect(vaultKey.status).toBe(200);
|
|
expect(vaultKey.json.masterKey).toBe(mintedKey);
|
|
|
|
// 10. Logout
|
|
const logout = await postJson<{ success?: boolean }>(
|
|
'/api/v1/auth/logout',
|
|
{},
|
|
{ Authorization: `Bearer ${jwt}` }
|
|
);
|
|
expect(logout.status).toBe(200);
|
|
expect(logout.json.success).toBe(true);
|
|
});
|