2026-06-21 20:03:32 -06:00
|
|
|
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
|
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
|
|
|
|
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
2026-08-01 17:05:22 +02:00
|
|
|
// Mock the OPAQUE WASM client so `login()`'s Phase 2 silent-migration
|
|
|
|
|
// hook and Phase 3 lookup-then-login flip can exercise the wire path
|
|
|
|
|
// (params + lookup + register or ke1/ke3 handshake) without touching
|
2026-08-01 15:21:42 +02:00
|
|
|
// real WASM in jsdom.
|
|
|
|
|
vi.mock('@serenity-kit/opaque', () => ({
|
|
|
|
|
ready: Promise.resolve(),
|
|
|
|
|
client: {
|
|
|
|
|
startRegistration: vi.fn(() => ({
|
|
|
|
|
clientRegistrationState: 'STATE-R',
|
|
|
|
|
registrationRequest: 'REQ-R'
|
|
|
|
|
})),
|
|
|
|
|
finishRegistration: vi.fn(() => ({
|
|
|
|
|
registrationRecord: 'RECORD-R',
|
|
|
|
|
exportKey: 'EK',
|
|
|
|
|
serverStaticPublicKey: 'SPK'
|
2026-08-01 17:05:22 +02:00
|
|
|
})),
|
|
|
|
|
startLogin: vi.fn(() => ({
|
|
|
|
|
clientLoginState: 'STATE-L',
|
|
|
|
|
startLoginRequest: 'REQ-L'
|
|
|
|
|
})),
|
|
|
|
|
finishLogin: vi.fn(() => ({
|
|
|
|
|
finishLoginRequest: 'REQ-F',
|
|
|
|
|
sessionKey: 'SK',
|
|
|
|
|
exportKey: 'EK',
|
|
|
|
|
serverStaticPublicKey: 'SPK'
|
2026-08-01 15:21:42 +02:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}));
|
2026-06-21 20:03:32 -06:00
|
|
|
import { apiFetch, apiJson } from '$lib/api/client';
|
|
|
|
|
import * as auth from './auth';
|
2026-08-01 15:21:42 +02:00
|
|
|
import { __resetOpaqueParamsCache } from './opaque';
|
2026-06-21 20:03:32 -06:00
|
|
|
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
|
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
|
// Several auth probes use the raw global fetch (NOT apiFetch) on purpose.
|
|
|
|
|
const okRes = { ok: true, status: 200, json: async () => ({}) };
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
2026-08-01 15:21:42 +02:00
|
|
|
// login() dynamically imports the OPAQUE client and calls
|
|
|
|
|
// syncOpaqueEnvelope, which caches /params results in a
|
|
|
|
|
// module-level singleton. Reset it so each test's mock
|
|
|
|
|
// responses drive a fresh /params fetch.
|
|
|
|
|
__resetOpaqueParamsCache();
|
2026-06-21 20:03:32 -06:00
|
|
|
f.mockResolvedValue(okRes);
|
|
|
|
|
j.mockResolvedValue({});
|
|
|
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes));
|
|
|
|
|
});
|
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
|
it('exercises the auth endpoints (success paths)', async () => {
|
|
|
|
|
await auth.fetchMe().catch(() => {});
|
|
|
|
|
await auth.tryRefresh().catch(() => {});
|
|
|
|
|
await auth.login('u', 'p').catch(() => {});
|
|
|
|
|
await auth.getOidcProviders().catch(() => {});
|
|
|
|
|
await auth.getAuthStatus().catch(() => {});
|
|
|
|
|
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
|
|
|
|
await auth.exchangeOidcCode('code').catch(() => {});
|
2026-07-14 01:46:33 +02:00
|
|
|
await auth.register('e@x.test', 'p', 'u').catch(() => {});
|
2026-06-21 20:03:32 -06:00
|
|
|
await auth.sendMagicLink('e@x.test').catch(() => {});
|
|
|
|
|
await auth.logout().catch(() => {});
|
|
|
|
|
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
|
|
|
expect(fc + f.mock.calls.length).toBeGreaterThan(3);
|
|
|
|
|
});
|
|
|
|
|
it('fetchMe returns null when the probe is not ok', async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
'fetch',
|
|
|
|
|
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
|
|
|
|
|
);
|
|
|
|
|
await expect(auth.fetchMe()).resolves.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
it('tryRefresh returns false when the refresh fails', async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
'fetch',
|
|
|
|
|
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
|
|
|
|
|
);
|
|
|
|
|
await expect(auth.tryRefresh()).resolves.toBe(false);
|
|
|
|
|
});
|
2026-08-01 15:21:42 +02:00
|
|
|
|
2026-08-01 17:05:22 +02:00
|
|
|
// ── Phase 2 + 3: OPAQUE lookup, silent migration, and login flip ─────
|
|
|
|
|
//
|
|
|
|
|
// `login()` MUST first probe the server for an OPAQUE envelope via
|
|
|
|
|
// `POST /api/auth/opaque/login/lookup`:
|
|
|
|
|
// - if the response says `hasOpaque: true` → dispatch to
|
|
|
|
|
// `opaqueLogin` (KE1/KE3) and skip the legacy path entirely
|
|
|
|
|
// (Phase 3 cutover);
|
|
|
|
|
// - if `false` → fall through to `POST /api/auth/login` (legacy),
|
|
|
|
|
// then run `syncOpaqueEnvelope(password)` to silently mint the
|
|
|
|
|
// envelope for the NEXT login (Phase 2 silent migration).
|
2026-08-01 15:21:42 +02:00
|
|
|
//
|
2026-08-01 17:05:22 +02:00
|
|
|
// Regression risks these tests guard:
|
|
|
|
|
// - The lookup POST goes missing (Phase 3 flip never fires — every
|
|
|
|
|
// login stays on legacy forever, defeats the OPAQUE substrate).
|
|
|
|
|
// - The silent-migration hook goes missing on the legacy branch
|
|
|
|
|
// (envelopes never get minted, so the lookup always returns
|
|
|
|
|
// false — same net effect as above).
|
|
|
|
|
// - The OPAQUE branch silently falls back to legacy on any WASM
|
|
|
|
|
// hiccup (would mask a wrong passphrase as a network error).
|
2026-08-05 22:16:03 +02:00
|
|
|
it('login flips to OPAQUE when the lookup reports hasOpaque: true (no rotation when KSF matches)', async () => {
|
2026-08-01 17:05:22 +02:00
|
|
|
// Order on the wire, given the current implementation:
|
|
|
|
|
// 1. GET /api/auth/opaque/params (via checkOpaqueAvailable → fetchOpaqueParams)
|
2026-08-05 22:16:03 +02:00
|
|
|
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: true, ksf: <matches /params> }
|
2026-08-01 17:05:22 +02:00
|
|
|
// 3. POST /api/auth/opaque/login/ke1 → { exchangeId, loginResponse }
|
|
|
|
|
// 4. POST /api/auth/opaque/login/ke3 → AuthResponse
|
2026-08-05 22:16:03 +02:00
|
|
|
// The legacy /api/auth/login POST must NOT fire on this branch, AND
|
|
|
|
|
// Phase C's rotation MUST NOT fire because the envelope's KSF matches
|
|
|
|
|
// what /params publishes — nothing to rotate to.
|
|
|
|
|
const paramsKsf = { memoryKib: 8, iterations: 1, parallelism: 1 };
|
2026-08-01 15:21:42 +02:00
|
|
|
f.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
2026-08-01 17:05:22 +02:00
|
|
|
enabled: true,
|
|
|
|
|
ciphersuiteVersion: 1,
|
2026-08-05 22:16:03 +02:00
|
|
|
ksf: paramsKsf
|
2026-08-01 17:05:22 +02:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
2026-08-05 22:16:03 +02:00
|
|
|
json: async () => ({ hasOpaque: true, ksf: paramsKsf })
|
2026-08-01 17:05:22 +02:00
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
|
|
|
access_token: 'at-opaque',
|
|
|
|
|
refresh_token: 'rt-opaque',
|
|
|
|
|
token_type: 'Bearer',
|
|
|
|
|
expires_in: 3600
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const authResponse = await auth.login('alice@example.com', 'correct horse battery staple');
|
|
|
|
|
expect(authResponse.access_token).toBe('at-opaque');
|
|
|
|
|
|
|
|
|
|
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
|
|
|
expect(urls).toEqual([
|
|
|
|
|
'/api/auth/opaque/params',
|
|
|
|
|
'/api/auth/opaque/login/lookup',
|
|
|
|
|
'/api/auth/opaque/login/ke1',
|
|
|
|
|
'/api/auth/opaque/login/ke3'
|
|
|
|
|
]);
|
|
|
|
|
// Legacy MUST NOT run when we took the OPAQUE branch.
|
|
|
|
|
expect(urls).not.toContain('/api/auth/login');
|
2026-08-05 22:16:03 +02:00
|
|
|
// Phase C: no rotation → no register/* calls.
|
|
|
|
|
expect(urls).not.toContain('/api/auth/opaque/register/start');
|
|
|
|
|
expect(urls).not.toContain('/api/auth/opaque/register/finish');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Phase C: silent KSF rotation on OPAQUE login ─────────────────────
|
|
|
|
|
//
|
|
|
|
|
// `login()` MUST fire `syncOpaqueEnvelope(password)` after a successful
|
|
|
|
|
// OPAQUE login when the envelope's stored KSF differs from the server's
|
|
|
|
|
// currently-published KSF (or when the envelope predates per-envelope
|
|
|
|
|
// KSF storage, signalled by `lookup.ksf === null / absent`). Regression
|
|
|
|
|
// this guards: silently ignoring the drift would freeze users on
|
|
|
|
|
// whatever KSF they registered under years ago, making the operator's
|
|
|
|
|
// tuning-defaults knob effectively write-only for existing accounts.
|
|
|
|
|
|
|
|
|
|
it('OPAQUE login triggers silent KSF rotation when envelope KSF drifted from /params', async () => {
|
|
|
|
|
// Wire order:
|
|
|
|
|
// 1. GET /api/auth/opaque/params (via checkOpaqueAvailable — server publishes NEW KSF)
|
|
|
|
|
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: true, ksf: <OLD, drifted from /params> }
|
|
|
|
|
// 3. POST /api/auth/opaque/login/ke1 → { exchangeId, loginResponse } (uses OLD envelope KSF)
|
|
|
|
|
// 4. POST /api/auth/opaque/login/ke3 → AuthResponse
|
|
|
|
|
// 5. POST /api/auth/opaque/register/start ← rotation fires
|
|
|
|
|
// 6. POST /api/auth/opaque/register/finish
|
|
|
|
|
// After (6), the envelope is re-minted under the CURRENT /params
|
|
|
|
|
// KSF, so the user's next login uses the new values.
|
|
|
|
|
const newParamsKsf = { memoryKib: 8, iterations: 1, parallelism: 1 };
|
|
|
|
|
const oldEnvelopeKsf = { memoryKib: 32, iterations: 3, parallelism: 4 };
|
|
|
|
|
f.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ enabled: true, ciphersuiteVersion: 1, ksf: newParamsKsf })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ hasOpaque: true, ksf: oldEnvelopeKsf })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
|
|
|
access_token: 'at-opaque',
|
|
|
|
|
refresh_token: 'rt-opaque',
|
|
|
|
|
token_type: 'Bearer',
|
|
|
|
|
expires_in: 3600
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ registrationResponse: 'RESP-R' })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
|
|
|
|
|
|
|
|
|
const authResponse = await auth.login('alice@example.com', 'pw');
|
|
|
|
|
expect(authResponse.access_token).toBe('at-opaque');
|
|
|
|
|
|
|
|
|
|
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
|
|
|
expect(urls).toEqual([
|
|
|
|
|
'/api/auth/opaque/params',
|
|
|
|
|
'/api/auth/opaque/login/lookup',
|
|
|
|
|
'/api/auth/opaque/login/ke1',
|
|
|
|
|
'/api/auth/opaque/login/ke3',
|
|
|
|
|
'/api/auth/opaque/register/start',
|
|
|
|
|
'/api/auth/opaque/register/finish'
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('OPAQUE login triggers silent rotation when envelope predates per-envelope KSF (ksf null)', async () => {
|
|
|
|
|
// `lookup.ksf` is null/absent → envelope predates migration
|
|
|
|
|
// 20261005000000 → rotation fires so the envelope gets stored under
|
|
|
|
|
// the new per-envelope schema on the next go-round. Same wire
|
|
|
|
|
// sequence as the drift case above.
|
|
|
|
|
f.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
enabled: true,
|
|
|
|
|
ciphersuiteVersion: 1,
|
|
|
|
|
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
// No `ksf` field at all — pre-migration envelope.
|
|
|
|
|
json: async () => ({ hasOpaque: true })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
|
|
|
access_token: 'at',
|
|
|
|
|
refresh_token: 'rt',
|
|
|
|
|
token_type: 'Bearer',
|
|
|
|
|
expires_in: 3600
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ registrationResponse: 'RESP-R' })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
|
|
|
|
|
|
|
|
|
await auth.login('alice@example.com', 'pw');
|
|
|
|
|
|
|
|
|
|
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
|
|
|
expect(urls).toContain('/api/auth/opaque/register/start');
|
|
|
|
|
expect(urls).toContain('/api/auth/opaque/register/finish');
|
2026-08-01 17:05:22 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('login falls back to legacy + silent-migration when hasOpaque: false', async () => {
|
|
|
|
|
// Order on the wire:
|
|
|
|
|
// 1. GET /api/auth/opaque/params
|
|
|
|
|
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: false }
|
|
|
|
|
// 3. POST /api/auth/login → AuthResponse
|
|
|
|
|
// 4. POST /api/auth/opaque/register/start (params is cached — no re-fetch)
|
|
|
|
|
// 5. POST /api/auth/opaque/register/finish
|
|
|
|
|
f.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
enabled: true,
|
|
|
|
|
ciphersuiteVersion: 1,
|
|
|
|
|
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
|
2026-08-01 15:21:42 +02:00
|
|
|
})
|
|
|
|
|
})
|
2026-08-01 17:05:22 +02:00
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ hasOpaque: false })
|
|
|
|
|
})
|
2026-08-01 15:21:42 +02:00
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
2026-08-01 17:05:22 +02:00
|
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
|
|
|
access_token: 'at',
|
|
|
|
|
refresh_token: 'rt',
|
|
|
|
|
token_type: 'Bearer',
|
|
|
|
|
expires_in: 3600
|
2026-08-01 15:21:42 +02:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ registrationResponse: 'RESP-R' })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
|
|
|
|
|
|
|
|
|
const authResponse = await auth.login('alice@example.com', 'correct horse battery staple');
|
|
|
|
|
expect(authResponse.access_token).toBe('at');
|
|
|
|
|
|
|
|
|
|
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
2026-08-01 17:05:22 +02:00
|
|
|
expect(urls).toEqual([
|
|
|
|
|
'/api/auth/opaque/params',
|
|
|
|
|
'/api/auth/opaque/login/lookup',
|
|
|
|
|
'/api/auth/login',
|
|
|
|
|
'/api/auth/opaque/register/start',
|
|
|
|
|
'/api/auth/opaque/register/finish'
|
|
|
|
|
]);
|
2026-08-01 15:21:42 +02:00
|
|
|
});
|
|
|
|
|
|
2026-08-01 17:05:22 +02:00
|
|
|
it('login skips the OPAQUE branch entirely when the substrate is disabled', async () => {
|
|
|
|
|
// /params replies `enabled: false` (mode=off or misconfig). The
|
|
|
|
|
// lookup POST MUST NOT fire (cheap short-circuit inside
|
|
|
|
|
// checkOpaqueAvailable), and the legacy silent-migration hook
|
|
|
|
|
// MUST also short-circuit — no register/start calls either.
|
2026-08-01 15:21:42 +02:00
|
|
|
f.mockResolvedValueOnce({
|
2026-08-01 17:05:22 +02:00
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
enabled: false,
|
|
|
|
|
ciphersuiteVersion: 0,
|
|
|
|
|
ksf: { memoryKib: 0, iterations: 0, parallelism: 0 }
|
|
|
|
|
})
|
|
|
|
|
}).mockResolvedValueOnce({
|
2026-08-01 15:21:42 +02:00
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
|
|
|
access_token: 'at',
|
|
|
|
|
refresh_token: 'rt',
|
|
|
|
|
token_type: 'Bearer',
|
|
|
|
|
expires_in: 3600
|
|
|
|
|
})
|
2026-08-01 17:05:22 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const authResponse = await auth.login('alice@example.com', 'pw');
|
|
|
|
|
expect(authResponse.access_token).toBe('at');
|
|
|
|
|
|
|
|
|
|
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
|
|
|
expect(urls).toEqual(['/api/auth/opaque/params', '/api/auth/login']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('login returns AuthResponse even when silent-migration fails (non-fatal)', async () => {
|
|
|
|
|
// Wire order: /params (enabled=true) → lookup (false) → legacy
|
|
|
|
|
// login (200) → /params is cached, skip → register/start fails
|
|
|
|
|
// with 500 → syncOpaqueEnvelope logs a console.warn and returns,
|
|
|
|
|
// login() still returns the AuthResponse to the caller.
|
|
|
|
|
f.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
enabled: true,
|
|
|
|
|
ciphersuiteVersion: 1,
|
|
|
|
|
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({ hasOpaque: false })
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK',
|
|
|
|
|
json: async () => ({
|
|
|
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
|
|
|
access_token: 'at',
|
|
|
|
|
refresh_token: 'rt',
|
|
|
|
|
token_type: 'Bearer',
|
|
|
|
|
expires_in: 3600
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: false,
|
|
|
|
|
status: 500,
|
|
|
|
|
statusText: 'Internal Server Error',
|
|
|
|
|
json: async () => ({})
|
|
|
|
|
});
|
2026-08-01 15:21:42 +02:00
|
|
|
|
|
|
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
const authResponse = await auth.login('alice@example.com', 'pw');
|
|
|
|
|
expect(authResponse.access_token).toBe('at');
|
|
|
|
|
consoleSpy.mockRestore();
|
|
|
|
|
});
|