mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
03493a2edd
* refactor(server): move the webauthn config and the account erasure into nest/auth webauthnConfig resolved the RP ID through its own getSetting() on the db singleton; it is a WebauthnConfigService over DatabaseService now, injected into AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv() is still read per call, so an operator flipping the RP ID at runtime takes effect on the next ceremony instead of after a restart. userCleanupService follows as UserCleanupService. It keeps taking removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService: BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule cannot import BudgetModule back, and breaking that edge the other way drags BudgetController, BudgetMcp and its trips.bridge chain into every graph that imports AuthModule. The legacy module had no tests of its own - the erasure only ever ran through AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009 pin both halves, including the orphan data dir that no permissions row can describe and the all-or-nothing deletion transaction. * refactor(server): fold the oauth 2.1 server out of services/ 694 lines move into OauthService over DatabaseService, AddonsService and AuditService. The thin wrapper that only forwarded is gone, and so is the second path to the MCP addon flag - validateAuthorizeRequest read it through addons.bridge while the wrapper read it through the injected service, which would have let the two drift. The pending authorization codes stay a module singleton in oauth.pending-codes.ts instead of becoming instance state. The SDK still serves /oauth/authorize, /oauth/register and /oauth/consent from its own router, mounted on Express before app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps would break the authorization-code flow silently, since the SDK only ever answers "Authorization grant is invalid.". The import-time sweep keeps its .unref() and is a named function now so a test can drive it. scopes and sessionManager are imported deep, never through the src/mcp barrel: the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and auth.service carry. The 70 cases of the legacy suite moved over unchanged, bound to a hand-built service. Fifteen more cover what free functions never let anyone reach: the code store at capacity, an expired code, an expired access token, refresh against a public and an unknown client, the RFC 8707 resource checks, and that a code written through the container is read back through the bridge. * refactor(server): move the wiki reader into the help module wikiService had exactly one consumer, HelpController, and touches no database - it stays a plain module rather than becoming a provider, the way auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per process without any ceremony around it. The WIKI_DIR anchor needed one more level: src/services sits three below the repo root, src/nest/help sits four. The existing path-resolution case caught it, which is what it is there for - a wrong anchor makes help degrade to the GitHub fallback without anybody noticing. Also brings the nest README back in line: services/ is down to five top-level files, and the passkey and budget entries no longer point at paths that were deleted since they were written.
229 lines
8.9 KiB
TypeScript
229 lines
8.9 KiB
TypeScript
/**
|
|
* Unit tests for AdminService.checkAndNotifyVersion() — VNOTIF-001 to
|
|
* VNOTIF-007, moved from tests/unit/services/versionNotification.test.ts with
|
|
* the 2026-08 admin fold, IDs preserved. Kept separate from admin.service.test.ts
|
|
* because it stubs global fetch and drives the module-scoped version cache per
|
|
* case, which would leak into the ADMIN-SVC-* suite. The notification path runs
|
|
* for real against the temp db's notifications table.
|
|
*/
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers';
|
|
import { createAdmin } from '../../helpers/factories';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
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');
|
|
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-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
|
// Mock MCP to avoid session side-effects
|
|
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn(), invalidateMcpSessions: vi.fn() }));
|
|
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn() }));
|
|
|
|
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 { 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';
|
|
|
|
const dbs = new DatabaseService(testDb);
|
|
const realtime = new RealtimeService();
|
|
const permissions = new PermissionsService(dbs);
|
|
const webauthn = new WebauthnConfigService(dbs);
|
|
const userCleanup = new UserCleanupService(dbs);
|
|
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup);
|
|
const svc = new AdminService(
|
|
dbs,
|
|
new SettingsService(dbs),
|
|
new AddonsService(dbs),
|
|
new PasskeyService(dbs, auth, webauthn),
|
|
new PackingService(dbs, permissions, realtime),
|
|
auth,
|
|
permissions,
|
|
new NotificationsService(dbs, realtime),
|
|
userCleanup,
|
|
);
|
|
const checkAndNotifyVersion = () => svc.checkAndNotifyVersion();
|
|
|
|
// Helper: mock the GitHub releases/latest endpoint
|
|
function mockGitHubLatest(tagName: string, ok = true): void {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok,
|
|
// fetchGithub reads text() and parses it itself (size cap), so stub both.
|
|
text: async () => JSON.stringify({ tag_name: tagName, html_url: `https://github.com/liketrek/TREK/releases/tag/${tagName}` }),
|
|
json: async () => ({ tag_name: tagName, html_url: `https://github.com/liketrek/TREK/releases/tag/${tagName}` }),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function mockGitHubFetchFailure(): void {
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
|
}
|
|
|
|
function getLastNotifiedVersion(): string | undefined {
|
|
return (
|
|
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
|
|
| { value: string }
|
|
| undefined
|
|
)?.value;
|
|
}
|
|
|
|
function getNotificationCount(): number {
|
|
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
|
}
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
__clearVersionCacheForTests();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// checkAndNotifyVersion
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('checkAndNotifyVersion', () => {
|
|
it('VNOTIF-001 — does nothing when no update is available', async () => {
|
|
createAdmin(testDb);
|
|
// GitHub reports same version as package.json (or older) → update_available: false
|
|
const { version } = require('../../../package.json');
|
|
mockGitHubLatest(`v${version}`);
|
|
|
|
await checkAndNotifyVersion();
|
|
|
|
expect(getNotificationCount()).toBe(0);
|
|
expect(getLastNotifiedVersion()).toBeUndefined();
|
|
});
|
|
|
|
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
|
|
const { user: admin1 } = createAdmin(testDb);
|
|
const { user: admin2 } = createAdmin(testDb);
|
|
mockGitHubLatest('v99.0.0');
|
|
|
|
await checkAndNotifyVersion();
|
|
|
|
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{
|
|
recipient_id: number;
|
|
type: string;
|
|
scope: string;
|
|
}>;
|
|
expect(notifications.length).toBe(2);
|
|
const recipientIds = notifications.map((n) => n.recipient_id);
|
|
expect(recipientIds).toContain(admin1.id);
|
|
expect(recipientIds).toContain(admin2.id);
|
|
expect(notifications[0].type).toBe('navigate');
|
|
expect(notifications[0].scope).toBe('admin');
|
|
});
|
|
|
|
it('VNOTIF-003 — sets last_notified_version in app_settings after notifying', async () => {
|
|
createAdmin(testDb);
|
|
mockGitHubLatest('v99.1.0');
|
|
|
|
await checkAndNotifyVersion();
|
|
|
|
expect(getLastNotifiedVersion()).toBe('99.1.0');
|
|
});
|
|
|
|
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
|
|
createAdmin(testDb);
|
|
mockGitHubLatest('v99.2.0');
|
|
|
|
// First call notifies
|
|
await checkAndNotifyVersion();
|
|
const countAfterFirst = getNotificationCount();
|
|
expect(countAfterFirst).toBe(1);
|
|
|
|
// Second call with same version — should not create another
|
|
await checkAndNotifyVersion();
|
|
expect(getNotificationCount()).toBe(countAfterFirst);
|
|
});
|
|
|
|
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
|
|
createAdmin(testDb);
|
|
// Simulate having been notified about an older version
|
|
testDb
|
|
.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
|
.run('last_notified_version', '98.0.0');
|
|
mockGitHubLatest('v99.3.0');
|
|
|
|
await checkAndNotifyVersion();
|
|
|
|
expect(getNotificationCount()).toBe(1);
|
|
expect(getLastNotifiedVersion()).toBe('99.3.0');
|
|
});
|
|
|
|
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
|
|
createAdmin(testDb);
|
|
mockGitHubLatest('v99.4.0');
|
|
|
|
await checkAndNotifyVersion();
|
|
|
|
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
|
|
type: string;
|
|
scope: string;
|
|
navigate_target: string;
|
|
title_key: string;
|
|
text_key: string;
|
|
navigate_text_key: string;
|
|
};
|
|
expect(notif.type).toBe('navigate');
|
|
expect(notif.scope).toBe('admin');
|
|
expect(notif.navigate_target).toBe('/admin');
|
|
expect(notif.title_key).toBe('notif.version_available.title');
|
|
expect(notif.text_key).toBe('notif.version_available.text');
|
|
expect(notif.navigate_text_key).toBe('notif.action.view_admin');
|
|
});
|
|
|
|
it('VNOTIF-007 — silently handles GitHub API fetch failure (no crash, no notification)', async () => {
|
|
createAdmin(testDb);
|
|
mockGitHubFetchFailure();
|
|
|
|
// Should not throw
|
|
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
|
|
expect(getNotificationCount()).toBe(0);
|
|
expect(getLastNotifiedVersion()).toBeUndefined();
|
|
});
|
|
});
|