mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
1430056d24
The attempt counter and the backoff were written on every failure and never consulted when planning the next run: rows were found in a live database at 46 attempts against a limit of 6, so a document a provider refuses — one it reads as corrupt, a type it will not take, a full quota — was re-uploaded on every cron tick forever. planReconcile now skips a row that is shelved or still inside its window, and a manual run clears the counters, which is what "waits for a person" has to mean. The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed, with a comment explaining it, and nothing read it. Providers fire once per document, so twenty files dropped into a watched folder were twenty runs — the in-flight guard answered `busy` to nineteen of them, throwing away nineteen announcements, and the one run that did start had begun before most of the changes landed. A burst now folds into one trailing run, and the binding is re-read when the timer fires so a sync switched off in between does not get a last run out of a stale row. Four defects the new suites turned up, all fixed: the sync button lost its accessible name below sm, where its label is hidden; the stored address was never shown in the connect dialog although the form kept submitting it; the allow-self-signed switch only read the connection at mount, so a stored "on" showed as off and the next save wrote that back; and a refused folder create escaped as an unhandled rejection with nothing said to anybody. Also here: the phone shell gets the same feature (one sheet, four views, the same hook as the desktop), the marks come from the product's own icon files rather than redrawn approximations, and the 22 non-English locales carry the new keys. ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
707 lines
32 KiB
TypeScript
707 lines
32 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 { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
|
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
|
|
|
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
|
|
|
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: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => null,
|
|
isOwner: () => false,
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
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(),
|
|
}));
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createUserWithMfa, createAdmin, createInviteToken } from '../../helpers/factories';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
|
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
|
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
|
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
|
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
|
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
|
import { AuthService } from '../../../src/nest/auth/auth.service';
|
|
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
|
import { PackingService } from '../../../src/nest/packing/packing.service';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
|
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
|
import { AdminService } from '../../../src/nest/admin/admin.service';
|
|
import { VersionCheckJob } from '../../../src/nest/admin/version-check.job';
|
|
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
|
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers';
|
|
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
|
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
|
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
|
|
|
|
const dbs = new DatabaseService(testDb);
|
|
const realtime = new RealtimeService();
|
|
const permissions = new PermissionsService(dbs);
|
|
const webauthn = new WebauthnConfigService(dbs);
|
|
const userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime));
|
|
// 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.
|
|
const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs));
|
|
const svc = new AdminService(
|
|
dbs,
|
|
new AddonsService(dbs),
|
|
new PasskeyService(dbs, auth, webauthn),
|
|
auth,
|
|
permissions,
|
|
makeNotificationsService(dbs, realtime),
|
|
userCleanup,
|
|
realtime,
|
|
);
|
|
|
|
// 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]) => svc.createUser(d);
|
|
const updateUser = (id: string, d: Parameters<AdminService['updateUser']>[1]) => svc.updateUser(id, d);
|
|
const deleteUser = (id: string, actingId: number) => 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 = () => 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]) => svc.updateAddon(id, d);
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// ── listUsers ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('listUsers', () => {
|
|
it('ADMIN-SVC-001 — returns all users with online:false', () => {
|
|
createUser(testDb);
|
|
createUser(testDb);
|
|
const users = listUsers() as any[];
|
|
expect(users.length).toBeGreaterThanOrEqual(2);
|
|
expect(users.every((u: any) => u.online === false)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── createUser ────────────────────────────────────────────────────────────────
|
|
|
|
describe('createUser (service)', () => {
|
|
it('ADMIN-SVC-002 — creates a user successfully', () => {
|
|
const result = 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', () => {
|
|
const result = svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' }) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-004 — returns 400 for invalid role', () => {
|
|
const result = 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', () => {
|
|
createUser(testDb);
|
|
const { user } = createUser(testDb);
|
|
const result = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' }) as any;
|
|
expect(result.status).toBe(409);
|
|
});
|
|
|
|
it('ADMIN-SVC-007 — returns 400 for weak password', () => {
|
|
const result = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = 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', () => {
|
|
const result = updateUser('99999', { username: 'ghost' }) as any;
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it('ADMIN-SVC-010 — returns 400 for invalid role', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateUser(String(user.id), { role: 'superadmin' }) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-011 — returns 409 when username is taken', () => {
|
|
const { user: u1 } = createUser(testDb);
|
|
const { user: u2 } = createUser(testDb);
|
|
const result = updateUser(String(u2.id), { username: u1.username }) as any;
|
|
expect(result.status).toBe(409);
|
|
});
|
|
|
|
it('ADMIN-SVC-012 — returns 409 when email is taken', () => {
|
|
const { user: u1 } = createUser(testDb);
|
|
const { user: u2 } = createUser(testDb);
|
|
const result = updateUser(String(u2.id), { email: u1.email }) as any;
|
|
expect(result.status).toBe(409);
|
|
});
|
|
|
|
it('ADMIN-SVC-013 — returns 400 for weak password', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateUser(String(user.id), { password: 'weak' }) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-014 — tracks changed fields in result', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = 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', () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const { user } = createUser(testDb);
|
|
const result = deleteUser(String(user.id), admin.id) as any;
|
|
expect(result.email).toBe(user.email);
|
|
});
|
|
|
|
it('ADMIN-SVC-016 — returns 400 when deleting own account', () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const result = deleteUser(String(admin.id), admin.id) as any;
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('ADMIN-SVC-017 — returns 404 for non-existent user', () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const result = deleteUser('99999', admin.id) as any;
|
|
expect(result.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ── getStats ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('getStats', () => {
|
|
it('ADMIN-SVC-018 — returns numeric counts for all stats', () => {
|
|
const stats = 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', () => {
|
|
const result = 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', () => {
|
|
savePermissions({ trip_create: 'admin' });
|
|
const result = 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', () => {
|
|
const result = 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', () => {
|
|
const result = 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', () => {
|
|
const result = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
|
|
user.id, 'test_action', JSON.stringify({ key: 'val' })
|
|
);
|
|
const result = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
|
|
user.id, 'bad_json_action', 'not-valid-json{'
|
|
);
|
|
const result = 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"', () => {
|
|
vi.stubEnv('DEMO_MODE', 'false');
|
|
const result = 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"', () => {
|
|
// 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 = 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);
|
|
});
|
|
});
|
|
|
|
// ── 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', () => {
|
|
const result = 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');
|
|
});
|
|
});
|
|
|
|
// ── updateAddon ───────────────────────────────────────────────────────────────
|
|
|
|
describe('updateAddon', () => {
|
|
it('ADMIN-SVC-066 — updateAddon enables and disables a seeded addon', () => {
|
|
const disabled = updateAddon('mcp', { enabled: false }) as any;
|
|
expect(disabled.addon).toBeDefined();
|
|
expect(disabled.addon.enabled).toBe(false);
|
|
|
|
const enabled = updateAddon('mcp', { enabled: true }) as any;
|
|
expect(enabled.addon.enabled).toBe(true);
|
|
});
|
|
|
|
it('ADMIN-SVC-067 — updateAddon returns 404 for unknown addon id', () => {
|
|
const result = 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)', () => {
|
|
updateAddon('packing', { enabled: true });
|
|
// no-op save (enabled already true) → sessions survive
|
|
expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(false);
|
|
// config-only save → sessions survive
|
|
expect((updateAddon('packing', { config: { foo: 'bar' } }) as any).mcpAffected).toBe(false);
|
|
// real flip of an MCP-relevant addon → invalidate
|
|
expect((updateAddon('packing', { enabled: false }) as any).mcpAffected).toBe(true);
|
|
expect((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 = 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 = 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', () => {
|
|
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run();
|
|
testDb.prepare("UPDATE photo_providers SET enabled = 0 WHERE id = 'immich'").run();
|
|
|
|
const result = updateAddon('immich', { enabled: true }) as any;
|
|
expect(result).toEqual({ error: 'Enable the Journey addon first', status: 409 });
|
|
expect(testDb.prepare("SELECT enabled FROM photo_providers WHERE id = 'immich'").get()).toEqual({ enabled: 0 });
|
|
});
|
|
|
|
it('ADMIN-SVC-088 — enables a provider under an enabled journey; disabling never needs journey', () => {
|
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run();
|
|
const enabled = 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.
|
|
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run();
|
|
const disabled = updateAddon('immich', { enabled: false }) as any;
|
|
expect(disabled.addon.enabled).toBe(false);
|
|
});
|
|
|
|
it('ADMIN-SVC-089 — disabling journey cascades every photo provider off', () => {
|
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run();
|
|
testDb.prepare('UPDATE photo_providers SET enabled = 1').run();
|
|
|
|
const result = updateAddon('journey', { enabled: false }) as any;
|
|
expect(result.addon.enabled).toBe(false);
|
|
const rows = testDb.prepare('SELECT enabled FROM photo_providers').all() as Array<{ enabled: number }>;
|
|
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();
|
|
|
|
const notified = testDb
|
|
.prepare('SELECT value FROM app_settings WHERE key = ?')
|
|
.get('last_notified_version') as { value: string } | undefined;
|
|
expect(notified?.value).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', () => {
|
|
const { user } = createUser(testDb);
|
|
expect(updateUser(String(user.id), { username: '' }) as any).toMatchObject({ status: 400, error: 'Username cannot be empty' });
|
|
expect(updateUser(String(user.id), { email: ' ' }) as any).toMatchObject({ status: 400, error: 'Email cannot be empty' });
|
|
// The row is untouched.
|
|
const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string };
|
|
expect(row.username).toBe(user.username);
|
|
});
|
|
|
|
|
|
});
|
|
|
|
// ── What an admin password reset ends, and what an ordinary edit must not ─────
|
|
|
|
const pv = (id: number): number =>
|
|
(testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number | null })
|
|
?.password_version ?? 0;
|
|
|
|
const mcpTokenCount = (id: number): number =>
|
|
(testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n;
|
|
|
|
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', () => {
|
|
// 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 = pv(user.id);
|
|
|
|
updateUser(String(user.id), { password: 'ANewStrongPass123!' });
|
|
|
|
expect(pv(user.id)).toBe(before + 1);
|
|
});
|
|
|
|
it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', () => {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
|
|
|
|
updateUser(String(user.id), { password: 'ANewStrongPass123!' });
|
|
|
|
expect(mcpTokenCount(user.id)).toBe(0);
|
|
});
|
|
|
|
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', () => {
|
|
const { user } = createUser(testDb);
|
|
const before = pv(user.id);
|
|
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
|
|
|
|
updateUser(String(user.id), { username: 'renamed' });
|
|
|
|
expect(pv(user.id)).toBe(before);
|
|
expect(mcpTokenCount(user.id)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('resetUserMfa', () => {
|
|
it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', () => {
|
|
// 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 = 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 = testDb
|
|
.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes FROM users WHERE id = ?')
|
|
.get(user.id) as { mfa_enabled: number; mfa_secret: string | null; mfa_backup_codes: string | null };
|
|
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', () => {
|
|
// 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 = 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', () => {
|
|
const admin = createAdmin(testDb);
|
|
|
|
expect(svc.resetUserMfa('99999', 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();
|
|
});
|
|
});
|
|
});
|