mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
093e069ccc
* refactor(auth): session token validation and password-change consistency * refactor(journey): entry field allow-list and public share-link consistency * refactor(mcp): align tool authorization with the REST permission checks * chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
708 lines
29 KiB
TypeScript
708 lines
29 KiB
TypeScript
/**
|
|
* authServiceDb.test.ts
|
|
*
|
|
* DB-centric unit tests for authService.ts using a real in-memory SQLite database.
|
|
* Pure function tests live in authService.test.ts (stub DB); this file covers
|
|
* functions that require actual DB queries to exercise their logic.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// vi.hoisted: build the real in-memory DB and the module mock before any import
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
canAccessTrip: (tripId: any, userId: number) =>
|
|
db
|
|
.prepare(
|
|
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`
|
|
)
|
|
.get(userId, tripId, userId),
|
|
isOwner: (tripId: any, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
SESSION_DURATION_SECONDS: 86400,
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/services/mfaCrypto', () => ({
|
|
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
|
|
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
|
|
}));
|
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
|
decrypt_api_key: vi.fn((v) => v),
|
|
maybe_encrypt_api_key: vi.fn((v) => v),
|
|
mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)),
|
|
encrypt_api_key: vi.fn((v) => v),
|
|
}));
|
|
vi.mock('../../../src/services/permissions', () => ({
|
|
getAllPermissions: vi.fn(() => ({})),
|
|
checkPermission: vi.fn(),
|
|
}));
|
|
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
|
|
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
|
vi.mock('../../../src/scheduler', () => ({
|
|
startTripReminders: vi.fn(),
|
|
buildCronExpression: vi.fn(),
|
|
loadSettings: vi.fn(() => ({ enabled: false })),
|
|
VALID_INTERVALS: ['daily', 'weekly', 'monthly'],
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Imports (after mocks)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
|
|
import {
|
|
updateSettings,
|
|
getSettings,
|
|
listUsers,
|
|
getAppSettings,
|
|
validateKeys,
|
|
isOidcOnlyMode,
|
|
resolveAuthToggles,
|
|
setupMfa,
|
|
enableMfa,
|
|
disableMfa,
|
|
validateInviteToken,
|
|
registerUser,
|
|
loginUser,
|
|
changePassword,
|
|
verifyMfaLogin,
|
|
createMcpToken,
|
|
deleteMcpToken,
|
|
generateToken,
|
|
} from '../../../src/services/authService';
|
|
import { verifyJwtAndLoadUser } from '../../../src/middleware/auth';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => resetTestDb(testDb));
|
|
|
|
afterAll(() => testDb.close());
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// updateSettings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('updateSettings', () => {
|
|
it('AUTH-DB-001: updates username successfully', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateSettings(user.id, { username: 'newname' });
|
|
expect(result.success).toBe(true);
|
|
expect(result.user?.username).toBe('newname');
|
|
});
|
|
|
|
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateSettings(user.id, { username: 'x' });
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/between 2 and 50/i);
|
|
});
|
|
|
|
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateSettings(user.id, { username: 'bad name' });
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/only contain/i);
|
|
});
|
|
|
|
it('AUTH-DB-004: returns 409 when username is already taken by another user', () => {
|
|
const { user: user1 } = createUser(testDb, { username: 'alice' });
|
|
const { user: user2 } = createUser(testDb, { username: 'bob' });
|
|
const result = updateSettings(user2.id, { username: user1.username });
|
|
expect(result.status).toBe(409);
|
|
expect(result.error).toMatch(/already taken/i);
|
|
});
|
|
|
|
it('AUTH-DB-005: updates email successfully', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateSettings(user.id, { email: 'new@example.com' });
|
|
expect(result.success).toBe(true);
|
|
expect(result.user?.email).toBe('new@example.com');
|
|
});
|
|
|
|
it('AUTH-DB-006: returns 400 for invalid email format', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateSettings(user.id, { email: 'not-an-email' });
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/invalid email/i);
|
|
});
|
|
|
|
it('AUTH-DB-007: returns 409 when email is already taken by another user', () => {
|
|
const { user: user1 } = createUser(testDb, { email: 'taken@example.com' });
|
|
const { user: user2 } = createUser(testDb);
|
|
const result = updateSettings(user2.id, { email: user1.email });
|
|
expect(result.status).toBe(409);
|
|
expect(result.error).toMatch(/already taken/i);
|
|
});
|
|
|
|
it('AUTH-DB-008: returns success with no field changes when empty body is passed', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = updateSettings(user.id, {});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getSettings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('getSettings', () => {
|
|
it('AUTH-DB-009: returns 403 for non-admin user', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = getSettings(user.id);
|
|
expect(result.status).toBe(403);
|
|
expect(result.error).toMatch(/admin/i);
|
|
});
|
|
|
|
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', () => {
|
|
const { user } = createAdmin(testDb);
|
|
testDb
|
|
.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?')
|
|
.run('maps-key-value', 'weather-key-value', user.id);
|
|
const result = getSettings(user.id);
|
|
expect(result.status).toBeUndefined();
|
|
expect(result.settings).toBeDefined();
|
|
expect(result.settings).toHaveProperty('maps_api_key');
|
|
expect(result.settings).toHaveProperty('openweather_api_key');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listUsers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('listUsers', () => {
|
|
it('AUTH-DB-011: returns all users except self, sorted by username', () => {
|
|
const { user: self } = createUser(testDb, { username: 'zzself' });
|
|
createUser(testDb, { username: 'alice' });
|
|
createUser(testDb, { username: 'charlie' });
|
|
createUser(testDb, { username: 'bob' });
|
|
const result = listUsers(self.id);
|
|
expect(result).toHaveLength(3);
|
|
const names = result.map((u) => u.username);
|
|
expect(names).toEqual([...names].sort());
|
|
expect(names).not.toContain('zzself');
|
|
});
|
|
|
|
it('AUTH-DB-012: returns empty array when only one user exists', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = listUsers(user.id);
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getAppSettings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('getAppSettings', () => {
|
|
it('AUTH-DB-013: returns 403 for non-admin', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = getAppSettings(user.id);
|
|
expect(result.status).toBe(403);
|
|
expect(result.error).toMatch(/admin/i);
|
|
});
|
|
|
|
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => {
|
|
const { user } = createAdmin(testDb);
|
|
testDb
|
|
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
|
|
.run();
|
|
const result = getAppSettings(user.id);
|
|
expect(result.status).toBeUndefined();
|
|
expect(result.data).toBeDefined();
|
|
expect(result.data).toHaveProperty('allow_registration', 'true');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateKeys
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('validateKeys', () => {
|
|
it('AUTH-DB-015: returns 403 for non-admin', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await validateKeys(user.id);
|
|
expect(result.status).toBe(403);
|
|
expect(result.error).toMatch(/admin/i);
|
|
expect(result.maps).toBe(false);
|
|
expect(result.weather).toBe(false);
|
|
});
|
|
|
|
it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => {
|
|
const { user } = createAdmin(testDb);
|
|
const result = await validateKeys(user.id);
|
|
expect(result.maps).toBe(false);
|
|
expect(result.weather).toBe(false);
|
|
expect(result.maps_details).toBeNull();
|
|
});
|
|
|
|
it('AUTH-DB-017: returns { maps: true } when fetch returns 200', async () => {
|
|
const { user } = createAdmin(testDb);
|
|
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
|
|
|
|
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
|
|
status: 200,
|
|
statusText: 'OK',
|
|
text: async () => '',
|
|
} as Response);
|
|
|
|
const result = await validateKeys(user.id);
|
|
expect(result.maps).toBe(true);
|
|
expect(result.maps_details?.ok).toBe(true);
|
|
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
it('AUTH-DB-018: returns { maps: false } when fetch throws a network error', async () => {
|
|
const { user } = createAdmin(testDb);
|
|
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
|
|
|
|
const fetchSpy = vi
|
|
.spyOn(global, 'fetch')
|
|
.mockRejectedValueOnce(new Error('Network failure'));
|
|
|
|
const result = await validateKeys(user.id);
|
|
expect(result.maps).toBe(false);
|
|
expect(result.maps_details?.error_status).toBe('FETCH_ERROR');
|
|
expect(result.maps_details?.error_message).toBe('Network failure');
|
|
|
|
fetchSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isOidcOnlyMode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('isOidcOnlyMode', () => {
|
|
it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => {
|
|
vi.stubEnv('OIDC_ONLY', '');
|
|
expect(isOidcOnlyMode()).toBe(false);
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', () => {
|
|
vi.stubEnv('OIDC_ONLY', 'true');
|
|
vi.stubEnv('OIDC_ISSUER', '');
|
|
vi.stubEnv('OIDC_CLIENT_ID', '');
|
|
expect(isOidcOnlyMode()).toBe(false);
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', () => {
|
|
vi.stubEnv('OIDC_ONLY', 'true');
|
|
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
|
|
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
|
|
expect(isOidcOnlyMode()).toBe(true);
|
|
vi.unstubAllEnvs();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveAuthToggles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resolveAuthToggles', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run();
|
|
});
|
|
|
|
it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', () => {
|
|
vi.stubEnv('OIDC_ONLY', '');
|
|
const t = resolveAuthToggles();
|
|
expect(t.password_login).toBe(true);
|
|
expect(t.password_registration).toBe(true);
|
|
expect(t.oidc_login).toBe(true);
|
|
expect(t.oidc_registration).toBe(true);
|
|
});
|
|
|
|
it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', () => {
|
|
vi.stubEnv('OIDC_ONLY', 'true');
|
|
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
|
|
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
|
|
const t = resolveAuthToggles();
|
|
expect(t.password_login).toBe(false);
|
|
expect(t.password_registration).toBe(false);
|
|
expect(t.oidc_login).toBe(true);
|
|
expect(t.oidc_registration).toBe(true);
|
|
});
|
|
|
|
it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', () => {
|
|
vi.stubEnv('OIDC_ONLY', '');
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
|
const t = resolveAuthToggles();
|
|
expect(t.password_login).toBe(true);
|
|
expect(t.password_registration).toBe(false);
|
|
expect(t.oidc_login).toBe(true);
|
|
expect(t.oidc_registration).toBe(false);
|
|
});
|
|
|
|
it('AUTH-DB-022d: new granular keys take precedence over legacy keys', () => {
|
|
vi.stubEnv('OIDC_ONLY', '');
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
|
|
const t = resolveAuthToggles();
|
|
// New key present → use new keys, allow_registration ignored
|
|
expect(t.password_registration).toBe(true);
|
|
expect(t.oidc_registration).toBe(true); // defaults to true when key not set
|
|
});
|
|
|
|
it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', () => {
|
|
vi.stubEnv('OIDC_ONLY', 'true');
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
|
|
const t = resolveAuthToggles();
|
|
// OIDC_ONLY forces password toggles off even when DB says true
|
|
expect(t.password_login).toBe(false);
|
|
expect(t.password_registration).toBe(false);
|
|
});
|
|
|
|
it('AUTH-DB-022f: individual granular keys can be set independently', () => {
|
|
vi.stubEnv('OIDC_ONLY', '');
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run();
|
|
const t = resolveAuthToggles();
|
|
expect(t.password_login).toBe(true);
|
|
expect(t.password_registration).toBe(false);
|
|
expect(t.oidc_login).toBe(true);
|
|
expect(t.oidc_registration).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// setupMfa
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('setupMfa', () => {
|
|
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => {
|
|
vi.stubEnv('DEMO_MODE', 'true');
|
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
|
const result = setupMfa(user.id, 'demo@nomad.app');
|
|
expect(result.status).toBe(403);
|
|
expect(result.error).toMatch(/demo mode/i);
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('AUTH-DB-023: returns 400 when MFA is already enabled', () => {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
|
|
const result = setupMfa(user.id, user.email);
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/already enabled/i);
|
|
});
|
|
|
|
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = setupMfa(user.id, user.email);
|
|
expect(result.error).toBeUndefined();
|
|
expect(typeof result.secret).toBe('string');
|
|
expect(result.secret!.length).toBeGreaterThan(0);
|
|
expect(typeof result.otpauth_url).toBe('string');
|
|
expect(result.otpauth_url).toMatch(/^otpauth:\/\/totp\//);
|
|
expect(result.qrPromise).toBeInstanceOf(Promise);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// enableMfa
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('enableMfa', () => {
|
|
it('AUTH-DB-025: returns 400 when no verification code is provided', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = enableMfa(user.id, undefined);
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/code is required/i);
|
|
});
|
|
|
|
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => {
|
|
const { user } = createUser(testDb);
|
|
// No setupMfa called first, so no pending entry exists
|
|
const result = enableMfa(user.id, '123456');
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/no mfa setup in progress/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// disableMfa
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('disableMfa', () => {
|
|
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => {
|
|
vi.stubEnv('DEMO_MODE', 'true');
|
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
|
const result = disableMfa(user.id, 'demo@nomad.app', {
|
|
password: 'password123',
|
|
code: '000000',
|
|
});
|
|
expect(result.status).toBe(403);
|
|
expect(result.error).toMatch(/demo mode/i);
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('AUTH-DB-028: returns 400 when password or code is missing', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const missingCode = disableMfa(user.id, user.email, { password: 'pass', code: undefined });
|
|
expect(missingCode.status).toBe(400);
|
|
expect(missingCode.error).toMatch(/password and authenticator code/i);
|
|
|
|
const missingPassword = disableMfa(user.id, user.email, { password: undefined, code: '123456' });
|
|
expect(missingPassword.status).toBe(400);
|
|
expect(missingPassword.error).toMatch(/password and authenticator code/i);
|
|
});
|
|
|
|
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => {
|
|
const { user } = createUser(testDb);
|
|
// mfa_enabled defaults to 0 / not set
|
|
const result = disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/not enabled/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateInviteToken
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('validateInviteToken', () => {
|
|
it('AUTH-DB-030: returns 404 for unknown token', () => {
|
|
const result = validateInviteToken('no-such-token');
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it('AUTH-DB-031: returns 410 when max_uses exceeded', () => {
|
|
// createInviteToken with used_count already at max
|
|
const invite = createInviteToken(testDb, { max_uses: 1 });
|
|
// manually set used_count = 1 to simulate exhaustion
|
|
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
|
const result = validateInviteToken(invite.token);
|
|
expect(result.status).toBe(410);
|
|
});
|
|
|
|
it('AUTH-DB-032: returns 410 when expired', () => {
|
|
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
|
|
const result = validateInviteToken(invite.token);
|
|
expect(result.status).toBe(410);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// registerUser — OIDC-only / registration-disabled
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('registerUser — OIDC-only / registration-disabled', () => {
|
|
it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', () => {
|
|
createUser(testDb); // ensure userCount > 0
|
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
|
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
|
|
|
const result = registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
|
|
expect(result.status).toBe(403);
|
|
expect(result.error).toMatch(/password registration is disabled/i);
|
|
});
|
|
|
|
it('AUTH-DB-034: returns 403 when registration is disabled and no invite', () => {
|
|
createUser(testDb); // ensure userCount > 0
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
|
|
|
const result = registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' });
|
|
expect(result.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// loginUser — OIDC-only mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('loginUser — OIDC-only mode', () => {
|
|
it('AUTH-DB-035: returns 403 when oidc_only=true', () => {
|
|
const { user, password } = createUser(testDb);
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
|
|
|
const result = loginUser({ email: user.email, password });
|
|
expect(result.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// changePassword — OIDC-only mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('changePassword — OIDC-only mode', () => {
|
|
it('AUTH-DB-036: returns 403 when oidc_only=true', () => {
|
|
const { user, password } = createUser(testDb);
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
|
|
|
const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
|
expect(result.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('changePassword — session invalidation', () => {
|
|
const pvOf = (id: number) =>
|
|
(testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number }).password_version;
|
|
const mcpCount = (id: number) =>
|
|
(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(id) as { c: number }).c;
|
|
|
|
it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', () => {
|
|
const { user, password } = createUser(testDb);
|
|
createMcpToken(user.id, 'cli');
|
|
|
|
expect(pvOf(user.id)).toBe(0);
|
|
expect(mcpCount(user.id)).toBe(1);
|
|
|
|
const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(typeof result.token).toBe('string'); // fresh session for the current device
|
|
expect(pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate
|
|
expect(mcpCount(user.id)).toBe(0); // static MCP tokens revoked
|
|
});
|
|
|
|
it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', () => {
|
|
const { user, password } = createUser(testDb);
|
|
const stolen = generateToken({ id: user.id }); // pv=0 at mint time
|
|
|
|
expect(verifyJwtAndLoadUser(stolen)).not.toBeNull();
|
|
|
|
changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
|
|
|
expect(verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// disableMfa — require_mfa policy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('disableMfa — require_mfa policy', () => {
|
|
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', () => {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
|
|
|
const result = disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
|
|
expect(result.status).toBe(403);
|
|
expect(result.error).toMatch(/cannot be disabled/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// verifyMfaLogin — validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('verifyMfaLogin — validation', () => {
|
|
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => {
|
|
const result = verifyMfaLogin({ mfa_token: undefined, code: undefined });
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const jwt = require('jsonwebtoken');
|
|
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
|
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
|
expect(result.status).toBe(401);
|
|
expect(result.error).toMatch(/invalid/i);
|
|
});
|
|
|
|
it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const jwt = require('jsonwebtoken');
|
|
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
|
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
|
expect(result.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP token service
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('MCP token service', () => {
|
|
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = createMcpToken(user.id, undefined);
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = createMcpToken(user.id, 'a'.repeat(101));
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = createMcpToken(user.id, 'My Token');
|
|
expect(result.token).toBeDefined();
|
|
expect((result.token as any).raw_token).toMatch(/^trek_/);
|
|
});
|
|
|
|
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', () => {
|
|
const { user } = createUser(testDb);
|
|
for (let i = 0; i < 10; i++) {
|
|
testDb.prepare(
|
|
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
|
|
).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
|
|
}
|
|
const result = createMcpToken(user.id, 'One More');
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => {
|
|
const { user } = createUser(testDb);
|
|
const result = deleteMcpToken(user.id, '99999');
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => {
|
|
const { user } = createUser(testDb);
|
|
const created = createMcpToken(user.id, 'Deletable Token');
|
|
const tokenId = String((created.token as any).id);
|
|
|
|
const result = deleteMcpToken(user.id, tokenId);
|
|
expect(result).toEqual({ success: true });
|
|
|
|
const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
});
|