mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
49c3c57c31
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup timer only because it happened to be the domain that mints tokens. Issuing them and sweeping them are different jobs, and FilesService and TokenService mint or consume them too. EphemeralTokenService owns the lifecycle and hands the three operations out. The store and the interval stay module state next door, and that is load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on the upgrade handshake, outside the container, before any injector is in reach. Per-instance state would hand it an empty Map and close every connection with 4001, which no test would catch because the ws suites drive the transport directly. WS-027 through WS-029 pin the session gate that runs through exactly that path. The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken returns null at 10,000 live tokens and TokenService turns that into a 503. A second store would raise the real ceiling with nobody noticing. Its own leaf module rather than a provider in AuthModule: TokensModule mints download and ws tokens as well, AuthModule already imports TokensModule, and TokensModule is documented as a deliberate leaf that three graphs reach for. Same shape as MailerModule, which broke AuthModule <-> NotificationsModule. Twelve hand-wired construction sites in tests needed the new collaborator. The typecheck named all twelve; before this branch they would have been undefined at runtime. Sixteen more test files are cleared here too, each at its root cause. Four of them were passing for the wrong reason: files.controller.test.ts drove two handlers with shifted arguments, a string tripId where the trip row belongs and undefined where tripId and id belong. It stayed green because FilesService is fully stubbed and the stubs ignore what they are given, so DELETE /:id/permanent and DELETE /trash/empty never covered that the trip row reaches the permission check. conflictUpdate.test.ts was missing real collaborators on PlacesService and PackingService. None of the exercised paths reach them, so it passed by luck. assignments.service.test.ts builds its service by hand, and the one consumer of the collaborator it was missing swallows every throw in a bare catch. A broken journey reconciliation would still have shown 28 green tests. scheduler.test.ts mocks two modules with paths that resolve one directory above the server root, so they mock nothing at all. Harmless only because src/scheduler.ts imports neither.
524 lines
24 KiB
TypeScript
524 lines
24 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. ADMIN-BR-001 pins the admin.bridge export.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
|
|
|
// ── 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, 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 { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
|
import { AdminService } from '../../../src/nest/admin/admin.service';
|
|
import { checkAndNotifyVersion as bridgeCheckAndNotifyVersion } from '../../../src/nest/admin/admin.bridge';
|
|
import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers';
|
|
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
|
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.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);
|
|
// 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());
|
|
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');
|
|
});
|
|
});
|
|
|
|
// ── 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
|
|
const docsFlip = updateAddon('documents', { enabled: false }) as any;
|
|
if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── admin.bridge ──────────────────────────────────────────────────────────────
|
|
|
|
describe('admin.bridge', () => {
|
|
it('ADMIN-BR-001 — checkAndNotifyVersion delegates to AdminService over the shared db Proxy', 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 bridgeCheckAndNotifyVersion();
|
|
|
|
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 bridge instance
|
|
// and the container singleton share it — the second read must not re-fetch.
|
|
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);
|
|
});
|
|
|
|
|
|
});
|