Files
TREK/server/tests/unit/nest/native-auth.controller.test.ts
T
Maurice 8b6a65023f Merge dev into feat/native-apps
Brings the native apps branch up to dev (NestJS-only server, MikroORM
repositories, tracked sessions, per-domain locales, the lint ratchets) and
ports its new code onto that architecture:

- server: /api/auth/native moves onto injected services. The session is
  minted by the auth domain's new LoginSessionService (login bookkeeping and
  SessionsService.issue in one transaction), the one-time codes live behind a
  NativeHandoffStore port with a capped in-memory implementation that drops
  expired codes on write instead of running a timer, both routes declare
  response contracts, and the exchange answers { success: true }. Unit and
  e2e suites run on createTestModule and the ORM factories.
- shared: native strings for az, et, sk and th, native wired into every
  locale, the address placeholder as a full URL.
- client: the account and export changes go into dev's shared hooks
  (useAccountSettings, useTripExport), the native server card is a
  desktop/phone pair on useNativeServer, the hand-off call moves out of the
  page hook into native/, best-effort native failures go through
  reportError instead of the console, and the address screen is known to
  knip and lint:i18n-keys.
- package-lock.json regenerated on Linux.
2026-10-25 17:04:15 +01:00

103 lines
4.8 KiB
TypeScript

/**
* NativeAuthController: the HTTP half of the native sign-in hand-off. The
* handoff route hands the signed-in user's id and the challenge to the
* service; the exchange route is rate-limited per ip before the code is
* touched, sets the remembered session cookie and writes the login row.
*/
import type { AuditService } from '../../../src/nest/audit/audit.service';
import { DomainError } from '../../../src/nest/common/domain-error';
import type { RateLimitService } from '../../../src/nest/common/rate-limit.service';
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
import { NativeAuthController } from '../../../src/nest/native-auth/native-auth.controller';
import type { NativeAuthService } from '../../../src/nest/native-auth/native-auth.service';
import type { User } from '../../../src/types';
import { nativeHandoffResponseSchema, successResponseSchema } from '@trek/shared';
import type { Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const challenge = 'a'.repeat(64);
const verifier = 'v'.repeat(43);
function build(opts: { redeem?: () => Promise<{ token: string; userId: number }>; allowed?: boolean } = {}) {
const nativeAuth = {
issueCode: vi.fn().mockResolvedValue('code-1'),
redeem: vi.fn(opts.redeem ?? (() => Promise.resolve({ token: 'jwt', userId: 7 }))),
};
const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) };
const rl = { check: vi.fn().mockResolvedValue(opts.allowed ?? true) };
const controller = new NativeAuthController(
nativeAuth as unknown as NativeAuthService,
audit as unknown as AuditService,
rl as unknown as RateLimitService,
);
return { controller, nativeAuth, audit, rl };
}
const req = { ip: '10.0.0.1', headers: { 'user-agent': 'TREK Android' } } as unknown as Request;
function makeRes() {
return { cookie: vi.fn() } as unknown as Response & { cookie: ReturnType<typeof vi.fn> };
}
beforeEach(() => vi.clearAllMocks());
describe('NativeAuthController.handoff', () => {
it('NATIVE-CTL-001: issues a code for the signed-in user and the given challenge', async () => {
const { controller, nativeAuth } = build();
expect(await controller.handoff({ challenge }, { id: 7 } as User)).toEqual({ code: 'code-1' });
expect(nativeAuth.issueCode).toHaveBeenCalledWith(7, challenge);
});
});
describe('NativeAuthController.exchange', () => {
it('NATIVE-CTL-002: sets a remembered session cookie, passes the device on and audits the login', async () => {
const { controller, nativeAuth, audit } = build();
const res = makeRes();
expect(await controller.exchange({ code: 'code-1', verifier }, req, res)).toEqual({ success: true });
expect(nativeAuth.redeem).toHaveBeenCalledWith('code-1', verifier, { userAgent: 'TREK Android' });
expect(res.cookie).toHaveBeenCalledWith('trek_session', 'jwt', expect.objectContaining({ httpOnly: true }));
expect(audit.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({ userId: 7, action: 'user.login', details: { method: 'native_app' } }),
);
});
it('NATIVE-CTL-003: a code that does not redeem sets no cookie and writes no login', async () => {
const { controller, audit } = build({
redeem: () => Promise.reject(new DomainError(400, 'Invalid or expired code')),
});
const res = makeRes();
await expect(controller.exchange({ code: 'x', verifier }, req, res)).rejects.toEqual(
expect.objectContaining({ status: 400, response: { error: 'Invalid or expired code' } }),
);
expect(res.cookie).not.toHaveBeenCalled();
expect(audit.writeAudit).not.toHaveBeenCalled();
});
it('NATIVE-CTL-004: answers 429 once the per-ip bucket is exhausted, before touching the code', async () => {
const { controller, nativeAuth, rl } = build({ allowed: false });
await expect(controller.exchange({ code: 'x', verifier }, req, makeRes())).rejects.toEqual(
expect.objectContaining({ status: 429 }),
);
expect(rl.check).toHaveBeenCalledWith('native-exchange', '10.0.0.1', 20, 15 * 60 * 1000, expect.any(Number));
expect(nativeAuth.redeem).not.toHaveBeenCalled();
});
it('NATIVE-CTL-005: buckets requests without an ip under one key', async () => {
const { controller, rl } = build();
await controller.exchange({ code: 'x', verifier }, { headers: {} } as unknown as Request, makeRes());
expect(rl.check).toHaveBeenCalledWith('native-exchange', 'unknown', 20, expect.any(Number), expect.any(Number));
});
it('NATIVE-CTL-006: both routes declare their shared response contracts', () => {
const proto = NativeAuthController.prototype;
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, proto.handoff)).toBe(nativeHandoffResponseSchema);
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, proto.exchange)).toBe(successResponseSchema);
});
});