mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, string> } {
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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<TokenService> = {}) =>
|
||||
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<TokenService> = {}, invites: Partial<RegistrationInvitesService> = {}) =>
|
||||
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<AdminService>));
|
||||
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<AdminService>)).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<AdminService>)).permissions()).toEqual({ a: 1 });
|
||||
expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
|
||||
expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
|
||||
expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).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<AdminService>)).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<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
||||
|
||||
@@ -100,9 +100,6 @@ const getStats = () => svc.getStats();
|
||||
const getPermissions = () => svc.getPermissions();
|
||||
const savePermissions = (p: Record<string, string>) => svc.savePermissions(p);
|
||||
const getAuditLog = (q: { limit?: string; offset?: string }) => svc.getAuditLog(q);
|
||||
const listInvites = () => svc.listInvites();
|
||||
const createInvite = (by: number, d: Parameters<AdminService['createInvite']>[1]) => svc.createInvite(by, d);
|
||||
const deleteInvite = (id: string) => svc.deleteInvite(id);
|
||||
const getOidcSettings = () => svc.getOidcSettings();
|
||||
const updateOidcSettings = (d: Parameters<AdminService['updateOidcSettings']>[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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user