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:
193
tests/integration/api-tokens.test.ts
Normal file
193
tests/integration/api-tokens.test.ts
Normal 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
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');
|
||||
});
|
||||
});
|
||||
183
tests/unit/api-routes/access-lists.test.ts
Normal file
183
tests/unit/api-routes/access-lists.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
128
tests/unit/api-routes/audit-log.test.ts
Normal file
128
tests/unit/api-routes/audit-log.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
tests/unit/api-routes/ca-certificates.test.ts
Normal file
145
tests/unit/api-routes/ca-certificates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
tests/unit/api-routes/caddy.test.ts
Normal file
77
tests/unit/api-routes/caddy.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
146
tests/unit/api-routes/certificates.test.ts
Normal file
146
tests/unit/api-routes/certificates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
131
tests/unit/api-routes/client-certificates.test.ts
Normal file
131
tests/unit/api-routes/client-certificates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
134
tests/unit/api-routes/instances.test.ts
Normal file
134
tests/unit/api-routes/instances.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
148
tests/unit/api-routes/l4-proxy-hosts.test.ts
Normal file
148
tests/unit/api-routes/l4-proxy-hosts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
150
tests/unit/api-routes/proxy-hosts.test.ts
Normal file
150
tests/unit/api-routes/proxy-hosts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
219
tests/unit/api-routes/settings.test.ts
Normal file
219
tests/unit/api-routes/settings.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
172
tests/unit/api-routes/tokens.test.ts
Normal file
172
tests/unit/api-routes/tokens.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
156
tests/unit/api-routes/users.test.ts
Normal file
156
tests/unit/api-routes/users.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user