feat: add comprehensive REST API with token auth, OpenAPI docs, and full test coverage
- API token model (SHA-256 hashed, debounced lastUsedAt) with Bearer auth - Dual auth middleware (session + API token) in src/lib/api-auth.ts - 23 REST endpoints under /api/v1/ covering all functionality: tokens, proxy-hosts, l4-proxy-hosts, certificates, ca-certificates, client-certificates, access-lists, settings, instances, users, audit-log, caddy/apply - OpenAPI 3.1 spec at /api/v1/openapi.json with fully typed schemas - Swagger UI docs page at /api-docs in the dashboard - API token management integrated into the Profile page - Fix: next build now works under Node.js (bun:sqlite aliased to better-sqlite3) - 89 new API route unit tests + 11 integration tests (592 total) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
128
tests/unit/api-auth.test.ts
Normal file
128
tests/unit/api-auth.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the api-tokens model
|
||||
vi.mock('@/src/lib/models/api-tokens', () => ({
|
||||
validateToken: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock next-auth
|
||||
vi.mock('@/src/lib/auth', () => ({
|
||||
auth: vi.fn(),
|
||||
checkSameOrigin: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { authenticateApiRequest, requireApiUser, requireApiAdmin, ApiAuthError } from '@/src/lib/api-auth';
|
||||
import { validateToken } from '@/src/lib/models/api-tokens';
|
||||
import { auth } from '@/src/lib/auth';
|
||||
|
||||
const mockValidateToken = vi.mocked(validateToken);
|
||||
const mockAuth = vi.mocked(auth);
|
||||
|
||||
function createMockRequest(options: { authorization?: string; method?: string; origin?: string } = {}): any {
|
||||
return {
|
||||
headers: {
|
||||
get(name: string) {
|
||||
if (name === 'authorization') return options.authorization ?? null;
|
||||
if (name === 'origin') return options.origin ?? null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
method: options.method ?? 'GET',
|
||||
nextUrl: { pathname: '/api/v1/test' },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('authenticateApiRequest', () => {
|
||||
it('authenticates via Bearer token', async () => {
|
||||
mockValidateToken.mockResolvedValue({
|
||||
token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null },
|
||||
user: { id: 42, role: 'admin' },
|
||||
});
|
||||
|
||||
const result = await authenticateApiRequest(createMockRequest({ authorization: 'Bearer test-token' }));
|
||||
|
||||
expect(result.userId).toBe(42);
|
||||
expect(result.role).toBe('admin');
|
||||
expect(result.authMethod).toBe('bearer');
|
||||
expect(mockValidateToken).toHaveBeenCalledWith('test-token');
|
||||
});
|
||||
|
||||
it('rejects invalid Bearer token', async () => {
|
||||
mockValidateToken.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
authenticateApiRequest(createMockRequest({ authorization: 'Bearer bad-token' }))
|
||||
).rejects.toThrow(ApiAuthError);
|
||||
});
|
||||
|
||||
it('falls back to session auth when no Bearer header', async () => {
|
||||
mockAuth.mockResolvedValue({
|
||||
user: { id: '10', role: 'user', name: 'Test', email: 'test@test.com' },
|
||||
expires: '',
|
||||
} as any);
|
||||
|
||||
const result = await authenticateApiRequest(createMockRequest());
|
||||
|
||||
expect(result.userId).toBe(10);
|
||||
expect(result.role).toBe('user');
|
||||
expect(result.authMethod).toBe('session');
|
||||
});
|
||||
|
||||
it('throws 401 when neither auth method succeeds', async () => {
|
||||
mockAuth.mockResolvedValue(null as any);
|
||||
|
||||
await expect(
|
||||
authenticateApiRequest(createMockRequest())
|
||||
).rejects.toThrow(ApiAuthError);
|
||||
|
||||
try {
|
||||
await authenticateApiRequest(createMockRequest());
|
||||
} catch (e) {
|
||||
expect((e as ApiAuthError).status).toBe(401);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireApiAdmin', () => {
|
||||
it('allows admin users', async () => {
|
||||
mockValidateToken.mockResolvedValue({
|
||||
token: { id: 1, name: 'test', created_by: 1, created_at: '', last_used_at: null, expires_at: null },
|
||||
user: { id: 1, role: 'admin' },
|
||||
});
|
||||
|
||||
const result = await requireApiAdmin(createMockRequest({ authorization: 'Bearer token' }));
|
||||
expect(result.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('rejects non-admin users with 403', async () => {
|
||||
mockValidateToken.mockResolvedValue({
|
||||
token: { id: 1, name: 'test', created_by: 2, created_at: '', last_used_at: null, expires_at: null },
|
||||
user: { id: 2, role: 'user' },
|
||||
});
|
||||
|
||||
try {
|
||||
await requireApiAdmin(createMockRequest({ authorization: 'Bearer token' }));
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ApiAuthError);
|
||||
expect((e as ApiAuthError).status).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireApiUser', () => {
|
||||
it('returns auth result for valid user', async () => {
|
||||
mockAuth.mockResolvedValue({
|
||||
user: { id: '5', role: 'viewer', name: 'V', email: 'v@test.com' },
|
||||
expires: '',
|
||||
} as any);
|
||||
|
||||
const result = await requireApiUser(createMockRequest());
|
||||
expect(result.userId).toBe(5);
|
||||
expect(result.role).toBe('viewer');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user