diff --git a/services/platform-service/src/modules/tokens/routes.test.ts b/services/platform-service/src/modules/tokens/routes.test.ts new file mode 100644 index 00000000..b98c5fb5 --- /dev/null +++ b/services/platform-service/src/modules/tokens/routes.test.ts @@ -0,0 +1,176 @@ +/** + * Route-level tests for tokens module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + list: vi.fn(), + listByUser: vi.fn(), + hashToken: vi.fn(), + create: vi.fn(), + countActive: vi.fn(), + revoke: vi.fn(), + remove: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +const baseToken = { + id: 'tok_1', + productId: 'lysnrai', + userId: 'admin_1', + userName: 'admin@example.com', + name: 'ci-token', + prefix: 'wai_12345678', + status: 'active', + scopes: ['read'], + createdAt: '2026-02-16T00:00:00Z', + expiresAt: '2026-05-16T00:00:00Z', + lastUsed: null, +}; + +async function buildApp(payload?: { sub: string; productId: string; role?: string; email?: string }) { + const { tokenRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + if (payload) { + app.addHook('onRequest', async req => { + (req as typeof req & { jwtPayload?: typeof payload }).jwtPayload = payload; + }); + } + await app.register(tokenRoutes, { prefix: '/api' }); + return app; +} + +describe('tokenRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /tokens returns 401 when unauthenticated', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/tokens' }); + expect(res.statusCode).toBe(401); + }); + + it('GET /tokens lists all tokens for admin', async () => { + repoMock.list.mockResolvedValue([baseToken]); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/tokens' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.tokens).toHaveLength(1); + expect(repoMock.list).toHaveBeenCalledWith('lysnrai'); + }); + + it('GET /tokens lists own tokens for non-admin user', async () => { + repoMock.listByUser.mockResolvedValue([baseToken]); + const app = await buildApp({ sub: 'user_1', productId: 'lysnrai', role: 'user' }); + + const res = await app.inject({ method: 'GET', url: '/api/tokens' }); + + expect(res.statusCode).toBe(200); + expect(repoMock.listByUser).toHaveBeenCalledWith('user_1', 'lysnrai'); + }); + + it('POST /tokens returns 403 for non-admin', async () => { + const app = await buildApp({ sub: 'user_1', productId: 'lysnrai', role: 'user' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/tokens', + payload: { name: 'token', scopes: ['read'], expiresInDays: 30 }, + }); + + expect(res.statusCode).toBe(403); + }); + + it('POST /tokens creates token for admin', async () => { + repoMock.hashToken.mockResolvedValue('hashed_token'); + repoMock.create.mockResolvedValue(baseToken); + const app = await buildApp({ + sub: 'admin_1', + productId: 'lysnrai', + role: 'admin', + email: 'admin@example.com', + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/tokens', + payload: { name: 'ci-token', scopes: ['read'], expiresInDays: 30 }, + }); + + expect(res.statusCode).toBe(201); + const data = JSON.parse(res.body); + expect(data).toHaveProperty('rawToken'); + expect(data.rawToken.startsWith('wai_')).toBe(true); + expect(repoMock.hashToken).toHaveBeenCalled(); + }); + + it('GET /tokens/count returns count for admin', async () => { + repoMock.countActive.mockResolvedValue(4); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/tokens/count' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.count).toBe(4); + }); + + it('PATCH /tokens/:id revokes token', async () => { + repoMock.revoke.mockResolvedValue(true); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/tokens/tok_1', + payload: { action: 'revoke' }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.success).toBe(true); + }); + + it('PATCH /tokens/:id returns 400 when token not found', async () => { + repoMock.revoke.mockResolvedValue(false); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/tokens/tok_missing', + payload: { action: 'revoke' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('DELETE /tokens/:id returns 403 for non-super-admin', async () => { + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/tokens/tok_1' }); + + expect(res.statusCode).toBe(403); + }); + + it('DELETE /tokens/:id deletes token for super_admin', async () => { + repoMock.remove.mockResolvedValue(true); + const app = await buildApp({ sub: 'root_1', productId: 'lysnrai', role: 'super_admin' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/tokens/tok_1' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.success).toBe(true); + expect(repoMock.remove).toHaveBeenCalledWith('tok_1', 'root_1'); + }); +});