mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
8b6a65023f
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.
103 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|