import { beforeEach, describe, expect, test, vi } from 'vitest'; import { hash } from '@/lib/crypto'; import { parseSecureToken } from '@/lib/jwt'; import redis from '@/lib/redis'; import { getApiKeyByHash, updateApiKeyLastUsed } from '@/queries/prisma/apiKey'; import { getUser } from '@/queries/prisma/user'; import { hashApiKey } from './api-key'; import { checkAuth } from './auth'; vi.mock('@/lib/jwt', () => ({ parseSecureToken: vi.fn(), parseToken: vi.fn(() => null), })); vi.mock('@/queries/prisma/user', () => ({ getUser: vi.fn(), })); vi.mock('@/queries/prisma/apiKey', () => ({ getApiKeyByHash: vi.fn(), updateApiKeyLastUsed: vi.fn(() => Promise.resolve()), })); vi.mock('@/lib/redis', () => ({ default: { enabled: false, client: { get: vi.fn(), }, }, })); const parseSecureTokenMock = vi.mocked(parseSecureToken); const getUserMock = vi.mocked(getUser); const getApiKeyByHashMock = vi.mocked(getApiKeyByHash); const updateApiKeyLastUsedMock = vi.mocked(updateApiKeyLastUsed); const redisMock = redis as unknown as { enabled: boolean; client: { get: ReturnType; }; }; const PASSWORD_HASH = '$2b$10$currentpasswordhashvalue'; function authedRequest() { return new Request('http://localhost/api/test', { headers: { authorization: 'Bearer secure-token' }, }); } function mockUser() { getUserMock.mockResolvedValue({ id: 'user-1', username: 'bob', role: 'user', password: PASSWORD_HASH, } as any); } beforeEach(() => { vi.unstubAllEnvs(); vi.stubEnv('CLOUD_MODE', ''); parseSecureTokenMock.mockReset(); getUserMock.mockReset(); getApiKeyByHashMock.mockReset(); updateApiKeyLastUsedMock.mockClear(); redisMock.enabled = false; redisMock.client.get.mockReset(); }); describe('checkAuth api keys', () => { const API_KEY = 'umami_abcdefghijklmnopqrstuvwxyz012345'; function apiKeyRequest(path = '/api/websites') { return new Request(`http://localhost${path}`, { headers: { authorization: `Bearer ${API_KEY}` }, }); } function mockApiKey(lastUsedAt: Date | null = null) { getApiKeyByHashMock.mockResolvedValue({ id: 'key-1', userId: 'user-1', name: 'CI', keyHash: hashApiKey(API_KEY), keyPrefix: 'umami_abcdefgh', lastUsedAt, createdAt: new Date(), } as any); } test('authorizes a valid api key and looks it up by hash', async () => { mockApiKey(); mockUser(); const result: any = await checkAuth(apiKeyRequest()); expect(getApiKeyByHashMock).toHaveBeenCalledWith(hashApiKey(API_KEY)); expect(result?.user?.id).toBe('user-1'); expect(result?.user).not.toHaveProperty('password'); expect(result?.apiKey).toEqual({ id: 'key-1', name: 'CI' }); expect(parseSecureTokenMock).not.toHaveBeenCalled(); }); test('rejects an unknown api key', async () => { getApiKeyByHashMock.mockResolvedValue(null); const result = await checkAuth(apiKeyRequest()); expect(result).toBeNull(); expect(getUserMock).not.toHaveBeenCalled(); }); test('rejects an api key whose user no longer exists', async () => { mockApiKey(); getUserMock.mockResolvedValue(null as any); const result = await checkAuth(apiKeyRequest()); expect(result).toBeNull(); }); test('rejects api keys on sensitive routes', async () => { mockApiKey(); mockUser(); for (const path of [ '/api/me/password', '/api/me/api-keys', '/api/me/api-keys/key-1', '/api/2fa/status', '/api/auth/logout', '/api/users', '/api/admin/users', ]) { expect(await checkAuth(apiKeyRequest(path))).toBeNull(); } expect(getApiKeyByHashMock).not.toHaveBeenCalled(); }); test('ignores api keys in cloud mode', async () => { vi.stubEnv('CLOUD_MODE', '1'); mockApiKey(); mockUser(); parseSecureTokenMock.mockReturnValue(null); const result = await checkAuth(apiKeyRequest()); expect(result).toBeNull(); expect(getApiKeyByHashMock).not.toHaveBeenCalled(); expect(parseSecureTokenMock).toHaveBeenCalled(); }); test('updates lastUsedAt when stale', async () => { mockApiKey(new Date(Date.now() - 60 * 60 * 1000)); mockUser(); await checkAuth(apiKeyRequest()); expect(updateApiKeyLastUsedMock).toHaveBeenCalledWith('key-1'); }); test('does not update lastUsedAt when recently used', async () => { mockApiKey(new Date()); mockUser(); await checkAuth(apiKeyRequest()); expect(updateApiKeyLastUsedMock).not.toHaveBeenCalled(); }); test('does not treat a jwt as an api key', async () => { parseSecureTokenMock.mockReturnValue({ userId: 'user-1' } as any); mockUser(); const result = await checkAuth(authedRequest()); expect(result?.user?.id).toBe('user-1'); expect(getApiKeyByHashMock).not.toHaveBeenCalled(); }); }); describe('checkAuth password fingerprint', () => { test('authorizes a stateless token whose fingerprint matches the current password', async () => { parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any); mockUser(); const result = await checkAuth(authedRequest()); expect(result?.user?.id).toBe('user-1'); }); test('authorizes a legacy stateless token that does not include a password fingerprint', async () => { parseSecureTokenMock.mockReturnValue({ userId: 'user-1' } as any); mockUser(); const result = await checkAuth(authedRequest()); expect(result?.user?.id).toBe('user-1'); }); test('rejects a stateless token whose fingerprint predates a password change', async () => { // Token minted against the old password must stop working once the password changes. parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash('old-password-hash'), } as any); mockUser(); const result = await checkAuth(authedRequest()); expect(result).toBeNull(); }); test('does not expose the password hash on the returned user', async () => { parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any); mockUser(); const result = await checkAuth(authedRequest()); expect(result?.user).not.toHaveProperty('password'); }); test('authorizes a Redis session whose fingerprint matches the current password', async () => { redisMock.enabled = true; parseSecureTokenMock.mockReturnValue({ authKey: 'auth:session-key' } as any); redisMock.client.get.mockResolvedValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) }); mockUser(); const result = await checkAuth(authedRequest()); expect(result?.user?.id).toBe('user-1'); }); test('rejects a Redis session whose fingerprint predates a password change', async () => { redisMock.enabled = true; parseSecureTokenMock.mockReturnValue({ authKey: 'auth:session-key' } as any); redisMock.client.get.mockResolvedValue({ userId: 'user-1', pwd: hash('old-password-hash') }); mockUser(); const result = await checkAuth(authedRequest()); expect(result).toBeNull(); }); });