mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
b53130ca0d
A sign-in with another subject of the same provider is refused, so an account whose provider re-issued the subject (a migrated or rebuilt IdP, a changed sub format) was locked out: editing the email, as the wiki advised, never touched the stored link. The edit dialog now offers "Remove SSO link" for other accounts while SSO is set up (DELETE /api/admin/users/:id/sso, audit admin.user_sso_unlink). The next SSO sign-in links again under the rules of a first one, so it still needs a verified, not self-set email. The log line names the remedy, and the wiki tells the two refusals apart. Fixes #3065
1287 lines
57 KiB
TypeScript
1287 lines
57 KiB
TypeScript
/**
|
|
* Unit tests for the DI-native AdminService — ADMIN-SVC-001 through
|
|
* ADMIN-SVC-069, moved 1:1 from tests/unit/services/adminService.test.ts with
|
|
* the 2026-08 fold (IDs preserved, including the pre-existing 029/030 gap and
|
|
* the duplicated 069). The packing-template cases (031-044, 056-064) moved with
|
|
* their functions to tests/unit/nest/packing.service.test.ts.
|
|
* Constructs the service directly over a real in-memory SQLite DB (repo
|
|
* convention — no TestingModule). Focuses on validation/error branches that the
|
|
* integration tests don't exercise. VCJOB-001 pins the version-check cron path
|
|
* (it replaced ADMIN-BR-001 when the old admin bridge died with the cron move).
|
|
*/
|
|
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { Addons } from '../../../src/db/entities/Addons.entity';
|
|
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
|
import { DocumentProviders } from '../../../src/db/entities/DocumentProviders.entity';
|
|
import { McpTokens } from '../../../src/db/entities/McpTokens.entity';
|
|
import { PhotoProviderFields } from '../../../src/db/entities/PhotoProviderFields.entity';
|
|
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
|
import { PushSubscriptions } from '../../../src/db/entities/PushSubscriptions.entity';
|
|
import { TripFiles } from '../../../src/db/entities/TripFiles.entity';
|
|
import { UserSessions } from '../../../src/db/entities/UserSessions.entity';
|
|
import { Users } from '../../../src/db/entities/Users.entity';
|
|
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
|
|
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
|
|
import type { McpTokensRepository } from '../../../src/db/repositories/McpTokens.repository';
|
|
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
|
import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers';
|
|
import { AdminService } from '../../../src/nest/admin/admin.service';
|
|
import { VersionCheckJob } from '../../../src/nest/admin/version-check.job';
|
|
import { DataPathsService } from '../../../src/nest/app-config/data-paths.service';
|
|
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
|
import { EphemeralTokenService } from '../../../src/nest/auth-core/ephemeral-token.service';
|
|
import { AuthService } from '../../../src/nest/auth/auth.service';
|
|
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
|
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
|
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
|
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
|
import type { DatabaseBackupStrategy } from '../../../src/nest/database/database-backup.interface';
|
|
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
|
|
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
|
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
|
import { PackingService } from '../../../src/nest/packing/packing.service';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
|
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
|
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
|
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
|
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
|
|
import { asLegacyResult } from '../../helpers/domain-error';
|
|
import { createUser, createUserWithMfa, createAdmin, createInviteToken } from '../../helpers/factories';
|
|
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
|
import { readAppSetting } from '../../helpers/factories/settings';
|
|
import { readUser } from '../../helpers/factories/users';
|
|
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
|
import {
|
|
createTestJourneysRepo,
|
|
createTestJourneyEntriesRepo,
|
|
createTestJourneyContributorsRepo,
|
|
} from '../../helpers/journey-repos';
|
|
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
|
|
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
|
import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-repos';
|
|
import { createTestSessionsService } from '../../helpers/sessions';
|
|
import {
|
|
createTestShareTokensRepo,
|
|
createTestPluginsRepo,
|
|
createTestPluginUserErasureQueueRepo,
|
|
} from '../../helpers/share-repos';
|
|
import { createTestAddonsService } from '../../helpers/test-addons';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import type { TestOrm } from '../../helpers/test-orm';
|
|
import {
|
|
createTestUnitOfWork,
|
|
createTestAppSettingsRepo,
|
|
createTestUsersRepo,
|
|
createTestWebauthnCredentialsRepo,
|
|
createTestWebauthnChallengesRepo,
|
|
createTestInviteTokensRepo,
|
|
createTestMcpTokensRepo,
|
|
createTestOauthTokensRepo,
|
|
createTestPasswordResetTokensRepo,
|
|
createTestTripsRepo,
|
|
createTestTripMembersRepo,
|
|
createTestSettingsRepo,
|
|
createTestPlacesRepo,
|
|
sharedTestOrm,
|
|
} from '../../helpers/test-uow';
|
|
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
|
|
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => null,
|
|
isOwner: () => false,
|
|
};
|
|
return mock;
|
|
});
|
|
|
|
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
|
encrypt_api_key: (v: string) => v,
|
|
decrypt_api_key: (v: string) => v,
|
|
maybe_encrypt_api_key: (v: string) => v,
|
|
}));
|
|
vi.mock('../../../src/mcp', () => ({
|
|
revokeUserSessions: vi.fn(),
|
|
invalidateMcpSessions: vi.fn(),
|
|
}));
|
|
vi.mock('../../../src/mcp/sessionManager', () => ({
|
|
revokeUserSessions: vi.fn(),
|
|
revokeUserSessionsForClient: vi.fn(),
|
|
}));
|
|
vi.mock('../../../src/demo/demo-reset', () => ({
|
|
saveBaseline: vi.fn(),
|
|
}));
|
|
|
|
const realtime = new RealtimeService();
|
|
|
|
let webauthn: WebauthnConfigService;
|
|
|
|
// Positional and previously wrong: an AtlasService sat in the membership slot
|
|
// and the mailer was missing entirely, so `auth` was built with its last four
|
|
// collaborators shifted by one. Nothing failed, because none of the cases below
|
|
// reach a path that uses them.
|
|
|
|
let permissions: PermissionsService;
|
|
let userCleanup: UserCleanupService;
|
|
let auth: AuthService;
|
|
let svc: AdminService;
|
|
let mcpTokensRepo: McpTokensRepository;
|
|
let auditLogRepo: AuditLogRepository;
|
|
// The database backup port AdminService injects for the demo baseline route.
|
|
// Its snapshot fails, which is the failure ADMIN-SVC-095 needs the real
|
|
// saveBaseline() to meet.
|
|
const databaseBackupStub = {
|
|
canSnapshot: () => true,
|
|
snapshot: async () => {
|
|
throw new Error('database or disk is full');
|
|
},
|
|
} as unknown as DatabaseBackupStrategy;
|
|
beforeAll(async () => {
|
|
webauthn = new WebauthnConfigService(await createTestAppSettingsRepo(testDb));
|
|
permissions = new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb));
|
|
userCleanup = new UserCleanupService(
|
|
new MaintenanceRepository((await sharedTestOrm(testDb)).em),
|
|
new BudgetService(
|
|
permissions,
|
|
new ExchangeRatesService(),
|
|
realtime,
|
|
await createTestUnitOfWork(testDb),
|
|
...(await budgetRepoArgs(testDb)),
|
|
),
|
|
await createTestUnitOfWork(testDb),
|
|
await createTestUsersRepo(testDb),
|
|
await createTestTripMembersRepo(testDb),
|
|
await createTestBudgetItemsRepo(testDb),
|
|
await createTestBudgetSettlementsRepo(testDb),
|
|
await createTestJourneyShareTokensRepo(testDb),
|
|
await createTestJourneysRepo(testDb),
|
|
await createTestJourneyEntriesRepo(testDb),
|
|
await createTestJourneyContributorsRepo(testDb),
|
|
await createTestShareTokensRepo(testDb),
|
|
await createTestPluginsRepo(testDb),
|
|
await createTestPluginUserErasureQueueRepo(testDb),
|
|
{ releaseValues: async () => [] } as never,
|
|
);
|
|
auth = new AuthService(
|
|
permissions,
|
|
new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb)),
|
|
webauthn,
|
|
userCleanup,
|
|
new MailerService(
|
|
await createTestUsersRepo(testDb),
|
|
await createTestSettingsRepo(testDb),
|
|
await createTestAppSettingsRepo(testDb),
|
|
),
|
|
new EphemeralTokenService(),
|
|
new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)),
|
|
await createTestUnitOfWork(testDb),
|
|
await createTestAppSettingsRepo(testDb),
|
|
await createTestUsersRepo(testDb),
|
|
await createTestInviteTokensRepo(testDb),
|
|
await createTestMcpTokensRepo(testDb),
|
|
await createTestOauthTokensRepo(testDb),
|
|
await createTestWebauthnCredentialsRepo(testDb),
|
|
await createTestPasswordResetTokensRepo(testDb),
|
|
await createTestPushSubscriptionsRepo(testDb),
|
|
await createTestSessionsService(testDb),
|
|
);
|
|
const t = await sharedTestOrm(testDb);
|
|
orm = t;
|
|
mcpTokensRepo = await createTestMcpTokensRepo(testDb);
|
|
auditLogRepo = t.repo(AuditLog);
|
|
svc = new AdminService(
|
|
await createTestUsersRepo(testDb),
|
|
auditLogRepo,
|
|
await createTestAppSettingsRepo(testDb),
|
|
t.repo(Addons),
|
|
t.repo(PhotoProviders),
|
|
t.repo(PhotoProviderFields),
|
|
t.repo(DocumentProviders),
|
|
mcpTokensRepo,
|
|
await createTestOauthTokensRepo(testDb),
|
|
await createTestTripsRepo(testDb),
|
|
await createTestPlacesRepo(testDb),
|
|
t.repo(TripFiles),
|
|
t.repo(PushSubscriptions),
|
|
await createTestAddonsService(testDb),
|
|
new PasskeyService(
|
|
auth,
|
|
webauthn,
|
|
await createTestUnitOfWork(testDb),
|
|
await createTestWebauthnCredentialsRepo(testDb),
|
|
await createTestWebauthnChallengesRepo(testDb),
|
|
await createTestUsersRepo(testDb),
|
|
),
|
|
auth,
|
|
permissions,
|
|
await makeNotificationsService(testDb, realtime),
|
|
userCleanup,
|
|
realtime,
|
|
await createTestUnitOfWork(testDb),
|
|
databaseBackupStub,
|
|
new DataPathsService(),
|
|
await createTestSessionsService(testDb),
|
|
);
|
|
});
|
|
|
|
// Legacy free-function names bound to the service, so the moved cases below read
|
|
// exactly as they did before the fold.
|
|
const listUsers = () => svc.listUsers();
|
|
const svcCreateUser = (d: Parameters<AdminService['createUser']>[0]) => asLegacyResult(svc.createUser(d));
|
|
const updateUser = (id: string, d: Parameters<AdminService['updateUser']>[1]) => asLegacyResult(svc.updateUser(id, d));
|
|
const deleteUser = (id: string, actingId: number) => asLegacyResult(svc.deleteUser(id, actingId));
|
|
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 saveDemoBaseline = () => asLegacyResult(svc.saveDemoBaseline());
|
|
const getGithubReleases = (perPage?: string, page?: string) => svc.getGithubReleases(perPage, page);
|
|
const checkVersion = () => svc.checkVersion();
|
|
const listAddons = () => svc.listAddons();
|
|
const updateAddon = (id: string, d: Parameters<AdminService['updateAddon']>[1]) =>
|
|
asLegacyResult(svc.updateAddon(id, d));
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
let orm: TestOrm;
|
|
|
|
const setJourney = (enabled: boolean) => updateRows(orm, Addons, { id: 'journey' }, { enabled });
|
|
|
|
// ── listUsers ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('listUsers', () => {
|
|
it('ADMIN-SVC-001 — returns all users with online:false', async () => {
|
|
createUser(testDb);
|
|
createUser(testDb);
|
|
const users = (await listUsers()) as any[];
|
|
expect(users.length).toBeGreaterThanOrEqual(2);
|
|
expect(users.every((u: any) => u.online === false)).toBe(true);
|
|
});
|
|
|
|
it('ADMIN-SVC-001b — last_active is the later of the last sign-in and the last request', async () => {
|
|
const { user: busy } = createUser(testDb);
|
|
const { user: quiet } = createUser(testDb);
|
|
const { user: never } = createUser(testDb);
|
|
await updateRows(orm, Users, { id: busy.id }, { last_login: '2026-10-01 08:00:00' });
|
|
await updateRows(orm, Users, { id: quiet.id }, { last_login: '2026-10-05 08:00:00' });
|
|
// The cookie renewed itself for a week: no new sign-in, but requests until the 8th.
|
|
await insertRow(orm, UserSessions, {
|
|
id: 'busy-session',
|
|
user: busy.id,
|
|
created_at: '2026-10-01 08:00:00',
|
|
last_seen_at: '2026-10-08 17:30:00',
|
|
expires_at: '2999-01-01 00:00:00',
|
|
});
|
|
await insertRow(orm, UserSessions, {
|
|
id: 'quiet-session',
|
|
user: quiet.id,
|
|
created_at: '2026-10-02 08:00:00',
|
|
last_seen_at: '2026-10-02 09:00:00',
|
|
expires_at: '2999-01-01 00:00:00',
|
|
});
|
|
const users = await listUsers();
|
|
const of = (id: number) => users.find((u) => u.id === id);
|
|
expect(of(busy.id)).toMatchObject({ last_login: '2026-10-01T08:00:00Z', last_active: '2026-10-08T17:30:00Z' });
|
|
expect(of(quiet.id)).toMatchObject({ last_login: '2026-10-05T08:00:00Z', last_active: '2026-10-05T08:00:00Z' });
|
|
expect(of(never.id)).toMatchObject({ last_login: null, last_active: null });
|
|
});
|
|
});
|
|
|
|
// ── createUser ────────────────────────────────────────────────────────────────
|
|
|
|
describe('createUser (service)', () => {
|
|
it('ADMIN-SVC-002 — creates a user successfully', async () => {
|
|
const result = (await svcCreateUser({
|
|
username: 'newuser',
|
|
email: 'new@test.com',
|
|
password: 'ValidPass1!',
|
|
})) as any;
|
|
expect(result.user).toBeDefined();
|
|
expect(result.user.email).toBe('new@test.com');
|
|
});
|
|
|
|
it('ADMIN-SVC-003 — returns 400 when username is missing', async () => {
|
|
const result = (await svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' })) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-004 — returns 400 for invalid role', async () => {
|
|
const result = (await svcCreateUser({
|
|
username: 'u1',
|
|
email: 'u1@test.com',
|
|
password: 'ValidPass1!',
|
|
role: 'superuser',
|
|
})) as any;
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/invalid role/i);
|
|
});
|
|
|
|
it('ADMIN-SVC-005 — returns 409 for duplicate username', async () => {
|
|
createUser(testDb);
|
|
const { user } = createUser(testDb);
|
|
const result = (await svcCreateUser({
|
|
username: user.username,
|
|
email: 'unique@test.com',
|
|
password: 'ValidPass1!',
|
|
})) as any;
|
|
expect(result.status).toBe(409);
|
|
});
|
|
|
|
it('ADMIN-SVC-006 — returns 409 for duplicate email', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = (await svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' })) as any;
|
|
expect(result.status).toBe(409);
|
|
});
|
|
|
|
it('ADMIN-SVC-007 — returns 400 for weak password', async () => {
|
|
const result = (await svcCreateUser({
|
|
username: 'weakpwuser',
|
|
email: 'weakpw@test.com',
|
|
password: 'short',
|
|
})) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ── updateUser ────────────────────────────────────────────────────────────────
|
|
|
|
describe('updateUser', () => {
|
|
it('ADMIN-SVC-008 — updates username successfully', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = (await updateUser(String(user.id), { username: 'updatedname' })) as any;
|
|
expect(result.user).toBeDefined();
|
|
expect(result.user.username).toBe('updatedname');
|
|
});
|
|
|
|
it('ADMIN-SVC-009 — returns 404 for non-existent user', async () => {
|
|
const result = (await updateUser('99999', { username: 'ghost' })) as any;
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it('ADMIN-SVC-010 — returns 400 for invalid role', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = (await updateUser(String(user.id), { role: 'superadmin' })) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-011 — returns 409 when username is taken', async () => {
|
|
const { user: u1 } = createUser(testDb);
|
|
const { user: u2 } = createUser(testDb);
|
|
const result = (await updateUser(String(u2.id), { username: u1.username })) as any;
|
|
expect(result.status).toBe(409);
|
|
});
|
|
|
|
it('ADMIN-SVC-012 — returns 409 when email is taken', async () => {
|
|
const { user: u1 } = createUser(testDb);
|
|
const { user: u2 } = createUser(testDb);
|
|
const result = (await updateUser(String(u2.id), { email: u1.email })) as any;
|
|
expect(result.status).toBe(409);
|
|
});
|
|
|
|
it('ADMIN-SVC-013 — returns 400 for weak password', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = (await updateUser(String(user.id), { password: 'weak' })) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-014 — tracks changed fields in result', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = (await updateUser(String(user.id), { username: 'newname', role: 'admin' })) as any;
|
|
expect(result.changed).toContain('username');
|
|
expect(result.changed).toContain('role');
|
|
});
|
|
});
|
|
|
|
// ── deleteUser ────────────────────────────────────────────────────────────────
|
|
|
|
describe('deleteUser', () => {
|
|
it('ADMIN-SVC-015 — deletes user successfully', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const { user } = createUser(testDb);
|
|
const result = (await deleteUser(String(user.id), admin.id)) as any;
|
|
expect(result.email).toBe(user.email);
|
|
});
|
|
|
|
it('ADMIN-SVC-016 — returns 400 when deleting own account', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const result = (await deleteUser(String(admin.id), admin.id)) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-017 — returns 404 for non-existent user', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const result = (await deleteUser('99999', admin.id)) as any;
|
|
expect(result.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ── getStats ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('getStats', () => {
|
|
it('ADMIN-SVC-018 — returns numeric counts for all stats', async () => {
|
|
const stats = (await getStats()) as any;
|
|
expect(typeof stats.totalUsers).toBe('number');
|
|
expect(typeof stats.totalTrips).toBe('number');
|
|
expect(typeof stats.totalPlaces).toBe('number');
|
|
expect(typeof stats.totalFiles).toBe('number');
|
|
});
|
|
});
|
|
|
|
// ── getPermissions / savePermissions ─────────────────────────────────────────
|
|
|
|
describe('Permissions', () => {
|
|
it('ADMIN-SVC-019 — getPermissions returns an array of actions', async () => {
|
|
const result = (await getPermissions()) as any;
|
|
expect(Array.isArray(result.permissions)).toBe(true);
|
|
expect(result.permissions.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('ADMIN-SVC-020 — savePermissions persists a permission change', async () => {
|
|
await savePermissions({ trip_create: 'admin' });
|
|
const result = (await getPermissions()) as any;
|
|
const perm = result.permissions.find((p: any) => p.key === 'trip_create');
|
|
expect(perm.level).toBe('admin');
|
|
});
|
|
});
|
|
|
|
// ── getAuditLog ───────────────────────────────────────────────────────────────
|
|
|
|
describe('getAuditLog', () => {
|
|
it('ADMIN-SVC-021 — returns entries array with total', async () => {
|
|
const result = (await getAuditLog({})) as any;
|
|
expect(Array.isArray(result.entries)).toBe(true);
|
|
expect(typeof result.total).toBe('number');
|
|
expect(result.limit).toBe(100);
|
|
expect(result.offset).toBe(0);
|
|
});
|
|
|
|
it('ADMIN-SVC-022 — respects limit and offset params', async () => {
|
|
const result = (await getAuditLog({ limit: '10', offset: '0' })) as any;
|
|
expect(result.limit).toBe(10);
|
|
expect(result.offset).toBe(0);
|
|
});
|
|
|
|
it('ADMIN-SVC-023 — caps limit at 500', async () => {
|
|
const result = (await getAuditLog({ limit: '9999' })) as any;
|
|
expect(result.limit).toBe(500);
|
|
});
|
|
});
|
|
|
|
// ── getAuditLog — JSON details parsing ───────────────────────────────────────
|
|
|
|
describe('getAuditLog — JSON details', () => {
|
|
it('ADMIN-SVC-045 — parses JSON details when present', async () => {
|
|
const { user } = createUser(testDb);
|
|
await insertRow(orm, AuditLog, { user: user.id, action: 'test_action', details: JSON.stringify({ key: 'val' }) });
|
|
const result = (await getAuditLog({})) as any;
|
|
expect(result.entries.length).toBeGreaterThanOrEqual(1);
|
|
const entry = result.entries.find((e: any) => e.action === 'test_action');
|
|
expect(entry).toBeDefined();
|
|
expect(entry.details).toEqual({ key: 'val' });
|
|
});
|
|
|
|
it('ADMIN-SVC-046 — falls back to the raw string when details are not valid JSON', async () => {
|
|
const { user } = createUser(testDb);
|
|
await insertRow(orm, AuditLog, { user: user.id, action: 'bad_json_action', details: 'not-valid-json{' });
|
|
const result = (await getAuditLog({})) as any;
|
|
const entry = result.entries.find((e: any) => e.action === 'bad_json_action');
|
|
expect(entry).toBeDefined();
|
|
// Was { _parse_error: true } before the 2026-08 quirk fix — the admin UI
|
|
// rendered that sentinel literally.
|
|
expect(entry.details).toBe('not-valid-json{');
|
|
});
|
|
});
|
|
|
|
// ── OIDC Settings ─────────────────────────────────────────────────────────────
|
|
|
|
// ── saveDemoBaseline ──────────────────────────────────────────────────────────
|
|
|
|
describe('saveDemoBaseline', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('ADMIN-SVC-050 — returns 404 when DEMO_MODE is not "true"', async () => {
|
|
vi.stubEnv('DEMO_MODE', 'false');
|
|
const result = (await saveDemoBaseline()) as any;
|
|
expect(result.status).toBe(404);
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('ADMIN-SVC-051 — returns a defined result object when DEMO_MODE is "true"', async () => {
|
|
// saveDemoBaseline() uses a dynamic CJS require() whose mock cannot be
|
|
// intercepted via vi.mock in this test environment (tsx runtime + CJS loader).
|
|
// The function either succeeds (message) or falls through the catch to a
|
|
// 500 error. Either way the result must be a defined, non-null object.
|
|
vi.stubEnv('DEMO_MODE', 'true');
|
|
const result = (await saveDemoBaseline()) as any;
|
|
expect(result).toBeDefined();
|
|
expect(typeof result).toBe('object');
|
|
// The 404 branch must NOT be taken — DEMO_MODE is "true".
|
|
expect(result.status).not.toBe(404);
|
|
});
|
|
|
|
// Plan 3i Task 4 fix wave (must-land 3): before this fix, AdminService
|
|
// #saveDemoBaseline called the now-async saveBaseline() without awaiting
|
|
// it, so a copy failure (proven live in task-4-review.md with a real
|
|
// EISDIR from fs.copyFileSync) went nowhere — the route answered 200 and
|
|
// the rejection later surfaced as an unhandled rejection that crashed the
|
|
// process. This suite's dynamic require()s of demo-reset.ts bypass this
|
|
// file's top-of-file vi.mock, same documented limitation as ADMIN-SVC-051,
|
|
// so the REAL saveBaseline() runs here, against the injected database port
|
|
// whose snapshot rejects (`databaseBackupStub` above): the same "the save
|
|
// failed" shape a full disk would produce, without touching the
|
|
// filesystem. Awaiting that rejection (the fix) means the service's own
|
|
// try/catch converts it into the legacy 500 body, and this test resolving
|
|
// cleanly (not hanging, no unhandledRejection) is itself proof the
|
|
// rejection was actually caught.
|
|
it('ADMIN-SVC-095 — a failed baseline save is awaited and answers the legacy 500, not an unhandled rejection', async () => {
|
|
vi.stubEnv('DEMO_MODE', 'true');
|
|
const result = await saveDemoBaseline();
|
|
expect(result).toEqual({ error: 'Failed to save baseline', status: 500 });
|
|
});
|
|
});
|
|
|
|
// ── getGithubReleases ─────────────────────────────────────────────────────────
|
|
|
|
describe('getGithubReleases', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('ADMIN-SVC-052 — returns empty array when fetch fails', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
|
const result = await getGithubReleases();
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it('ADMIN-SVC-053 — returns releases array when fetch succeeds', async () => {
|
|
const mockReleases = [
|
|
{ id: 1, tag_name: 'v3.0.0', name: 'Release 3.0.0', html_url: 'https://github.com/example/releases/tag/v3.0.0' },
|
|
{ id: 2, tag_name: 'v2.9.9', name: 'Release 2.9.9', html_url: 'https://github.com/example/releases/tag/v2.9.9' },
|
|
];
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => JSON.stringify(mockReleases),
|
|
json: async () => mockReleases,
|
|
}),
|
|
);
|
|
const result = await getGithubReleases();
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result).toHaveLength(2);
|
|
expect((result as any[])[0].tag_name).toBe('v3.0.0');
|
|
});
|
|
|
|
it('ADMIN-SVC-053a — clamps the paging query instead of interpolating it raw', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '[]' });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await getGithubReleases('9999', '0');
|
|
expect(fetchMock.mock.calls[0][0]).toBe('https://api.github.com/repos/liketrek/TREK/releases?per_page=100&page=1');
|
|
|
|
await getGithubReleases('10&per_page=999', 'abc');
|
|
expect(fetchMock.mock.calls[1][0]).toBe('https://api.github.com/repos/liketrek/TREK/releases?per_page=10&page=1');
|
|
});
|
|
|
|
it('ADMIN-SVC-053b — round-trips the paging the admin UI actually sends', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '[]' });
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await getGithubReleases('20', '2');
|
|
expect(fetchMock.mock.calls[0][0]).toBe('https://api.github.com/repos/liketrek/TREK/releases?per_page=20&page=2');
|
|
});
|
|
});
|
|
|
|
// ── checkVersion ──────────────────────────────────────────────────────────────
|
|
|
|
describe('checkVersion', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// Since the 2026-08 quirk fix, failures cache too (on a 60s TTL), so each case
|
|
// clears the module-scoped cache rather than reading the previous one's result.
|
|
beforeEach(() => {
|
|
__clearVersionCacheForTests();
|
|
});
|
|
|
|
it('ADMIN-SVC-054 — returns update_available:false when fetch fails', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
|
const result = (await checkVersion()) as any;
|
|
expect(result.update_available).toBe(false);
|
|
expect(result.current).toBeDefined();
|
|
expect(result.latest).toBeDefined();
|
|
});
|
|
|
|
it('ADMIN-SVC-055 — returns update_available:true when latest version is greater than current', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () =>
|
|
JSON.stringify({ tag_name: 'v999.0.0', html_url: 'https://github.com/example/releases/tag/v999.0.0' }),
|
|
json: async () => ({ tag_name: 'v999.0.0', html_url: 'https://github.com/example/releases/tag/v999.0.0' }),
|
|
}),
|
|
);
|
|
const result = (await checkVersion()) as any;
|
|
expect(result.update_available).toBe(true);
|
|
expect(result.latest).toBe('999.0.0');
|
|
expect(result.release_url).toBe('https://github.com/example/releases/tag/v999.0.0');
|
|
});
|
|
|
|
it('ADMIN-SVC-070 — a failed check is cached briefly instead of refetching per call', async () => {
|
|
const fetchMock = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
await checkVersion();
|
|
await checkVersion();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ── listAddons ────────────────────────────────────────────────────────────────
|
|
|
|
describe('listAddons', () => {
|
|
it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', async () => {
|
|
const result = (await listAddons()) as any[];
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result.length).toBeGreaterThan(0);
|
|
const addonIds = result.map((a: any) => a.id);
|
|
expect(addonIds).toContain('packing');
|
|
expect(addonIds).toContain('budget');
|
|
});
|
|
|
|
// Plan 3i Task 4 fix wave (AD28): `listAllOrderedForAdminShelf` used to
|
|
// project the `persist(false)` shadow `provider_id`, which MikroORM never
|
|
// hydrates through a narrowed `fields` selection — every photo provider's
|
|
// `fields` grouped under `undefined` and every provider answered `fields:
|
|
// []`. Immich's own connection settings live on `users` columns from an
|
|
// earlier schema shape (see `users_add_immich_url`), so it seeds no
|
|
// `photo_provider_fields` rows and is not useful for this assertion —
|
|
// Synology Photos is the seeded provider that actually owns rows in the
|
|
// generic catalog (url/username/password/otp/skip_ssl), so it is the one
|
|
// that pins the defect: before the fix every one of its 5 seeded fields
|
|
// vanished into the `undefined` group instead.
|
|
it('ADMIN-SVC-066 — Synology Photos carries its seeded field catalog, not fields: []', async () => {
|
|
const result = await listAddons();
|
|
const immich = result.find((entry) => entry.id === 'immich');
|
|
const synology = result.find((entry) => entry.id === 'synologyphotos');
|
|
expect(immich).toBeDefined();
|
|
expect(synology).toBeDefined();
|
|
if (!synology || !('fields' in synology)) throw new Error('synologyphotos entry is missing a fields array');
|
|
expect(synology.fields.map((f) => f.key)).toEqual([
|
|
'synology_url',
|
|
'synology_username',
|
|
'synology_password',
|
|
'synology_otp',
|
|
'synology_skip_ssl',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ── updateAddon ───────────────────────────────────────────────────────────────
|
|
|
|
describe('updateAddon', () => {
|
|
it('ADMIN-SVC-066 — updateAddon enables and disables a seeded addon', async () => {
|
|
const disabled = (await updateAddon('mcp', { enabled: false })) as any;
|
|
expect(disabled.addon).toBeDefined();
|
|
expect(disabled.addon.enabled).toBe(false);
|
|
|
|
const enabled = (await updateAddon('mcp', { enabled: true })) as any;
|
|
expect(enabled.addon.enabled).toBe(true);
|
|
});
|
|
|
|
it('ADMIN-SVC-067 — updateAddon returns 404 for unknown addon id', async () => {
|
|
const result = (await updateAddon('nonexistent-addon-xyz', { enabled: true })) as any;
|
|
expect(result.status).toBe(404);
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('ADMIN-SVC-069 — mcpAffected only fires on a real enabled-flip of an MCP-relevant addon (#1414)', async () => {
|
|
await updateAddon('packing', { enabled: true });
|
|
// no-op save (enabled already true) → sessions survive
|
|
expect(((await updateAddon('packing', { enabled: true })) as any).mcpAffected).toBe(false);
|
|
// config-only save → sessions survive
|
|
expect(((await updateAddon('packing', { config: { foo: 'bar' } })) as any).mcpAffected).toBe(false);
|
|
// real flip of an MCP-relevant addon → invalidate
|
|
expect(((await updateAddon('packing', { enabled: false })) as any).mcpAffected).toBe(true);
|
|
expect(((await updateAddon('packing', { enabled: true })) as any).mcpAffected).toBe(true);
|
|
// real flip of an addon with no MCP surface → sessions survive. Taken from
|
|
// the list rather than named, because an addon that grows MCP tools joins it
|
|
// and would otherwise turn this assertion false without changing anything
|
|
// it is actually about (documents did, when document sync landed).
|
|
const noMcp = Object.values(ADDON_IDS).find((id) => !MCP_GATED_ADDON_IDS.includes(id));
|
|
if (noMcp) {
|
|
const flip = (await updateAddon(noMcp, { enabled: false })) as any;
|
|
if (!flip.error) expect(flip.mcpAffected).toBe(false);
|
|
}
|
|
|
|
// and the one this change put on the list carries the opposite verdict
|
|
const docsFlip = (await updateAddon('documents', { enabled: false })) as any;
|
|
if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(true);
|
|
});
|
|
|
|
it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', async () => {
|
|
await setJourney(false);
|
|
await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 0 });
|
|
|
|
const result = (await updateAddon('immich', { enabled: true })) as any;
|
|
expect(result).toEqual({ error: 'Enable the Journey addon first', status: 409 });
|
|
expect((await findRow(orm, PhotoProviders, { id: 'immich' }))?.enabled).toBe(0);
|
|
});
|
|
|
|
it('ADMIN-SVC-088 — enables a provider under an enabled journey; disabling never needs journey', async () => {
|
|
await setJourney(true);
|
|
const enabled = (await updateAddon('immich', { enabled: true })) as any;
|
|
expect(enabled.addon).toMatchObject({ id: 'immich', type: 'photo_provider', enabled: true });
|
|
|
|
// Switching a provider OFF stays possible with journey off — cleanup must not dead-end.
|
|
await setJourney(false);
|
|
const disabled = (await updateAddon('immich', { enabled: false })) as any;
|
|
expect(disabled.addon.enabled).toBe(false);
|
|
});
|
|
|
|
it('ADMIN-SVC-089 — disabling journey cascades every photo provider off', async () => {
|
|
await setJourney(true);
|
|
await updateRows(orm, PhotoProviders, {}, { enabled: 1 });
|
|
|
|
const result = (await updateAddon('journey', { enabled: false })) as any;
|
|
expect(result.addon.enabled).toBe(false);
|
|
const rows = await findRows(orm, PhotoProviders);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
expect(rows.every((r) => r.enabled === 0)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── version-check cron ────────────────────────────────────────────────────────
|
|
|
|
describe('version-check job', () => {
|
|
const registrarStub = {
|
|
isEnabled: () => true,
|
|
register: vi.fn(() => true),
|
|
unregister: vi.fn(),
|
|
} as unknown as CronRegistrarService;
|
|
const envStub = { isManaged: () => false } as unknown as RuntimeEnvService;
|
|
|
|
it('VCJOB-001 — the cron tick notifies and shares the module-scoped version cache with the route', async () => {
|
|
createAdmin(testDb);
|
|
__clearVersionCacheForTests();
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => JSON.stringify({ tag_name: 'v99.9.9', html_url: 'https://example.test/r' }),
|
|
json: async () => ({ tag_name: 'v99.9.9', html_url: 'https://example.test/r' }),
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await new VersionCheckJob(svc, registrarStub, envStub).tick();
|
|
|
|
expect(await readAppSetting(orm, 'last_notified_version')).toBe('99.9.9');
|
|
|
|
// The version cache is module-scoped in admin.helpers, so the cron and
|
|
// GET /api/admin/version-check hit GitHub once between them.
|
|
expect(await svc.checkVersion()).toMatchObject({ latest: '99.9.9' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
vi.unstubAllGlobals();
|
|
__clearVersionCacheForTests();
|
|
});
|
|
});
|
|
|
|
// ── Quirk fixes landed after the 2026-08 fold ─────────────────────────────────
|
|
|
|
describe('admin quirk fixes (post-fold)', () => {
|
|
it('ADMIN-SVC-072 — updateUser rejects an empty username/email instead of silently no-opping', async () => {
|
|
const { user } = createUser(testDb);
|
|
expect((await updateUser(String(user.id), { username: '' })) as any).toMatchObject({
|
|
status: 400,
|
|
error: 'Username cannot be empty',
|
|
});
|
|
expect((await updateUser(String(user.id), { email: ' ' })) as any).toMatchObject({
|
|
status: 400,
|
|
error: 'Email cannot be empty',
|
|
});
|
|
// The row is untouched.
|
|
const row = await readUser(orm, user.id);
|
|
expect(row.username).toBe(user.username);
|
|
});
|
|
|
|
// The edit dialog sends the email on every save, so only a new address is the
|
|
// admin vouching for it; the account's own mark survives any other edit.
|
|
it('ADMIN-SVC-073 — a new email from the admin clears the self-set mark, an unchanged one keeps it', async () => {
|
|
const { user } = createUser(testDb, { email: 'self@example.com' });
|
|
await updateRows(orm, Users, { id: user.id }, { email_self_asserted: 1 });
|
|
|
|
await updateUser(String(user.id), { email: 'self@example.com', role: 'user', username: user.username });
|
|
expect((await readUser(orm, user.id)).email_self_asserted).toBe(1);
|
|
await updateUser(String(user.id), { email: 'SELF@example.com' });
|
|
expect((await readUser(orm, user.id)).email_self_asserted).toBe(1);
|
|
|
|
await updateUser(String(user.id), { email: 'confirmed@example.com' });
|
|
expect((await readUser(orm, user.id)).email_self_asserted).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── What an admin password reset ends, and what an ordinary edit must not ─────
|
|
|
|
const pv = async (id: number): Promise<number> => (await findRow(orm, Users, { id }))?.password_version ?? 0;
|
|
|
|
const mcpTokenCount = (id: number): Promise<number> => countRows(orm, McpTokens, { user: id });
|
|
|
|
const addPushDevice = async (userId: number, endpoint: string): Promise<void> => {
|
|
await insertRow(orm, PushSubscriptions, { user: userId, endpoint, p256dh: 'p', auth: 'a', vapid_public_key: 'k' });
|
|
};
|
|
|
|
const liveSessions = (id: number): Promise<number> => countRows(orm, UserSessions, { user: id, revoked_at: null });
|
|
|
|
const pushDeviceCount = (id: number): Promise<number> => countRows(orm, PushSubscriptions, { user: id });
|
|
|
|
const passwordHash = async (id: number): Promise<string> => (await readUser(orm, id)).password_hash;
|
|
|
|
describe('admin password reset revokes what an intruder already holds', () => {
|
|
it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', async () => {
|
|
// An admin sets somebody else's password for one reason: the account is
|
|
// believed compromised. Without the bump, verifyJwtAndLoadUser keeps
|
|
// accepting every cookie the intruder holds, and the one action taken to
|
|
// lock them out is the one action that did not.
|
|
const { user } = createUser(testDb);
|
|
const before = await pv(user.id);
|
|
|
|
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
|
|
|
|
expect(await pv(user.id)).toBe(before + 1);
|
|
});
|
|
|
|
it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', async () => {
|
|
const { user } = createUser(testDb);
|
|
await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' });
|
|
|
|
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
|
|
|
|
expect(await mcpTokenCount(user.id)).toBe(0);
|
|
});
|
|
|
|
it('ADMIN-SVC-081b: and forgets the push devices, which outlive every session, of that user only', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
|
|
await addPushDevice(user.id, 'https://web.push.apple.com/owner');
|
|
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander');
|
|
|
|
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
|
|
|
|
expect(await pushDeviceCount(user.id)).toBe(0);
|
|
expect(await pushDeviceCount(other.id)).toBe(1);
|
|
});
|
|
|
|
it('ADMIN-SVC-081c: drops them in the same transaction as the password, so a failure leaves the account as it was', async () => {
|
|
const { user } = createUser(testDb);
|
|
const before = await pv(user.id);
|
|
const hashBefore = await passwordHash(user.id);
|
|
await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' });
|
|
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
|
|
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
|
try {
|
|
await expect(updateUser(String(user.id), { password: 'ANewStrongPass123!' })).rejects.toThrow('boom');
|
|
} finally {
|
|
testDb.exec('DROP TRIGGER boom');
|
|
}
|
|
|
|
expect(await pv(user.id)).toBe(before);
|
|
expect(await passwordHash(user.id)).toBe(hashBefore);
|
|
expect(await mcpTokenCount(user.id)).toBe(1);
|
|
expect(await pushDeviceCount(user.id)).toBe(1);
|
|
});
|
|
|
|
it('ADMIN-SVC-081d: and ends every session of that user, and only of that user', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const intruder = await auth.generateToken({ id: user.id });
|
|
const bystander = await auth.generateToken({ id: other.id });
|
|
|
|
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
|
|
|
|
expect(await liveSessions(user.id)).toBe(0);
|
|
expect(await liveSessions(other.id)).toBe(1);
|
|
expect(await auth.verifyJwtToken(intruder)).toBeNull();
|
|
expect(await auth.verifyJwtToken(bystander)).not.toBeNull();
|
|
});
|
|
|
|
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', async () => {
|
|
const { user } = createUser(testDb);
|
|
const before = await pv(user.id);
|
|
await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' });
|
|
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed');
|
|
|
|
await updateUser(String(user.id), { username: 'renamed' });
|
|
|
|
expect(await pv(user.id)).toBe(before);
|
|
expect(await mcpTokenCount(user.id)).toBe(1);
|
|
expect(await pushDeviceCount(user.id)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('resetUserMfa', () => {
|
|
it('ADMIN-SVC-083b: ends the sessions of the account whose second factor it removed', async () => {
|
|
const admin = createAdmin(testDb);
|
|
const { user } = createUserWithMfa(testDb);
|
|
await auth.generateToken({ id: user.id });
|
|
await auth.generateToken({ id: admin.user.id });
|
|
|
|
expect(
|
|
(await asLegacyResult(svc.resetUserMfa(String(user.id), admin.user.id))) as { success?: boolean },
|
|
).toMatchObject({ success: true });
|
|
expect(await liveSessions(user.id)).toBe(0);
|
|
expect(await liveSessions(admin.user.id)).toBe(1);
|
|
});
|
|
|
|
it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', async () => {
|
|
// The passkey half has existed since passkeys landed; TOTP never had an
|
|
// answer, which left "somebody on the trip lost their phone" with no way out
|
|
// short of an operator reaching into the database.
|
|
const admin = createAdmin(testDb);
|
|
const { user } = createUserWithMfa(testDb);
|
|
|
|
const result = (await asLegacyResult(svc.resetUserMfa(String(user.id), admin.user.id))) as {
|
|
success?: boolean;
|
|
email?: string;
|
|
};
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.email).toBe(user.email);
|
|
const row = await readUser(orm, user.id);
|
|
expect(row.mfa_enabled).toBe(0);
|
|
expect(row.mfa_secret).toBeNull();
|
|
expect(row.mfa_backup_codes).toBeNull();
|
|
});
|
|
|
|
it('ADMIN-SVC-084 — refuses to strip the callers own second factor', async () => {
|
|
// Reachable from a stolen admin session otherwise, and it would take the
|
|
// second factor off the very account that session came from. The
|
|
// self-service path in Settings asks for the current password.
|
|
const admin = createAdmin(testDb);
|
|
|
|
const result = (await asLegacyResult(svc.resetUserMfa(String(admin.user.id), admin.user.id))) as {
|
|
error?: string;
|
|
status?: number;
|
|
};
|
|
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/your own/i);
|
|
});
|
|
|
|
it('ADMIN-SVC-085 — 404 for a user that is not there', async () => {
|
|
const admin = createAdmin(testDb);
|
|
|
|
expect((await asLegacyResult(svc.resetUserMfa('99999', admin.user.id))) as { status?: number }).toMatchObject({
|
|
status: 404,
|
|
});
|
|
});
|
|
});
|
|
|
|
// #3065: the provider re-issued a user's subject, so every sign-in hits
|
|
// "linked to another subject of this provider" and editing the email cannot help.
|
|
describe('unlinkUserSso', () => {
|
|
it('ADMIN-SVC-097 — clears sub and issuer so the next SSO sign-in links like a first one', async () => {
|
|
const admin = createAdmin(testDb);
|
|
const { user } = createUser(testDb);
|
|
await updateRows(orm, Users, { id: user.id }, { oidc_sub: 'sub-old', oidc_issuer: 'https://idp.example.com' });
|
|
|
|
const result = await svc.unlinkUserSso(String(user.id), admin.user.id);
|
|
|
|
expect(result).toEqual({ email: user.email, unlinked: true });
|
|
const row = await readUser(orm, user.id);
|
|
expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({ oidc_sub: null, oidc_issuer: null });
|
|
});
|
|
|
|
it('ADMIN-SVC-098 — an account without an SSO link answers unlinked: false and stays as it is', async () => {
|
|
const admin = createAdmin(testDb);
|
|
const { user } = createUser(testDb);
|
|
|
|
expect(await svc.unlinkUserSso(String(user.id), admin.user.id)).toEqual({ email: user.email, unlinked: false });
|
|
});
|
|
|
|
it('ADMIN-SVC-099 — refuses the callers own account and keeps its link', async () => {
|
|
const admin = createAdmin(testDb);
|
|
await updateRows(
|
|
orm,
|
|
Users,
|
|
{ id: admin.user.id },
|
|
{ oidc_sub: 'sub-admin', oidc_issuer: 'https://idp.example.com' },
|
|
);
|
|
|
|
const result = (await asLegacyResult(svc.unlinkUserSso(String(admin.user.id), admin.user.id))) as {
|
|
error?: string;
|
|
status?: number;
|
|
};
|
|
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/your own/i);
|
|
expect((await readUser(orm, admin.user.id)).oidc_sub).toBe('sub-admin');
|
|
});
|
|
|
|
it('ADMIN-SVC-100 — 404 for a user that is not there, and for a guest', async () => {
|
|
const admin = createAdmin(testDb);
|
|
const { user: guest } = createUser(testDb);
|
|
await updateRows(orm, Users, { id: guest.id }, { is_guest: 1 });
|
|
|
|
expect((await asLegacyResult(svc.unlinkUserSso('99999', admin.user.id))) as { status?: number }).toMatchObject({
|
|
status: 404,
|
|
});
|
|
expect(
|
|
(await asLegacyResult(svc.unlinkUserSso(String(guest.id), admin.user.id))) as { status?: number },
|
|
).toMatchObject({ status: 404 });
|
|
});
|
|
});
|
|
|
|
describe('checkVersion on a centrally administered install', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
__clearVersionCacheForTests();
|
|
});
|
|
|
|
it('ADMIN-SVC-086 — answers "up to date" without asking GitHub', () => {
|
|
// Answered rather than refused: the admin page calls this on open, and a 403
|
|
// would put an error where a quiet surface belongs. The operator decides when
|
|
// this instance upgrades, so from inside it there is nothing available.
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
vi.stubEnv('TREK_MANAGED', 'true');
|
|
vi.stubEnv('APP_VERSION', '3.4.1');
|
|
|
|
return checkVersion().then((info) => {
|
|
expect(info.update_available).toBe(false);
|
|
expect(info.latest).toBe('3.4.1');
|
|
expect(info.current).toBe('3.4.1');
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the AI addons on a centrally administered install', () => {
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
it('ADMIN-SVC-096 — hides AI parsing and the AI assistant, and refuses to switch either', async () => {
|
|
expect((await listAddons()).map((a) => a.id)).toContain(ADDON_IDS.AI_ASSISTANT);
|
|
vi.stubEnv('TREK_MANAGED', 'true');
|
|
const ids = (await listAddons()).map((a) => a.id);
|
|
expect(ids).not.toContain(ADDON_IDS.AI_ASSISTANT);
|
|
expect(ids).not.toContain(ADDON_IDS.LLM_PARSING);
|
|
expect(await updateAddon(ADDON_IDS.AI_ASSISTANT, { enabled: true })).toMatchObject({ status: 403 });
|
|
});
|
|
});
|
|
|
|
// ── Plan 3i Task 1 — R4's guard-location + TX-boundary + AD22 parity proofs ──
|
|
|
|
describe('updateUser — last-admin guard (AD9/AD10, R4)', () => {
|
|
it('ADMIN-SVC-090 — refuses to demote the sole remaining admin, row untouched', async () => {
|
|
const { user: soleAdmin } = createAdmin(testDb);
|
|
|
|
const result = (await updateUser(String(soleAdmin.id), { role: 'user' })) as { error?: string; status?: number };
|
|
|
|
expect(result).toEqual({ error: 'Cannot remove the last admin', status: 400 });
|
|
const row = await readUser(orm, soleAdmin.id);
|
|
expect(row.role).toBe('admin');
|
|
});
|
|
|
|
it('ADMIN-SVC-091 — demoting one of SEVERAL admins is allowed', async () => {
|
|
createAdmin(testDb);
|
|
const { user: secondAdmin } = createAdmin(testDb);
|
|
|
|
const result = (await updateUser(String(secondAdmin.id), { role: 'user' })) as {
|
|
user?: { role: string };
|
|
error?: string;
|
|
};
|
|
|
|
expect(result.error).toBeUndefined();
|
|
const row = await readUser(orm, secondAdmin.id);
|
|
expect(row.role).toBe('user');
|
|
});
|
|
});
|
|
|
|
describe('getAuditLog — AD22 parity through the service (LEFT JOIN survives a deleted user, rule 16)', () => {
|
|
it("ADMIN-SVC-092 — a deleted user's audit row keeps its row (not dropped) with username/user_email both null, matching the legacy raw LEFT JOIN", async () => {
|
|
const { user: liveUser } = createUser(testDb);
|
|
const { user: doomedUser } = createUser(testDb);
|
|
|
|
await insertRow(orm, AuditLog, {
|
|
user: liveUser.id,
|
|
action: 'live_user_action',
|
|
resource: 'trip',
|
|
details: null,
|
|
ip: '127.0.0.1',
|
|
});
|
|
await insertRow(orm, AuditLog, {
|
|
user: doomedUser.id,
|
|
action: 'about_to_be_deleted_action',
|
|
resource: 'trip',
|
|
details: null,
|
|
ip: '127.0.0.1',
|
|
});
|
|
|
|
// audit_log.user_id is ON DELETE SET NULL (Task 0's report) — deleting the
|
|
// user directly (not through the service) makes the row's user_id
|
|
// genuinely NULL, the exact LEFT JOIN shape rule 16 warns about.
|
|
await deleteRows(orm, Users, { id: doomedUser.id });
|
|
|
|
// The legacy statement, run raw on the SAME seeded rows — the parity anchor.
|
|
const legacyRows = testDb
|
|
// test-sql-allow: the legacy statement is this parity test's oracle and has to run as written.
|
|
.prepare(
|
|
`SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
|
FROM audit_log a
|
|
LEFT JOIN users u ON u.id = a.user_id
|
|
ORDER BY a.id DESC
|
|
LIMIT ? OFFSET ?`,
|
|
)
|
|
.all(500, 0) as Array<{
|
|
id: number;
|
|
created_at: string;
|
|
user_id: number | null;
|
|
username: string | null;
|
|
user_email: string | null;
|
|
action: string;
|
|
resource: string | null;
|
|
details: string | null;
|
|
ip: string | null;
|
|
}>;
|
|
|
|
const result = (await getAuditLog({ limit: '500', offset: '0' })) as {
|
|
entries: Array<{ user_id: number | null; username: string | null; user_email: string | null; action: string }>;
|
|
total: number;
|
|
};
|
|
|
|
expect(result.entries.length).toBe(legacyRows.length);
|
|
|
|
const deletedEntry = result.entries.find((e) => e.action === 'about_to_be_deleted_action');
|
|
const legacyDeletedRow = legacyRows.find((r) => r.action === 'about_to_be_deleted_action');
|
|
expect(deletedEntry).toBeDefined();
|
|
expect(legacyDeletedRow).toBeDefined();
|
|
// The row survives the LEFT JOIN (never dropped): user_id is preserved,
|
|
// username/user_email both null — never `undefined` (rule 16: T | null).
|
|
expect(deletedEntry!.user_id).toBe(legacyDeletedRow!.user_id);
|
|
expect(deletedEntry!.username).toBeNull();
|
|
expect(deletedEntry!.user_email).toBeNull();
|
|
expect(legacyDeletedRow!.username).toBeNull();
|
|
expect(legacyDeletedRow!.user_email).toBeNull();
|
|
|
|
const liveEntry = result.entries.find((e) => e.action === 'live_user_action');
|
|
const legacyLiveRow = legacyRows.find((r) => r.action === 'live_user_action');
|
|
expect(liveEntry!.username).toBe(legacyLiveRow!.username);
|
|
expect(liveEntry!.user_email).toBe(legacyLiveRow!.user_email);
|
|
});
|
|
});
|
|
|
|
describe('updateUser — password-reset transaction boundary (AD11/12/13)', () => {
|
|
it('ADMIN-SVC-093 — a failure on the mcp_tokens delete (AD12, not try/caught) rolls back the already-run users UPDATE (AD11) too', async () => {
|
|
const { user } = createUser(testDb);
|
|
const before = await readUser(orm, user.id);
|
|
|
|
const spy = vi
|
|
.spyOn(mcpTokensRepo, 'deleteAllForUser')
|
|
.mockRejectedValueOnce(new Error('simulated mcp_tokens failure'));
|
|
|
|
await expect(
|
|
updateUser(String(user.id), { username: 'should-roll-back', password: 'ANewStrongPass123!' }),
|
|
).rejects.toThrow('simulated mcp_tokens failure');
|
|
|
|
const after = await readUser(orm, user.id);
|
|
|
|
// The users UPDATE (AD11) ran FIRST, inside the SAME uow.transactional
|
|
// boundary as the failing mcp_tokens delete — proves the TX boundary
|
|
// survived conversion (not just that each statement individually works):
|
|
// an uncaught failure anywhere in the block rolls the whole thing back,
|
|
// including a statement that already "succeeded" earlier in the same TX.
|
|
expect(after.username).toBe(before.username);
|
|
expect(after.password_version).toBe(before.password_version);
|
|
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('createUser: the uniqueness-check TOCTOU window (AD2-4)', () => {
|
|
it('ADMIN-SVC-094: a race between the email pre-check and the INSERT is stopped by the row-level UNIQUE constraint and answered with the same clean 409', async () => {
|
|
const { user: existing } = createUser(testDb, { email: 'race@test.example.com' });
|
|
|
|
// Simulates the concurrent-request race AD2-4's non-transactional shape
|
|
// leaves open (unchanged by this conversion, per R4): the uniqueness
|
|
// pre-check (AD3) reports no conflict, as it would if a second request's
|
|
// own insert lands in the gap between this request's check and its own
|
|
// INSERT — the two statements are two separate, un-transacted repository
|
|
// calls, exactly as the legacy raw SQL was.
|
|
const usersRepo = await createTestUsersRepo(testDb);
|
|
const spy = vi.spyOn(usersRepo, 'findIdByEmailCI').mockResolvedValueOnce(null);
|
|
|
|
// The pre-check said "clear," but `users.email` carries its own UNIQUE
|
|
// index (`Migration20200101000000_baseline_schema.ts`), and that is what
|
|
// actually stops the duplicate, at the SQL level. The repository turns
|
|
// the refusal into UserIdentityTakenError, so the request that lost the
|
|
// race gets the same answer as one that lost it with more lead time.
|
|
await expect(
|
|
svcCreateUser({ username: 'raceuser', email: existing.email, password: 'ValidPass1!' }),
|
|
).resolves.toEqual({ error: 'Email already taken', status: 409 });
|
|
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('ADMIN-SVC-095: an address that differs only in case is already taken, on create and on update', async () => {
|
|
const { user: existing } = createUser(testDb, { email: 'Case@Test.example.com' });
|
|
const { user: other } = createUser(testDb, { email: 'other-case@test.example.com' });
|
|
|
|
await expect(
|
|
svcCreateUser({ username: 'caseuser', email: 'case@test.example.com', password: 'ValidPass1!' }),
|
|
).resolves.toEqual({ error: 'Email already taken', status: 409 });
|
|
await expect(updateUser(String(other.id), { email: 'CASE@TEST.EXAMPLE.COM' })).resolves.toEqual({
|
|
error: 'Email already taken',
|
|
status: 409,
|
|
});
|
|
expect(existing.id).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('a create or edit that loses the race for an email or username', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('ADMIN-IDENT-001: createUser answers the same 409 when the database refuses what the check missed', async () => {
|
|
createUser(testDb, { username: 'anna', email: 'anna@example.com' });
|
|
// The other write lands between the check and the insert.
|
|
vi.spyOn(UsersRepository.prototype, 'findIdByEmailCI').mockResolvedValue(null);
|
|
expect(await svcCreateUser({ username: 'anna2', email: 'ANNA@example.com', password: 'Secure123!' })).toEqual({
|
|
error: 'Email already taken',
|
|
status: 409,
|
|
});
|
|
});
|
|
|
|
it('ADMIN-IDENT-002: updateUser does the same, and changes nothing', async () => {
|
|
createUser(testDb, { username: 'anna', email: 'anna@example.com' });
|
|
const { user } = createUser(testDb, { username: 'carl', email: 'carl@example.com' });
|
|
vi.spyOn(UsersRepository.prototype, 'findIdByEmailCI').mockResolvedValue(null);
|
|
expect(await updateUser(String(user.id), { email: 'Anna@Example.com' })).toEqual({
|
|
error: 'Email already taken',
|
|
status: 409,
|
|
});
|
|
expect((await readUser(orm, user.id)).email).toBe('carl@example.com');
|
|
});
|
|
});
|