From 93bbc65520dc5f83cbe849c938a1bba578c52cd8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 9 Aug 2026 21:53:42 +0200 Subject: [PATCH] Return the registration invites to the domain that consumes them invite_tokens gates signup. Its consumer is the registration path in AuthService, not anything in admin/, which held the four management methods only because the routes sit under /api/admin. Worth spelling out because the names invite it: invite_tokens and trip_invite_tokens are different tables for different jobs, and this one has nothing to do with adding a member to a trip. An earlier reading of the plan had these going to trip-invite/, which would have been the wrong home. The routes keep their paths and their admin guard; AdminController injects the service instead of carrying another domain's SQL. Also recorded, in the file, why the app-settings pair did NOT move to settings/ with the other app_settings readers: updateAppSettings runs the lockout guard that stops an admin disabling every login method, and that guard is auth's own resolveAuthToggles. Moving it would put an AuthModule import inside a leaf module half the container pulls in. --- server/src/nest/admin/admin.controller.ts | 10 ++- server/src/nest/admin/admin.service.ts | 65 -------------- server/src/nest/auth/auth.module.ts | 5 +- server/src/nest/auth/auth.service.ts | 10 +++ .../nest/auth/registration-invites.service.ts | 86 +++++++++++++++++++ .../tests/unit/nest/admin.controller.test.ts | 11 +-- server/tests/unit/nest/admin.service.test.ts | 49 ----------- .../nest/registration-invites.service.test.ts | 83 ++++++++++++++++++ 8 files changed, 194 insertions(+), 125 deletions(-) create mode 100644 server/src/nest/auth/registration-invites.service.ts create mode 100644 server/tests/unit/nest/registration-invites.service.test.ts diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 4a138a935..9dcc70695 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -3,6 +3,7 @@ import type { Request } from 'express'; import { readEnv } from '../../app-config'; import { AdminService } from './admin.service'; import { TokenService } from '../tokens/token.service'; +import { RegistrationInvitesService } from '../auth/registration-invites.service'; import { AdminUserCreateDto, AdminUserUpdateDto, @@ -63,6 +64,7 @@ export class AdminController { private readonly audit: AuditService, private readonly notifications: NotificationsService, private readonly tokens: TokenService, + private readonly invites: RegistrationInvitesService, ) {} // ── Users ── @@ -139,23 +141,23 @@ export class AdminController { // ── Invites ── @Get('invites') - listInvites() { return { invites: this.admin.listInvites() }; } + listInvites() { return { invites: this.invites.listInvites() }; } // Trips an admin can optionally bind a registration invite to (#1402). @Get('invites/trips') - listInviteTrips() { return { trips: this.admin.listTripsForInvite() }; } + listInviteTrips() { return { trips: this.invites.listTripsForInvite() }; } @Post('invites') @HttpCode(201) createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) { - const result = this.admin.createInvite(user.id, body); + const result = this.invites.createInvite(user.id, body); this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } }); return { invite: result.invite }; } @Delete('invites/:id') deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - ok(this.admin.deleteInvite(id)); + ok(this.invites.deleteInvite(id)); this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index a56207aa2..a83e91765 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -456,71 +456,6 @@ export class AdminService { } } - // ── Invite Tokens ────────────────────────────────────────────────────────── - - listInvites() { - return this.db.all(` - SELECT i.*, u.username as created_by_name, t.title as trip_title - FROM invite_tokens i - JOIN users u ON i.created_by = u.id - LEFT JOIN trips t ON i.trip_id = t.id - ORDER BY i.created_at DESC - `); - } - - /** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */ - listTripsForInvite() { - return this.db.all('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC'); - } - - createInvite( - createdBy: number, - data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null }, - ) { - const rawUses = parseInt(String(data.max_uses)); - const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5); - const token = crypto.randomBytes(16).toString('hex'); - const expiresAt = data.expires_in_days - ? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString() - : null; - - // Optional trip binding: only persist a trip that actually exists, so a stale - // or forged id can never bind (and never auto-adds anyone on registration). - let tripId: number | null = null; - if (data.trip_id != null && String(data.trip_id).trim() !== '') { - const parsed = parseInt(String(data.trip_id)); - if (!Number.isInteger(parsed) || !this.db.get('SELECT id FROM trips WHERE id = ?', parsed)) { - // Used to bind null silently, handing back a plain registration invite - // the admin never asked for. - return { error: 'Trip not found', status: 404 }; - } - tripId = parsed; - } - - const ins = this.db.run( - 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)', - token, uses, expiresAt, createdBy, tripId, - ); - - const inviteId = Number(ins.lastInsertRowid); - const invite = this.db.get(` - SELECT i.*, u.username as created_by_name, t.title as trip_title - FROM invite_tokens i - JOIN users u ON i.created_by = u.id - LEFT JOIN trips t ON i.trip_id = t.id - WHERE i.id = ? - `, inviteId); - - return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId }; - } - - deleteInvite(id: string) { - const invite = this.db.get('SELECT id FROM invite_tokens WHERE id = ?', id); - if (!invite) return { error: 'Invite not found', status: 404 }; - this.db.run('DELETE FROM invite_tokens WHERE id = ?', id); - return {}; - } - // ── Addons ───────────────────────────────────────────────────────────────── listAddons() { diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index f17332f3a..e87a5fcf9 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthController } from './auth.controller'; import { PasskeyController } from './passkey.controller'; import { AuthService } from './auth.service'; import { UserProfileService } from './user-profile.service'; +import { RegistrationInvitesService } from './registration-invites.service'; import { PasskeyService } from './passkey.service'; import { AuthMcp } from './auth.mcp'; import { UserCleanupService } from './user-cleanup.service'; @@ -39,7 +40,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' @Module({ imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule], controllers: [AuthPublicController, AuthController, PasskeyController], - providers: [AuthService, UserProfileService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], - exports: [AuthService, PasskeyService, UserCleanupService], + providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], + exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService], }) export class AuthModule {} diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index c89e2ced1..b3d150630 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -574,6 +574,16 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { // ------------------------------------------------------------------------- // Admin settings + // + // These stay here, against the first instinct to file them under settings/ + // with the other app_settings readers. updateAppSettings runs the lockout + // guard that stops an admin disabling every login method at once, and that + // guard is resolveAuthToggles — auth's own view of which methods exist. + // Moving the pair would put an AuthModule import inside SettingsModule, a + // leaf that half the container pulls in, to buy nothing but a tidier + // directory. The role check below is likewise left as written: swapping it + // for AdminGuard changes the response body, so it belongs with the guard + // work, not with a move. // ------------------------------------------------------------------------- getAppSettings(userId: number): { error?: string; status?: number; data?: Record } { diff --git a/server/src/nest/auth/registration-invites.service.ts b/server/src/nest/auth/registration-invites.service.ts new file mode 100644 index 000000000..a3426c17a --- /dev/null +++ b/server/src/nest/auth/registration-invites.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import crypto from 'crypto'; +import { DatabaseService } from '../database/database.service'; + +/** + * Registration invites: the tokens an admin hands out so someone can create an + * account on a closed instance, optionally dropping them straight into a trip. + * + * This lives in auth/ and not in trip-invite/, which is the trap the name sets. + * invite_tokens and trip_invite_tokens are different tables for different + * things — the first gates signup, the second adds an existing user to a trip. + * The consumer of this one is the registration path in AuthService, so the + * table belongs to the auth domain. + * + * The four methods moved verbatim out of AdminService, which held them only + * because the management routes are under /api/admin. Those routes keep their + * paths and their guards; AdminController now injects this instead of carrying + * another domain's SQL. + */ +@Injectable() +export class RegistrationInvitesService { + constructor(private readonly db: DatabaseService) {} + + listInvites() { + return this.db.all(` + SELECT i.*, u.username as created_by_name, t.title as trip_title + FROM invite_tokens i + JOIN users u ON i.created_by = u.id + LEFT JOIN trips t ON i.trip_id = t.id + ORDER BY i.created_at DESC + `); + } + + /** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */ + listTripsForInvite() { + return this.db.all('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC'); + } + + createInvite( + createdBy: number, + data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null }, + ) { + const rawUses = parseInt(String(data.max_uses)); + const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5); + const token = crypto.randomBytes(16).toString('hex'); + const expiresAt = data.expires_in_days + ? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString() + : null; + + // Optional trip binding: only persist a trip that actually exists, so a stale + // or forged id can never bind (and never auto-adds anyone on registration). + let tripId: number | null = null; + if (data.trip_id != null && String(data.trip_id).trim() !== '') { + const parsed = parseInt(String(data.trip_id)); + if (!Number.isInteger(parsed) || !this.db.get('SELECT id FROM trips WHERE id = ?', parsed)) { + // Used to bind null silently, handing back a plain registration invite + // the admin never asked for. + return { error: 'Trip not found', status: 404 }; + } + tripId = parsed; + } + + const ins = this.db.run( + 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)', + token, uses, expiresAt, createdBy, tripId, + ); + + const inviteId = Number(ins.lastInsertRowid); + const invite = this.db.get(` + SELECT i.*, u.username as created_by_name, t.title as trip_title + FROM invite_tokens i + JOIN users u ON i.created_by = u.id + LEFT JOIN trips t ON i.trip_id = t.id + WHERE i.id = ? + `, inviteId); + + return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId }; + } + + deleteInvite(id: string) { + const invite = this.db.get('SELECT id FROM invite_tokens WHERE id = ?', id); + if (!invite) return { error: 'Invite not found', status: 404 }; + this.db.run('DELETE FROM invite_tokens WHERE id = ?', id); + return {}; + } +} diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 26697d967..7d42c4668 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -8,6 +8,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', import type { AddonsService } from '../../../src/nest/addons/addons.service'; import { AdminController } from '../../../src/nest/admin/admin.controller'; import type { TokenService } from '../../../src/nest/tokens/token.service'; +import type { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service'; import type { AdminService } from '../../../src/nest/admin/admin.service'; import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; @@ -45,8 +46,8 @@ const addonsStub = () => ({ // The MCP-token routes read TokenService now, not AdminService. Stubbed via a // fourth, optional argument so every existing call site stays as it was. -const adminCtl = (s: AdminService, rt?: PluginRuntimeService, addons: AddonsService = addonsStub(), tokens: Partial = {}) => - new AdminController(s, addons, rt as unknown as PluginRuntimeService, audit, notifications, tokens as TokenService); +const adminCtl = (s: AdminService, rt?: PluginRuntimeService, addons: AddonsService = addonsStub(), tokens: Partial = {}, invites: Partial = {}) => + new AdminController(s, addons, rt as unknown as PluginRuntimeService, audit, notifications, tokens as TokenService, invites as RegistrationInvitesService); function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() }; @@ -99,10 +100,10 @@ describe('AdminController permissions + oidc + misc', () => { // admin-packing-templates.controller.test.ts. describe('AdminController invites', () => { it('invites: create 201 + audit, delete maps error', () => { - const c = adminCtl(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial)); + const c = adminCtl(svc(), undefined, undefined, {}, { createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) }); expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' })); - expect(thrown(() => adminCtl(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); + expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) }).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); }); @@ -154,7 +155,7 @@ describe('AdminController read-only getters', () => { expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); - expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listInvites()).toEqual({ invites: [{ id: 1 }] }); + expect(adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] }); expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); expect(adminCtl(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 99e151a1e..1b7ae8a3c 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -100,9 +100,6 @@ const getStats = () => svc.getStats(); const getPermissions = () => svc.getPermissions(); const savePermissions = (p: Record) => svc.savePermissions(p); const getAuditLog = (q: { limit?: string; offset?: string }) => svc.getAuditLog(q); -const listInvites = () => svc.listInvites(); -const createInvite = (by: number, d: Parameters[1]) => svc.createInvite(by, d); -const deleteInvite = (id: string) => svc.deleteInvite(id); const getOidcSettings = () => svc.getOidcSettings(); const updateOidcSettings = (d: Parameters[0]) => svc.updateOidcSettings(d); const saveDemoBaseline = () => svc.saveDemoBaseline(); @@ -299,44 +296,6 @@ describe('getAuditLog', () => { }); }); -// ── Invites ─────────────────────────────────────────────────────────────────── - -describe('Invites', () => { - it('ADMIN-SVC-024 — createInvite returns invite with token', () => { - const { user: admin } = createAdmin(testDb); - const result = createInvite(admin.id, { max_uses: 5 }) as any; - expect(result.invite.token).toBeDefined(); - expect(result.invite.max_uses).toBe(5); - }); - - it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => { - const { user: admin } = createAdmin(testDb); - const result = createInvite(admin.id, {}) as any; - expect(result.uses).toBe(1); - }); - - it('ADMIN-SVC-026 — listInvites returns array', () => { - const { user: admin } = createAdmin(testDb); - createInvite(admin.id, {}); - const invites = listInvites() as any[]; - expect(invites.length).toBeGreaterThanOrEqual(1); - }); - - it('ADMIN-SVC-027 — deleteInvite removes invite', () => { - const { user: admin } = createAdmin(testDb); - const invite = createInviteToken(testDb, { created_by: admin.id }) as any; - const result = deleteInvite(String(invite.id)) as any; - expect(result.error).toBeUndefined(); - const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id); - expect(check).toBeUndefined(); - }); - - it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => { - const result = deleteInvite('99999') as any; - expect(result.status).toBe(404); - }); -}); - // ── getAuditLog — JSON details parsing ─────────────────────────────────────── describe('getAuditLog — JSON details', () => { @@ -555,14 +514,6 @@ describe('admin quirk fixes (post-fold)', () => { expect(row.username).toBe(user.username); }); - it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', () => { - const { user: admin } = createAdmin(testDb); - expect(createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' }); - expect(createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 }); - expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 }); - // An absent/blank binding is still a plain registration invite. - expect((createInvite(admin.id, {}) as any).tripId).toBeNull(); - }); it('ADMIN-SVC-074 — listOAuthSessions survives a row with malformed scopes JSON', () => { const { user } = createUser(testDb); diff --git a/server/tests/unit/nest/registration-invites.service.test.ts b/server/tests/unit/nest/registration-invites.service.test.ts new file mode 100644 index 000000000..4a921911f --- /dev/null +++ b/server/tests/unit/nest/registration-invites.service.test.ts @@ -0,0 +1,83 @@ +/** + * registration-invites.service.test.ts + * + * DB-centric unit tests for RegistrationInvitesService against a real in-memory + * SQLite database. The cases moved here with the methods, out of + * admin.service.test.ts; the ADMIN-SVC-* case IDs are preserved so the history + * stays greppable. Constructed directly (no TestingModule, repo convention). + */ + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => undefined, isOwner: () => false }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); + +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createAdmin, createTrip, createInviteToken } from '../../helpers/factories'; +import { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service'; +import { DatabaseService } from '../../../src/nest/database/database.service'; + +const svc = new RegistrationInvitesService(new DatabaseService(testDb)); + +beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeEach(() => { resetTestDb(testDb); vi.clearAllMocks(); }); +afterAll(() => { testDb.close(); }); + +// ── Invites ─────────────────────────────────────────────────────────────────── + +describe('Invites', () => { + it('ADMIN-SVC-024 — createInvite returns invite with token', () => { + const { user: admin } = createAdmin(testDb); + const result = svc.createInvite(admin.id, { max_uses: 5 }) as any; + expect(result.invite.token).toBeDefined(); + expect(result.invite.max_uses).toBe(5); + }); + + it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => { + const { user: admin } = createAdmin(testDb); + const result = svc.createInvite(admin.id, {}) as any; + expect(result.uses).toBe(1); + }); + + it('ADMIN-SVC-026 — listInvites returns array', () => { + const { user: admin } = createAdmin(testDb); + svc.createInvite(admin.id, {}); + const invites = svc.listInvites() as any[]; + expect(invites.length).toBeGreaterThanOrEqual(1); + }); + + it('ADMIN-SVC-027 — deleteInvite removes invite', () => { + const { user: admin } = createAdmin(testDb); + const invite = createInviteToken(testDb, { created_by: admin.id }) as any; + const result = svc.deleteInvite(String(invite.id)) as any; + expect(result.error).toBeUndefined(); + const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id); + expect(check).toBeUndefined(); + }); + + it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => { + const result = svc.deleteInvite('99999') as any; + expect(result.status).toBe(404); + }); +}); + +describe('Invites — trip binding', () => { + it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', () => { + const { user: admin } = createAdmin(testDb); + expect(svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' }); + expect(svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 }); + expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 }); + // An absent/blank binding is still a plain registration invite. + expect((svc.createInvite(admin.id, {}) as any).tripId).toBeNull(); + }); +});