Files
TREK/server/tests/unit/nest/native-auth.service.test.ts
T
Maurice a3b04887a1 fix(native): new lint warnings and the MapLibre protocol type after the dev merge
Awaits that sat in loops in the native-auth tests run together, the hand-off hook marks its navigations as ignored, the native tests drop their non-null assertions, and the offline protocol answers with the data type MapLibre 6.15 expects.
2026-10-25 17:16:02 +01:00

161 lines
6.4 KiB
TypeScript

/**
* NativeAuthService and the auth domain's LoginSessionService behind it, over
* the container and a real in-memory SQLite: a code minted for a challenge
* redeems once, with the matching verifier, into a tracked session, and the
* login lands on the account.
*/
import { UserSessions } from '../../../src/db/entities/UserSessions.entity';
import { LoginSessionService } from '../../../src/nest/auth/login-session.service';
import { NativeAuthModule } from '../../../src/nest/native-auth/native-auth.module';
import { NativeAuthService } from '../../../src/nest/native-auth/native-auth.service';
import {
InMemoryNativeHandoffStore,
MAX_PENDING_HANDOFFS,
NATIVE_HANDOFF_CODE_TTL_MS,
NativeHandoffStore,
type NativeHandoff,
} from '../../../src/nest/native-auth/native-handoff.store';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { countRows, findRow } from '../../helpers/factories/rows';
import { makeUser, readUser } from '../../helpers/factories/users';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const verifier = 'v'.repeat(43);
const challenge = createHash('sha256').update(verifier).digest('hex');
const client = { userAgent: 'TREK iOS' };
const db = createSnapshotTestDb();
let t: TestModule;
let service: NativeAuthService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({
db,
imports: [NativeAuthModule],
overrides: [{ provide: MailerService, useValue: { sendPasswordResetEmail: vi.fn() } }],
});
service = t.get(NativeAuthService);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
afterAll(async () => {
await t.close();
db.close();
});
const refused = expect.objectContaining({ status: 400, response: { error: 'Invalid or expired code' } });
describe('NativeAuthService', () => {
it('NATIVE-SVC-001: redeems a code with the matching verifier into a remembered, tracked session', async () => {
const { user } = await makeUser(orm());
const before = await readUser(orm(), user.id);
const code = await service.issueCode(user.id, challenge);
const session = await service.redeem(code, verifier, client);
expect(session.userId).toBe(user.id);
const claims = jwt.decode(session.token) as { id: number; jti: string; remember?: boolean };
expect(claims.id).toBe(user.id);
expect(claims.remember).toBe(true);
const row = await findRow(orm(), UserSessions, { id: claims.jti });
expect(row?.user_id).toBe(user.id);
expect(row?.user_agent).toBe('TREK iOS');
const after = await readUser(orm(), user.id);
expect(after.login_count).toBe(before.login_count + 1);
expect(after.last_login).toBeTruthy();
});
it('NATIVE-SVC-002: a wrong verifier is refused and burns the code with it', async () => {
const { user } = await makeUser(orm());
const code = await service.issueCode(user.id, challenge);
await expect(service.redeem(code, 'w'.repeat(43), client)).rejects.toEqual(refused);
await expect(service.redeem(code, verifier, client)).rejects.toEqual(refused);
});
it('NATIVE-SVC-003: a code is single use', async () => {
const { user } = await makeUser(orm());
const code = await service.issueCode(user.id, challenge);
await service.redeem(code, verifier, client);
await expect(service.redeem(code, verifier, client)).rejects.toEqual(refused);
});
it('NATIVE-SVC-004: an unknown code is refused', async () => {
await expect(service.redeem('nope', verifier, client)).rejects.toEqual(refused);
});
it('NATIVE-SVC-005: a code older than its ttl is refused', async () => {
const { user } = await makeUser(orm());
vi.useFakeTimers({ toFake: ['Date'] });
const code = await service.issueCode(user.id, challenge);
vi.setSystemTime(Date.now() + NATIVE_HANDOFF_CODE_TTL_MS + 1);
await expect(service.redeem(code, verifier, client)).rejects.toEqual(refused);
});
it('NATIVE-SVC-006: a code whose account is gone mints no session', async () => {
const sessions = await countRows(orm(), UserSessions);
const code = await service.issueCode(987654, challenge);
await expect(service.redeem(code, verifier, client)).rejects.toEqual(refused);
expect(await countRows(orm(), UserSessions)).toBe(sessions);
});
it('NATIVE-SVC-007: a full store refuses to mint another code', async () => {
vi.spyOn(t.get(NativeHandoffStore), 'put').mockResolvedValueOnce(false);
await expect(service.issueCode(1, challenge)).rejects.toEqual(expect.objectContaining({ status: 503 }));
});
it('NATIVE-SVC-008: LoginSessionService answers null for an account that does not exist', async () => {
expect(await t.get(LoginSessionService).signIn(987654, true, client)).toBeNull();
});
});
describe('InMemoryNativeHandoffStore', () => {
const entry = (expiresAt: number): NativeHandoff => ({ userId: 1, challenge, expiresAt });
it('NATIVE-STORE-001: holds at most its cap of live codes', async () => {
const store = new InMemoryNativeHandoffStore();
const later = Date.now() + 60_000;
const stored = await Promise.all(
Array.from({ length: MAX_PENDING_HANDOFFS }, (_, i) => store.put(`c${i}`, entry(later))),
);
expect(stored.every(Boolean)).toBe(true);
expect(await store.put('one-too-many', entry(later))).toBe(false);
});
it('NATIVE-STORE-002: expired codes are dropped on the next write and make room', async () => {
const store = new InMemoryNativeHandoffStore();
const past = Date.now() - 1;
await Promise.all(Array.from({ length: MAX_PENDING_HANDOFFS }, (_, i) => store.put(`c${i}`, entry(past))));
expect(await store.put('fresh', entry(Date.now() + 60_000))).toBe(true);
expect(await store.take('c0')).toBeNull();
expect(await store.take('fresh')).toEqual(expect.objectContaining({ userId: 1 }));
});
it('NATIVE-STORE-003: take removes the entry, expired or not', async () => {
const store = new InMemoryNativeHandoffStore();
await store.put('old', entry(Date.now() + 60_000));
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(Date.now() + 120_000);
expect(await store.take('old')).toBeNull();
vi.useRealTimers();
expect(await store.take('old')).toBeNull();
});
});