diff --git a/server/tests/helpers/dto.ts b/server/tests/helpers/dto.ts new file mode 100644 index 000000000..d6833731a --- /dev/null +++ b/server/tests/helpers/dto.ts @@ -0,0 +1,16 @@ +/** + * A request body the case under test does not read. + * + * Controller unit tests stub the service and assert on the mapping around it: + * which status an error becomes, whether the cookie is set, which audit fires. + * The body never reaches validation on that path, because the global + * ZodValidationPipe is not in the unit harness, so filling in plausible fields + * would be inventing data no assertion looks at. + * + * Written as a named helper rather than a bare `as` cast so the intent is + * greppable: `anyBody()` means "irrelevant here", not "we gave up on the type". + * Anything a case DOES read belongs in the argument. + */ +export function anyBody(body: Partial = {}): T { + return body as T; +} diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index 4ede80832..77390c5b9 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -16,6 +16,8 @@ import type { AuthService } from '../../../src/nest/auth/auth.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; import { isDemoEmail } from '../../../src/nest/common/demo'; import type { User } from '../../../src/types'; +import { anyBody } from '../../helpers/dto'; +import type { ForgotPasswordDto } from '../../../src/nest/auth/auth.dto'; const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; const req = { ip: '9.9.9.9', headers: {} } as Request; @@ -89,7 +91,7 @@ describe('AuthPublicController', () => { }); it('register audits + sets cookie; maps error', () => { - expect(thrown(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register({}, req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); + expect(thrown(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); const setAuthCookie = vi.fn(); const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial), rl()); expect(c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user }); @@ -107,13 +109,13 @@ describe('AuthPublicController', () => { it('login: mfa branch, success cookie, error mapping', async () => { const setAuthCookie = vi.fn(); const mfa = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial), rl()); - expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); + expect(await mfa.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); const ok = apc(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial), rl()); - expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user }); + expect(await ok.login(anyBody(), req, res)).toEqual({ token: 'tk', user }); // The "remember me" flag from the service rides through to the cookie service. expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true); const bad = apc(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial), rl()); - expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } }); + expect(await thrownAsync(() => bad.login(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } }); }, 10000); it('forgot-password issues a reset email then returns the generic ok', async () => { @@ -124,10 +126,10 @@ describe('AuthPublicController', () => { }, 10000); it('reset-password: error audits a fail, mfa branch, success', () => { - expect(thrown(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword({}, req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); + expect(thrown(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' })); - expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword({}, req)).toEqual({ mfa_required: true }); - expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword({}, req)).toEqual({ success: true }); + expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true }); + expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ success: true }); }); it('app-config forwards the optional user (present and absent)', () => { @@ -147,7 +149,7 @@ describe('AuthPublicController', () => { it('login takes the mfa-required branch and never sets a cookie', async () => { const setAuthCookie = vi.fn(); const c = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial), rl()); - expect(await c.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); + expect(await c.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); expect(setAuthCookie).not.toHaveBeenCalled(); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' })); }, 10000); @@ -169,7 +171,7 @@ describe('AuthPublicController', () => { it('forgot-password ignores a non-string email body', async () => { const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null }); const c = apc(asvc({ requestPasswordReset } as Partial), rl()); - expect(await c.forgotPassword({ email: 42 } as { email?: unknown }, req)).toEqual({ ok: true }); + expect(await c.forgotPassword(anyBody({ email: 42 as unknown as string }), req)).toEqual({ ok: true }); expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String)); }, 10000); @@ -178,12 +180,12 @@ describe('AuthPublicController', () => { const now = Date.now(); for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now); const c = apc(asvc({ resetPassword: vi.fn() } as Partial), s); - expect(thrown(() => c.resetPassword({}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(thrown(() => c.resetPassword(anyBody(), req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('mfa/verify-login maps a service error', () => { const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial), rl()); - expect(thrown(() => c.verifyMfaLogin({}, req, res))).toEqual({ status: 401, body: { error: 'Bad code' } }); + expect(thrown(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } }); }); it('demo-login + register + invite throw 429 when the login bucket is exhausted', () => { @@ -191,14 +193,14 @@ describe('AuthPublicController', () => { const now = Date.now(); for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial), s); - expect(thrown(() => c.register({}, req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(thrown(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('mfa/verify-login sets cookie + audits; logout clears cookie', () => { const setAuthCookie = vi.fn(); const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial), rl()); - expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user }); + expect(c.verifyMfaLogin(anyBody(), req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalled(); const clearAuthCookie = vi.fn(); expect(apc(asvc({ clearAuthCookie } as Partial), rl()).logout(req, res)).toEqual({ success: true }); @@ -213,8 +215,8 @@ describe('AuthController (authenticated)', () => { }); it('change-password maps error, else audits', () => { - expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } }); - expect(ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, {}, req)).toEqual({ success: true }); + expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } }); + expect(ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' })); }); @@ -256,13 +258,13 @@ describe('AuthController (authenticated)', () => { // exhaust the shared 'login' bucket for this ip (max 5) for (let i = 0; i < 5; i++) s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now); const c = ac(asvc({ changePassword: vi.fn() } as Partial), s); - expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(thrown(() => c.changePassword(user, anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('change-password refreshes this device cookie when the service returns a token', () => { const setAuthCookie = vi.fn(); const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial), rl()); - expect(c.changePassword(user, {}, req, res)).toEqual({ success: true }); + expect(c.changePassword(user, anyBody(), req, res)).toEqual({ success: true }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req); }); @@ -277,7 +279,7 @@ describe('AuthController (authenticated)', () => { expect(ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true }); expect(updateMapsKey).toHaveBeenCalledWith(1, 'k'); const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 }); - expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, { a: 1 })).toEqual({ ok: 1 }); + expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never))).toEqual({ ok: 1 }); }); it('update-settings + get-settings map errors, else return their payloads', () => { @@ -318,9 +320,9 @@ describe('AuthController (authenticated)', () => { it('mfa/enable + mfa/disable map errors', () => { expect(thrown(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); - expect(thrown(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } }); + expect(thrown(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } }); const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial), rl()); - expect(ok.mfaDisable(user, {}, req)).toEqual({ success: true, mfa_enabled: false }); + expect(ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' })); }); diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index e1034e748..95ecff609 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -59,8 +59,10 @@ import { DaysService, DayReorderError, addDays } from '../../../src/nest/days/da import { getDay as bridgeGetDay, listDays as bridgeListDays } from '../../../src/nest/days/days.bridge'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); +const accommodations = new AccommodationsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); beforeAll(() => { createTables(testDb); @@ -332,6 +334,11 @@ describe('quirk fixes', () => { expect(day.title).toBe('Arrival'); }); + // Asserted against AccommodationsService, which owns createAccommodation since + // the days/accommodations split. It read `svc.createAccommodation` until then: + // that is `undefined`, calling it throws, and `.toThrow()` was satisfied by the + // TypeError rather than by the rollback. The invariant went unchecked for the + // whole time the case reported green. it('DAY-SVC-034 — createAccommodation is atomic: a failed reservation insert leaves no orphan stay', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -339,7 +346,7 @@ describe('quirk fixes', () => { const place = createPlace(testDb, trip.id, { name: 'Hotel' }); testDb.exec("CREATE TRIGGER boom BEFORE INSERT ON reservations BEGIN SELECT RAISE(ABORT, 'boom'); END"); try { - expect(() => svc.createAccommodation(trip.id, { + expect(() => accommodations.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })).toThrow(); expect(testDb.prepare('SELECT COUNT(*) as n FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toMatchObject({ n: 0 }); diff --git a/server/tests/unit/plugins/host-rpc.test.ts b/server/tests/unit/plugins/host-rpc.test.ts index b332916ff..7c540787f 100644 --- a/server/tests/unit/plugins/host-rpc.test.ts +++ b/server/tests/unit/plugins/host-rpc.test.ts @@ -102,7 +102,21 @@ const registry = createTestPluginRegistry([ const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter); -type Res = RpcResponse & Partial; +/** + * A response read loosely, on purpose. + * + * `RpcResponse & Partial` collapsed to `never`: both carry `ok`, one + * as the literal `true` and the other as `false`, so the intersection has no + * inhabitant and every `.ok` / `.result` / `.error` below was an error the + * build never saw, because tests/ sits outside tsconfig's include. The union + * would be the honest runtime type, but `dispatch` here is asserted on from + * both sides in the same expression, so this widens instead of narrowing. + */ +type Res = Pick & { + ok: boolean; + result?: unknown; + error?: RpcError['error']; +}; // The acting user is a REST arg, not a defaulted one: an explicit `undefined` has to // stay undefined (that is the userless-context case), and a default parameter would // quietly turn it back into user 5. diff --git a/server/tsconfig.tests.json b/server/tsconfig.tests.json new file mode 100644 index 000000000..3004a2877 --- /dev/null +++ b/server/tsconfig.tests.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src", "tests"] +}