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:
fuomag9
2026-03-26 09:45:45 +01:00
parent 0acb430ebb
commit de28478a42
49 changed files with 5160 additions and 6 deletions

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { apiTokens, users } from '@/src/lib/db/schema';
import { createHash } from 'node:crypto';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
function hashToken(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
async function insertUser(overrides: Partial<typeof users.$inferInsert> = {}) {
const now = nowIso();
const [user] = await db.insert(users).values({
email: 'admin@localhost',
name: 'Admin',
passwordHash: 'hash123',
role: 'admin',
provider: 'credentials',
subject: 'admin@localhost',
status: 'active',
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return user;
}
async function insertApiToken(createdBy: number, overrides: Partial<typeof apiTokens.$inferInsert> = {}) {
const now = nowIso();
const rawToken = 'test-token-' + Math.random().toString(36).slice(2);
const tokenHash = hashToken(rawToken);
const [token] = await db.insert(apiTokens).values({
name: 'Test Token',
tokenHash,
createdBy,
createdAt: now,
...overrides,
}).returning();
return { token, rawToken };
}
describe('api-tokens integration', () => {
it('inserts an api token and retrieves it by hash', async () => {
const user = await insertUser();
const { token, rawToken } = await insertApiToken(user.id);
const hash = hashToken(rawToken);
const row = await db.query.apiTokens.findFirst({
where: (t, { eq }) => eq(t.tokenHash, hash),
});
expect(row).toBeDefined();
expect(row!.id).toBe(token.id);
expect(row!.name).toBe('Test Token');
expect(row!.createdBy).toBe(user.id);
});
it('stored hash matches SHA-256 of raw token', async () => {
const user = await insertUser();
const { token, rawToken } = await insertApiToken(user.id);
const expectedHash = hashToken(rawToken);
expect(token.tokenHash).toBe(expectedHash);
});
it('different raw tokens produce different hashes', async () => {
const user = await insertUser();
const t1 = await insertApiToken(user.id, { name: 'Token 1' });
const t2 = await insertApiToken(user.id, { name: 'Token 2' });
expect(t1.token.tokenHash).not.toBe(t2.token.tokenHash);
});
it('token lookup fails for wrong hash', async () => {
const user = await insertUser();
await insertApiToken(user.id);
const wrongHash = hashToken('wrong-token');
const row = await db.query.apiTokens.findFirst({
where: (t, { eq }) => eq(t.tokenHash, wrongHash),
});
expect(row).toBeUndefined();
});
it('expired token is detectable', async () => {
const user = await insertUser();
const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
const { token } = await insertApiToken(user.id, { expiresAt: pastDate });
const row = await db.query.apiTokens.findFirst({
where: (t, { eq }) => eq(t.id, token.id),
});
expect(row).toBeDefined();
expect(new Date(row!.expiresAt!).getTime()).toBeLessThan(Date.now());
});
it('non-expired token has future expiry', async () => {
const user = await insertUser();
const futureDate = new Date(Date.now() + 86400000).toISOString(); // 1 day from now
const { token } = await insertApiToken(user.id, { expiresAt: futureDate });
const row = await db.query.apiTokens.findFirst({
where: (t, { eq }) => eq(t.id, token.id),
});
expect(row).toBeDefined();
expect(new Date(row!.expiresAt!).getTime()).toBeGreaterThan(Date.now());
});
it('deleting a token removes it from the database', async () => {
const user = await insertUser();
const { token } = await insertApiToken(user.id);
await db.delete(apiTokens).where(eq(apiTokens.id, token.id));
const row = await db.query.apiTokens.findFirst({
where: (t, { eq }) => eq(t.id, token.id),
});
expect(row).toBeUndefined();
});
it('cascade deletes tokens when user is deleted', async () => {
const user = await insertUser();
const { token } = await insertApiToken(user.id);
await db.delete(users).where(eq(users.id, user.id));
const row = await db.query.apiTokens.findFirst({
where: (t, { eq }) => eq(t.id, token.id),
});
expect(row).toBeUndefined();
});
it('lastUsedAt is initially null', async () => {
const user = await insertUser();
const { token } = await insertApiToken(user.id);
expect(token.lastUsedAt).toBeNull();
});
it('lastUsedAt can be updated', async () => {
const user = await insertUser();
const { token } = await insertApiToken(user.id);
const now = nowIso();
await db.update(apiTokens).set({ lastUsedAt: now }).where(eq(apiTokens.id, token.id));
const row = await db.query.apiTokens.findFirst({
where: (t, { eq }) => eq(t.id, token.id),
});
expect(row!.lastUsedAt).toBe(now);
});
it('unique index prevents duplicate token hashes', async () => {
const user = await insertUser();
const { token } = await insertApiToken(user.id);
await expect(
db.insert(apiTokens).values({
name: 'Duplicate',
tokenHash: token.tokenHash,
createdBy: user.id,
createdAt: nowIso(),
})
).rejects.toThrow();
});
it('lists tokens for a specific user', async () => {
const user1 = await insertUser({ email: 'u1@localhost', subject: 'u1@localhost' });
const user2 = await insertUser({ email: 'u2@localhost', subject: 'u2@localhost' });
await insertApiToken(user1.id, { name: 'User1 Token' });
await insertApiToken(user2.id, { name: 'User2 Token' });
const user1Tokens = await db.query.apiTokens.findMany({
where: (t, { eq }) => eq(t.createdBy, user1.id),
});
expect(user1Tokens).toHaveLength(1);
expect(user1Tokens[0].name).toBe('User1 Token');
});
});

128
tests/unit/api-auth.test.ts Normal file
View 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');
});
});

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/access-lists', () => ({
listAccessLists: vi.fn(),
createAccessList: vi.fn(),
getAccessList: vi.fn(),
updateAccessList: vi.fn(),
deleteAccessList: vi.fn(),
addAccessListEntry: vi.fn(),
removeAccessListEntry: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET as listGET, POST as listPOST } from '@/app/api/v1/access-lists/route';
import { GET as getGET, PUT, DELETE } from '@/app/api/v1/access-lists/[id]/route';
import { POST as entriesPOST } from '@/app/api/v1/access-lists/[id]/entries/route';
import { DELETE as entryDELETE } from '@/app/api/v1/access-lists/[id]/entries/[entryId]/route';
import { listAccessLists, createAccessList, getAccessList, updateAccessList, deleteAccessList, addAccessListEntry, removeAccessListEntry } from '@/src/lib/models/access-lists';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockList = vi.mocked(listAccessLists);
const mockCreate = vi.mocked(createAccessList);
const mockGet = vi.mocked(getAccessList);
const mockUpdate = vi.mocked(updateAccessList);
const mockDelete = vi.mocked(deleteAccessList);
const mockAddEntry = vi.mocked(addAccessListEntry);
const mockRemoveEntry = vi.mocked(removeAccessListEntry);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/access-lists', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleList = {
id: 1,
name: 'Whitelist',
type: 'allow',
entries: [{ id: 1, value: '10.0.0.0/8', type: 'ip' }],
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/access-lists', () => {
it('returns list of access lists', async () => {
mockList.mockResolvedValue([sampleList] as any);
const response = await listGET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual([sampleList]);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await listGET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('POST /api/v1/access-lists', () => {
it('creates an access list and returns 201', async () => {
const body = { name: 'New List', type: 'deny' };
mockCreate.mockResolvedValue({ id: 2, ...body, entries: [] } as any);
const response = await listPOST(createMockRequest({ method: 'POST', body }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(2);
expect(mockCreate).toHaveBeenCalledWith(body, 1);
});
});
describe('GET /api/v1/access-lists/[id]', () => {
it('returns an access list by id', async () => {
mockGet.mockResolvedValue(sampleList as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(sampleList);
});
it('returns 404 for non-existent access list', async () => {
mockGet.mockResolvedValue(null as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
});
describe('PUT /api/v1/access-lists/[id]', () => {
it('updates an access list', async () => {
const body = { name: 'Updated List' };
mockUpdate.mockResolvedValue({ ...sampleList, name: 'Updated List' } as any);
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.name).toBe('Updated List');
expect(mockUpdate).toHaveBeenCalledWith(1, body, 1);
});
});
describe('DELETE /api/v1/access-lists/[id]', () => {
it('deletes an access list', async () => {
mockDelete.mockResolvedValue(undefined as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockDelete).toHaveBeenCalledWith(1, 1);
});
});
describe('POST /api/v1/access-lists/[id]/entries', () => {
it('adds an entry to an access list and returns 201', async () => {
const body = { value: '192.168.0.0/16', type: 'ip' };
const updatedList = { ...sampleList, entries: [...sampleList.entries, { id: 2, ...body }] };
mockAddEntry.mockResolvedValue(updatedList as any);
const response = await entriesPOST(createMockRequest({ method: 'POST', body }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(201);
expect(data.entries).toHaveLength(2);
expect(mockAddEntry).toHaveBeenCalledWith(1, body, 1);
});
});
describe('DELETE /api/v1/access-lists/[id]/entries/[entryId]', () => {
it('removes an entry from an access list', async () => {
const updatedList = { ...sampleList, entries: [] };
mockRemoveEntry.mockResolvedValue(updatedList as any);
const response = await entryDELETE(
createMockRequest({ method: 'DELETE' }),
{ params: Promise.resolve({ id: '1', entryId: '1' }) }
);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.entries).toHaveLength(0);
expect(mockRemoveEntry).toHaveBeenCalledWith(1, 1, 1);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/audit', () => ({
listAuditEvents: vi.fn(),
countAuditEvents: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET } from '@/app/api/v1/audit-log/route';
import { listAuditEvents, countAuditEvents } from '@/src/lib/models/audit';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockListAuditEvents = vi.mocked(listAuditEvents);
const mockCountAuditEvents = vi.mocked(countAuditEvents);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { searchParams?: string } = {}): any {
return {
headers: { get: () => null },
method: 'GET',
nextUrl: { pathname: '/api/v1/audit-log', searchParams: new URLSearchParams(options.searchParams ?? '') },
json: async () => ({}),
};
}
const sampleEvents = [
{ id: 1, action: 'proxy_host.create', user_id: 1, details: '{}', created_at: '2026-01-01T00:00:00Z' },
{ id: 2, action: 'certificate.create', user_id: 1, details: '{}', created_at: '2026-01-01T01:00:00Z' },
];
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/audit-log', () => {
it('returns paginated events with total', async () => {
mockListAuditEvents.mockResolvedValue(sampleEvents as any);
mockCountAuditEvents.mockResolvedValue(2);
const response = await GET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.events).toEqual(sampleEvents);
expect(data.total).toBe(2);
expect(data.page).toBe(1);
expect(data.perPage).toBe(50);
expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, undefined);
expect(mockCountAuditEvents).toHaveBeenCalledWith(undefined);
});
it('parses page and per_page params', async () => {
mockListAuditEvents.mockResolvedValue([]);
mockCountAuditEvents.mockResolvedValue(100);
const response = await GET(createMockRequest({ searchParams: 'page=3&per_page=25' }));
const data = await response.json();
expect(response.status).toBe(200);
expect(data.page).toBe(3);
expect(data.perPage).toBe(25);
expect(mockListAuditEvents).toHaveBeenCalledWith(25, 50, undefined);
});
it('passes search param through', async () => {
mockListAuditEvents.mockResolvedValue([]);
mockCountAuditEvents.mockResolvedValue(0);
await GET(createMockRequest({ searchParams: 'search=proxy' }));
expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, 'proxy');
expect(mockCountAuditEvents).toHaveBeenCalledWith('proxy');
});
it('clamps per_page to max 200', async () => {
mockListAuditEvents.mockResolvedValue([]);
mockCountAuditEvents.mockResolvedValue(0);
await GET(createMockRequest({ searchParams: 'per_page=500' }));
expect(mockListAuditEvents).toHaveBeenCalledWith(200, 0, undefined);
});
it('clamps per_page to min 1', async () => {
mockListAuditEvents.mockResolvedValue([]);
mockCountAuditEvents.mockResolvedValue(0);
await GET(createMockRequest({ searchParams: 'per_page=0' }));
expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, undefined);
});
it('clamps page to min 1', async () => {
mockListAuditEvents.mockResolvedValue([]);
mockCountAuditEvents.mockResolvedValue(0);
await GET(createMockRequest({ searchParams: 'page=-1' }));
expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, undefined);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await GET(createMockRequest());
expect(response.status).toBe(401);
});
});

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/ca-certificates', () => ({
listCaCertificates: vi.fn(),
createCaCertificate: vi.fn(),
getCaCertificate: vi.fn(),
updateCaCertificate: vi.fn(),
deleteCaCertificate: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET as listGET, POST } from '@/app/api/v1/ca-certificates/route';
import { GET as getGET, PUT, DELETE } from '@/app/api/v1/ca-certificates/[id]/route';
import { listCaCertificates, createCaCertificate, getCaCertificate, updateCaCertificate, deleteCaCertificate } from '@/src/lib/models/ca-certificates';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockList = vi.mocked(listCaCertificates);
const mockCreate = vi.mocked(createCaCertificate);
const mockGet = vi.mocked(getCaCertificate);
const mockUpdate = vi.mocked(updateCaCertificate);
const mockDelete = vi.mocked(deleteCaCertificate);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/ca-certificates', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleCaCert = {
id: 1,
name: 'Internal CA',
certificate: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----',
private_key: '-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----',
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/ca-certificates', () => {
it('returns list of CA certificates', async () => {
mockList.mockResolvedValue([sampleCaCert] as any);
const response = await listGET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual([sampleCaCert]);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await listGET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('POST /api/v1/ca-certificates', () => {
it('creates a CA certificate and returns 201', async () => {
const body = { name: 'New CA', certificate: '---CERT---', private_key: '---KEY---' };
mockCreate.mockResolvedValue({ id: 2, ...body } as any);
const response = await POST(createMockRequest({ method: 'POST', body }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(2);
expect(mockCreate).toHaveBeenCalledWith(body, 1);
});
});
describe('GET /api/v1/ca-certificates/[id]', () => {
it('returns a CA certificate by id', async () => {
mockGet.mockResolvedValue(sampleCaCert as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(sampleCaCert);
});
it('returns 404 for non-existent CA certificate', async () => {
mockGet.mockResolvedValue(null as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
});
describe('PUT /api/v1/ca-certificates/[id]', () => {
it('updates a CA certificate', async () => {
const body = { name: 'Updated CA' };
mockUpdate.mockResolvedValue({ ...sampleCaCert, name: 'Updated CA' } as any);
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.name).toBe('Updated CA');
expect(mockUpdate).toHaveBeenCalledWith(1, body, 1);
});
});
describe('DELETE /api/v1/ca-certificates/[id]', () => {
it('deletes a CA certificate', async () => {
mockDelete.mockResolvedValue(undefined as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockDelete).toHaveBeenCalledWith(1, 1);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/caddy', () => ({
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { POST } from '@/app/api/v1/caddy/apply/route';
import { applyCaddyConfig } from '@/src/lib/caddy';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockApplyCaddyConfig = vi.mocked(applyCaddyConfig);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(): any {
return {
headers: { get: () => null },
method: 'POST',
nextUrl: { pathname: '/api/v1/caddy/apply', searchParams: new URLSearchParams() },
json: async () => ({}),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('POST /api/v1/caddy/apply', () => {
it('applies caddy config and returns ok', async () => {
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockApplyCaddyConfig).toHaveBeenCalled();
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('returns 500 when applyCaddyConfig fails', async () => {
mockApplyCaddyConfig.mockRejectedValue(new Error('Connection refused'));
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe('Connection refused');
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/certificates', () => ({
listCertificates: vi.fn(),
createCertificate: vi.fn(),
getCertificate: vi.fn(),
updateCertificate: vi.fn(),
deleteCertificate: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET as listGET, POST } from '@/app/api/v1/certificates/route';
import { GET as getGET, PUT, DELETE } from '@/app/api/v1/certificates/[id]/route';
import { listCertificates, createCertificate, getCertificate, updateCertificate, deleteCertificate } from '@/src/lib/models/certificates';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockList = vi.mocked(listCertificates);
const mockCreate = vi.mocked(createCertificate);
const mockGet = vi.mocked(getCertificate);
const mockUpdate = vi.mocked(updateCertificate);
const mockDelete = vi.mocked(deleteCertificate);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/certificates', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleCert = {
id: 1,
domains: ['secure.example.com'],
type: 'acme',
status: 'active',
expires_at: '2027-01-01',
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/certificates', () => {
it('returns list of certificates', async () => {
mockList.mockResolvedValue([sampleCert] as any);
const response = await listGET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual([sampleCert]);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await listGET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('POST /api/v1/certificates', () => {
it('creates a certificate and returns 201', async () => {
const body = { domains: ['new.example.com'], type: 'acme' };
mockCreate.mockResolvedValue({ id: 2, ...body } as any);
const response = await POST(createMockRequest({ method: 'POST', body }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(2);
expect(mockCreate).toHaveBeenCalledWith(body, 1);
});
});
describe('GET /api/v1/certificates/[id]', () => {
it('returns a certificate by id', async () => {
mockGet.mockResolvedValue(sampleCert as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(sampleCert);
});
it('returns 404 for non-existent certificate', async () => {
mockGet.mockResolvedValue(null as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
});
describe('PUT /api/v1/certificates/[id]', () => {
it('updates a certificate', async () => {
const body = { domains: ['updated.example.com'] };
mockUpdate.mockResolvedValue({ ...sampleCert, domains: ['updated.example.com'] } as any);
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.domains).toEqual(['updated.example.com']);
expect(mockUpdate).toHaveBeenCalledWith(1, body, 1);
});
});
describe('DELETE /api/v1/certificates/[id]', () => {
it('deletes a certificate', async () => {
mockDelete.mockResolvedValue(undefined as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockDelete).toHaveBeenCalledWith(1, 1);
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/issued-client-certificates', () => ({
listIssuedClientCertificates: vi.fn(),
createIssuedClientCertificate: vi.fn(),
getIssuedClientCertificate: vi.fn(),
revokeIssuedClientCertificate: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET as listGET, POST } from '@/app/api/v1/client-certificates/route';
import { GET as getGET, DELETE } from '@/app/api/v1/client-certificates/[id]/route';
import { listIssuedClientCertificates, createIssuedClientCertificate, getIssuedClientCertificate, revokeIssuedClientCertificate } from '@/src/lib/models/issued-client-certificates';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockList = vi.mocked(listIssuedClientCertificates);
const mockCreate = vi.mocked(createIssuedClientCertificate);
const mockGet = vi.mocked(getIssuedClientCertificate);
const mockRevoke = vi.mocked(revokeIssuedClientCertificate);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/client-certificates', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleClientCert = {
id: 1,
common_name: 'client1.example.com',
ca_certificate_id: 1,
status: 'active',
expires_at: '2027-06-01',
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/client-certificates', () => {
it('returns list of client certificates', async () => {
mockList.mockResolvedValue([sampleClientCert] as any);
const response = await listGET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual([sampleClientCert]);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await listGET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('POST /api/v1/client-certificates', () => {
it('creates a client certificate and returns 201', async () => {
const body = { common_name: 'client2.example.com', ca_certificate_id: 1 };
mockCreate.mockResolvedValue({ id: 2, ...body, status: 'active' } as any);
const response = await POST(createMockRequest({ method: 'POST', body }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(2);
expect(mockCreate).toHaveBeenCalledWith(body, 1);
});
});
describe('GET /api/v1/client-certificates/[id]', () => {
it('returns a client certificate by id', async () => {
mockGet.mockResolvedValue(sampleClientCert as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(sampleClientCert);
});
it('returns 404 for non-existent client certificate', async () => {
mockGet.mockResolvedValue(null as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
});
describe('DELETE /api/v1/client-certificates/[id]', () => {
it('revokes a client certificate and returns it', async () => {
const revoked = { ...sampleClientCert, status: 'revoked' };
mockRevoke.mockResolvedValue(revoked as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.status).toBe('revoked');
expect(mockRevoke).toHaveBeenCalledWith(1, 1);
});
});

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/instances', () => ({
listInstances: vi.fn(),
createInstance: vi.fn(),
deleteInstance: vi.fn(),
}));
vi.mock('@/src/lib/instance-sync', () => ({
syncInstances: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET, POST } from '@/app/api/v1/instances/route';
import { DELETE } from '@/app/api/v1/instances/[id]/route';
import { POST as syncPOST } from '@/app/api/v1/instances/sync/route';
import { listInstances, createInstance, deleteInstance } from '@/src/lib/models/instances';
import { syncInstances } from '@/src/lib/instance-sync';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockList = vi.mocked(listInstances);
const mockCreate = vi.mocked(createInstance);
const mockDelete = vi.mocked(deleteInstance);
const mockSync = vi.mocked(syncInstances);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/instances', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleInstance = {
id: 1,
name: 'Slave 1',
url: 'https://slave1.example.com:3000',
token: 'sync-token-abc',
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/instances', () => {
it('returns list of instances', async () => {
mockList.mockResolvedValue([sampleInstance] as any);
const response = await GET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual([sampleInstance]);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await GET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('POST /api/v1/instances', () => {
it('creates an instance and returns 201', async () => {
const body = { name: 'Slave 2', url: 'https://slave2.example.com:3000', token: 'token-xyz' };
mockCreate.mockResolvedValue({ id: 2, ...body } as any);
const response = await POST(createMockRequest({ method: 'POST', body }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(2);
expect(mockCreate).toHaveBeenCalledWith(body);
});
});
describe('DELETE /api/v1/instances/[id]', () => {
it('deletes an instance', async () => {
mockDelete.mockResolvedValue(undefined as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockDelete).toHaveBeenCalledWith(1);
});
});
describe('POST /api/v1/instances/sync', () => {
it('syncs instances and returns result', async () => {
const syncResult = { synced: 2, errors: [] };
mockSync.mockResolvedValue(syncResult as any);
const response = await syncPOST(createMockRequest({ method: 'POST' }));
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(syncResult);
expect(mockSync).toHaveBeenCalled();
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await syncPOST(createMockRequest({ method: 'POST' }));
expect(response.status).toBe(401);
});
});

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/l4-proxy-hosts', () => ({
listL4ProxyHosts: vi.fn(),
createL4ProxyHost: vi.fn(),
getL4ProxyHost: vi.fn(),
updateL4ProxyHost: vi.fn(),
deleteL4ProxyHost: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET as listGET, POST } from '@/app/api/v1/l4-proxy-hosts/route';
import { GET as getGET, PUT, DELETE } from '@/app/api/v1/l4-proxy-hosts/[id]/route';
import { listL4ProxyHosts, createL4ProxyHost, getL4ProxyHost, updateL4ProxyHost, deleteL4ProxyHost } from '@/src/lib/models/l4-proxy-hosts';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockList = vi.mocked(listL4ProxyHosts);
const mockCreate = vi.mocked(createL4ProxyHost);
const mockGet = vi.mocked(getL4ProxyHost);
const mockUpdate = vi.mocked(updateL4ProxyHost);
const mockDelete = vi.mocked(deleteL4ProxyHost);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/l4-proxy-hosts', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleHost = {
id: 1,
name: 'SSH Forward',
listen_port: 2222,
forward_host: '10.0.0.5',
forward_port: 22,
protocol: 'tcp',
enabled: true,
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/l4-proxy-hosts', () => {
it('returns list of L4 proxy hosts', async () => {
mockList.mockResolvedValue([sampleHost] as any);
const response = await listGET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual([sampleHost]);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await listGET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('POST /api/v1/l4-proxy-hosts', () => {
it('creates an L4 proxy host and returns 201', async () => {
const body = { name: 'New L4', listen_port: 3333, forward_host: '10.0.0.6', forward_port: 33 };
mockCreate.mockResolvedValue({ id: 2, ...body } as any);
const response = await POST(createMockRequest({ method: 'POST', body }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(2);
expect(mockCreate).toHaveBeenCalledWith(body, 1);
});
});
describe('GET /api/v1/l4-proxy-hosts/[id]', () => {
it('returns an L4 proxy host by id', async () => {
mockGet.mockResolvedValue(sampleHost as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(sampleHost);
});
it('returns 404 for non-existent host', async () => {
mockGet.mockResolvedValue(null as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
});
describe('PUT /api/v1/l4-proxy-hosts/[id]', () => {
it('updates an L4 proxy host', async () => {
const body = { listen_port: 4444 };
mockUpdate.mockResolvedValue({ ...sampleHost, listen_port: 4444 } as any);
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.listen_port).toBe(4444);
expect(mockUpdate).toHaveBeenCalledWith(1, body, 1);
});
});
describe('DELETE /api/v1/l4-proxy-hosts/[id]', () => {
it('deletes an L4 proxy host', async () => {
mockDelete.mockResolvedValue(undefined as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockDelete).toHaveBeenCalledWith(1, 1);
});
});

View File

@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/proxy-hosts', () => ({
listProxyHosts: vi.fn(),
createProxyHost: vi.fn(),
getProxyHost: vi.fn(),
updateProxyHost: vi.fn(),
deleteProxyHost: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET as listGET, POST } from '@/app/api/v1/proxy-hosts/route';
import { GET as getGET, PUT, DELETE } from '@/app/api/v1/proxy-hosts/[id]/route';
import { listProxyHosts, createProxyHost, getProxyHost, updateProxyHost, deleteProxyHost } from '@/src/lib/models/proxy-hosts';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockListProxyHosts = vi.mocked(listProxyHosts);
const mockCreateProxyHost = vi.mocked(createProxyHost);
const mockGetProxyHost = vi.mocked(getProxyHost);
const mockUpdateProxyHost = vi.mocked(updateProxyHost);
const mockDeleteProxyHost = vi.mocked(deleteProxyHost);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/proxy-hosts', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleHost = {
id: 1,
domains: ['example.com'],
forward_host: '10.0.0.1',
forward_port: 8080,
forward_scheme: 'http',
enabled: true,
created_at: '2026-01-01',
updated_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/proxy-hosts', () => {
it('returns list of proxy hosts', async () => {
mockListProxyHosts.mockResolvedValue([sampleHost] as any);
const response = await listGET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual([sampleHost]);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await listGET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('POST /api/v1/proxy-hosts', () => {
it('creates a proxy host and returns 201', async () => {
const body = { domains: ['new.example.com'], forward_host: '10.0.0.2', forward_port: 3000 };
mockCreateProxyHost.mockResolvedValue({ id: 2, ...body } as any);
const response = await POST(createMockRequest({ method: 'POST', body }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.id).toBe(2);
expect(mockCreateProxyHost).toHaveBeenCalledWith(body, 1);
});
});
describe('GET /api/v1/proxy-hosts/[id]', () => {
it('returns a proxy host by id', async () => {
mockGetProxyHost.mockResolvedValue(sampleHost as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(sampleHost);
expect(mockGetProxyHost).toHaveBeenCalledWith(1);
});
it('returns 404 for non-existent host', async () => {
mockGetProxyHost.mockResolvedValue(null as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
});
describe('PUT /api/v1/proxy-hosts/[id]', () => {
it('updates a proxy host', async () => {
const body = { forward_port: 9090 };
const updated = { ...sampleHost, forward_port: 9090 };
mockUpdateProxyHost.mockResolvedValue(updated as any);
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.forward_port).toBe(9090);
expect(mockUpdateProxyHost).toHaveBeenCalledWith(1, body, 1);
});
});
describe('DELETE /api/v1/proxy-hosts/[id]', () => {
it('deletes a proxy host', async () => {
mockDeleteProxyHost.mockResolvedValue(undefined as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockDeleteProxyHost).toHaveBeenCalledWith(1, 1);
});
});

View File

@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/settings', () => ({
getGeneralSettings: vi.fn(),
saveGeneralSettings: vi.fn(),
getCloudflareSettings: vi.fn(),
saveCloudflareSettings: vi.fn(),
getAuthentikSettings: vi.fn(),
saveAuthentikSettings: vi.fn(),
getMetricsSettings: vi.fn(),
saveMetricsSettings: vi.fn(),
getLoggingSettings: vi.fn(),
saveLoggingSettings: vi.fn(),
getDnsSettings: vi.fn(),
saveDnsSettings: vi.fn(),
getUpstreamDnsResolutionSettings: vi.fn(),
saveUpstreamDnsResolutionSettings: vi.fn(),
getGeoBlockSettings: vi.fn(),
saveGeoBlockSettings: vi.fn(),
getWafSettings: vi.fn(),
saveWafSettings: vi.fn(),
}));
vi.mock('@/src/lib/instance-sync', () => ({
getInstanceMode: vi.fn(),
setInstanceMode: vi.fn(),
getSlaveMasterToken: vi.fn(),
setSlaveMasterToken: vi.fn(),
}));
vi.mock('@/src/lib/caddy', () => ({
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET, PUT } from '@/app/api/v1/settings/[group]/route';
import { getGeneralSettings, saveGeneralSettings } from '@/src/lib/settings';
import { getInstanceMode, setInstanceMode, getSlaveMasterToken, setSlaveMasterToken } from '@/src/lib/instance-sync';
import { applyCaddyConfig } from '@/src/lib/caddy';
import { requireApiAdmin } from '@/src/lib/api-auth';
const mockGetGeneral = vi.mocked(getGeneralSettings);
const mockSaveGeneral = vi.mocked(saveGeneralSettings);
const mockGetInstanceMode = vi.mocked(getInstanceMode);
const mockSetInstanceMode = vi.mocked(setInstanceMode);
const mockGetSlaveMasterToken = vi.mocked(getSlaveMasterToken);
const mockSetSlaveMasterToken = vi.mocked(setSlaveMasterToken);
const mockApplyCaddyConfig = vi.mocked(applyCaddyConfig);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/settings/general', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/settings/[group]', () => {
it('returns general settings', async () => {
const settings = { site_name: 'My Proxy', admin_email: 'admin@example.com' };
mockGetGeneral.mockResolvedValue(settings as any);
const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'general' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(settings);
});
it('returns empty object when settings are null', async () => {
mockGetGeneral.mockResolvedValue(null as any);
const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'general' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({});
});
it('returns instance mode', async () => {
mockGetInstanceMode.mockResolvedValue('standalone' as any);
const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'instance-mode' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ mode: 'standalone' });
});
it('returns sync-token status', async () => {
mockGetSlaveMasterToken.mockResolvedValue('some-token' as any);
const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'sync-token' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ has_token: true });
});
it('returns has_token false when no token', async () => {
mockGetSlaveMasterToken.mockResolvedValue(null as any);
const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'sync-token' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ has_token: false });
});
it('returns 404 for unknown settings group', async () => {
const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'unknown' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Unknown settings group');
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'general' }) });
expect(response.status).toBe(401);
});
});
describe('PUT /api/v1/settings/[group]', () => {
it('saves general settings and applies caddy config', async () => {
mockSaveGeneral.mockResolvedValue(undefined);
const body = { site_name: 'Updated Proxy' };
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'general' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockSaveGeneral).toHaveBeenCalledWith(body);
expect(mockApplyCaddyConfig).toHaveBeenCalled();
});
it('sets instance mode', async () => {
mockSetInstanceMode.mockResolvedValue(undefined as any);
const body = { mode: 'master' };
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'instance-mode' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockSetInstanceMode).toHaveBeenCalledWith('master');
});
it('sets sync token', async () => {
mockSetSlaveMasterToken.mockResolvedValue(undefined as any);
const body = { token: 'new-sync-token' };
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'sync-token' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockSetSlaveMasterToken).toHaveBeenCalledWith('new-sync-token');
});
it('clears sync token when null', async () => {
mockSetSlaveMasterToken.mockResolvedValue(undefined as any);
const body = {};
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'sync-token' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(mockSetSlaveMasterToken).toHaveBeenCalledWith(null);
});
it('returns 404 for unknown settings group', async () => {
const response = await PUT(createMockRequest({ method: 'PUT', body: {} }), { params: Promise.resolve({ group: 'unknown' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Unknown settings group');
});
it('still returns ok even if applyCaddyConfig fails', async () => {
mockSaveGeneral.mockResolvedValue(undefined);
mockApplyCaddyConfig.mockRejectedValue(new Error('caddy down'));
const response = await PUT(createMockRequest({ method: 'PUT', body: { site_name: 'Test' } }), { params: Promise.resolve({ group: 'general' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
});
});

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/api-tokens', () => ({
createApiToken: vi.fn(),
listApiTokens: vi.fn(),
listAllApiTokens: vi.fn(),
deleteApiToken: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET, POST } from '@/app/api/v1/tokens/route';
import { DELETE } from '@/app/api/v1/tokens/[id]/route';
import { createApiToken, listApiTokens, listAllApiTokens, deleteApiToken } from '@/src/lib/models/api-tokens';
import { requireApiUser } from '@/src/lib/api-auth';
const mockCreateApiToken = vi.mocked(createApiToken);
const mockListApiTokens = vi.mocked(listApiTokens);
const mockListAllApiTokens = vi.mocked(listAllApiTokens);
const mockDeleteApiToken = vi.mocked(deleteApiToken);
const mockRequireApiUser = vi.mocked(requireApiUser);
function createMockRequest(options: { method?: string; body?: unknown; authorization?: string; searchParams?: string } = {}): any {
return {
headers: {
get(name: string) {
if (name === 'authorization') return options.authorization ?? 'Bearer test-token';
return null;
},
},
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/tokens', searchParams: new URLSearchParams(options.searchParams ?? '') },
json: async () => options.body ?? {},
};
}
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiUser.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/tokens', () => {
it('returns all tokens for admin', async () => {
const tokens = [
{ id: 1, name: 'Token 1', created_by: 1, created_at: '2026-01-01', last_used_at: null, expires_at: null },
{ id: 2, name: 'Token 2', created_by: 2, created_at: '2026-01-02', last_used_at: null, expires_at: null },
];
mockListAllApiTokens.mockResolvedValue(tokens as any);
const response = await GET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(tokens);
expect(mockListAllApiTokens).toHaveBeenCalled();
expect(mockListApiTokens).not.toHaveBeenCalled();
});
it('returns own tokens for non-admin user', async () => {
mockRequireApiUser.mockResolvedValue({ userId: 5, role: 'user', authMethod: 'bearer' });
const tokens = [{ id: 3, name: 'My Token', created_by: 5, created_at: '2026-01-01', last_used_at: null, expires_at: null }];
mockListApiTokens.mockResolvedValue(tokens as any);
const response = await GET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(tokens);
expect(mockListApiTokens).toHaveBeenCalledWith(5);
expect(mockListAllApiTokens).not.toHaveBeenCalled();
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiUser.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await GET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
});
describe('POST /api/v1/tokens', () => {
it('creates a token and returns 201', async () => {
const tokenResult = {
token: { id: 10, name: 'New Token', created_by: 1, created_at: '2026-01-01', last_used_at: null, expires_at: null },
rawToken: 'cpm_raw_token_abc123',
};
mockCreateApiToken.mockResolvedValue(tokenResult as any);
const response = await POST(createMockRequest({ method: 'POST', body: { name: 'New Token' } }));
const data = await response.json();
expect(response.status).toBe(201);
expect(data.raw_token).toBe('cpm_raw_token_abc123');
expect(data.token).toEqual(tokenResult.token);
expect(mockCreateApiToken).toHaveBeenCalledWith('New Token', 1, undefined);
});
it('creates a token with expires_at', async () => {
const tokenResult = {
token: { id: 11, name: 'Expiring Token', created_by: 1, created_at: '2026-01-01', last_used_at: null, expires_at: '2027-01-01' },
rawToken: 'cpm_raw_token_xyz',
};
mockCreateApiToken.mockResolvedValue(tokenResult as any);
const response = await POST(createMockRequest({ method: 'POST', body: { name: 'Expiring Token', expires_at: '2027-01-01' } }));
const data = await response.json();
expect(response.status).toBe(201);
expect(mockCreateApiToken).toHaveBeenCalledWith('Expiring Token', 1, '2027-01-01');
});
it('returns 400 when name is missing', async () => {
const response = await POST(createMockRequest({ method: 'POST', body: {} }));
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('name is required');
});
it('returns 400 when name is not a string', async () => {
const response = await POST(createMockRequest({ method: 'POST', body: { name: 123 } }));
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('name is required');
});
});
describe('DELETE /api/v1/tokens/[id]', () => {
it('deletes a token and returns ok', async () => {
mockDeleteApiToken.mockResolvedValue(undefined as any);
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '5' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ ok: true });
expect(mockDeleteApiToken).toHaveBeenCalledWith(5, 1);
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiUser.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '5' }) });
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
});

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/src/lib/models/user', () => ({
listUsers: vi.fn(),
getUserById: vi.fn(),
updateUserProfile: vi.fn(),
}));
vi.mock('@/src/lib/api-auth', () => {
const ApiAuthError = class extends Error {
status: number;
constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; }
};
return {
requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }),
apiErrorResponse: vi.fn((error: unknown) => {
const { NextResponse } = require('next/server');
if (error && typeof error === 'object' && 'status' in error) {
return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status });
}
return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 });
}),
ApiAuthError,
};
});
import { GET as listGET } from '@/app/api/v1/users/route';
import { GET as getGET, PUT } from '@/app/api/v1/users/[id]/route';
import { listUsers, getUserById, updateUserProfile } from '@/src/lib/models/user';
import { requireApiAdmin, requireApiUser } from '@/src/lib/api-auth';
const mockListUsers = vi.mocked(listUsers);
const mockGetUserById = vi.mocked(getUserById);
const mockUpdateUserProfile = vi.mocked(updateUserProfile);
const mockRequireApiAdmin = vi.mocked(requireApiAdmin);
const mockRequireApiUser = vi.mocked(requireApiUser);
function createMockRequest(options: { method?: string; body?: unknown } = {}): any {
return {
headers: { get: () => null },
method: options.method ?? 'GET',
nextUrl: { pathname: '/api/v1/users', searchParams: new URLSearchParams() },
json: async () => options.body ?? {},
};
}
const sampleUser = {
id: 1,
name: 'Admin User',
email: 'admin@example.com',
role: 'admin',
password_hash: '$2b$10$hashedpassword',
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
mockRequireApiUser.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' });
});
describe('GET /api/v1/users', () => {
it('returns list of users with password_hash stripped', async () => {
mockListUsers.mockResolvedValue([sampleUser] as any);
const response = await listGET(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0]).not.toHaveProperty('password_hash');
expect(data[0].name).toBe('Admin User');
expect(data[0].email).toBe('admin@example.com');
});
it('returns 401 on auth failure', async () => {
const { ApiAuthError } = await import('@/src/lib/api-auth');
mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401));
const response = await listGET(createMockRequest());
expect(response.status).toBe(401);
});
});
describe('GET /api/v1/users/[id]', () => {
it('returns a user by id with password_hash stripped', async () => {
mockGetUserById.mockResolvedValue(sampleUser as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).not.toHaveProperty('password_hash');
expect(data.name).toBe('Admin User');
});
it('returns 404 for non-existent user', async () => {
mockGetUserById.mockResolvedValue(null as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
it('returns 403 when non-admin tries to view another user', async () => {
mockRequireApiUser.mockResolvedValue({ userId: 5, role: 'user', authMethod: 'bearer' });
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('Forbidden');
});
it('allows non-admin to view themselves', async () => {
mockRequireApiUser.mockResolvedValue({ userId: 5, role: 'user', authMethod: 'bearer' });
const user = { ...sampleUser, id: 5, role: 'user' };
mockGetUserById.mockResolvedValue(user as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '5' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.id).toBe(5);
expect(data).not.toHaveProperty('password_hash');
});
});
describe('PUT /api/v1/users/[id]', () => {
it('updates a user', async () => {
const body = { name: 'Updated Name' };
const updated = { ...sampleUser, name: 'Updated Name' };
mockUpdateUserProfile.mockResolvedValue(updated as any);
const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.name).toBe('Updated Name');
expect(data).not.toHaveProperty('password_hash');
expect(mockUpdateUserProfile).toHaveBeenCalledWith(1, body);
});
it('returns 404 when updating non-existent user', async () => {
mockUpdateUserProfile.mockResolvedValue(null as any);
const response = await PUT(createMockRequest({ method: 'PUT', body: { name: 'X' } }), { params: Promise.resolve({ id: '999' }) });
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe('Not found');
});
});