Files
Sauci/apps/api/test/admin.routes.test.ts
T

105 lines
6.0 KiB
TypeScript

import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { AdminRepository } from '../src/domains/admin/repository.js';
import { registerAdminRoutes } from '../src/domains/admin/routes.js';
import { AdminError, type AdminPrincipal } from '../src/domains/admin/types.js';
import { FilesystemMediaStorage } from '../src/domains/media/storage.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: 'admin@test', name: 'Admin', avatarUrl: null };
const principal: AdminPrincipal = { adminId: '22222222-2222-4222-8222-222222222222', userId: identity.id, role: 'pack_creator', permissions: ['manage_packs'] };
function setup(denied = false) {
const repository: AdminRepository = {
principal: vi.fn(async (userId) => { if (denied) throw new AdminError('admin_access_denied', 'denied', 403); return { ...principal, userId }; }),
query: vi.fn(async () => ({ rows: [{ id: 'pack' }], count: 1 })),
insert: vi.fn(async () => [{ id: 'pack' }]), update: vi.fn(async () => ({ id: 'pack' })),
delete: vi.fn(async () => undefined), dashboard: vi.fn(async () => ({ packs: 1 })),
giftPremium: vi.fn(async () => ({ subscription_id: 'sub', expires_at: 'later' })),
featureInterestCounts: vi.fn(async () => []), users: vi.fn(async () => []), close: vi.fn(async () => undefined),
authorizeMedia: vi.fn(async () => undefined),
responseMedia: vi.fn(async () => ({ storageKey: 'response/test.jpg', mimeType: 'image/jpeg' })),
decryptMessage: vi.fn(async () => ({ content: 'plain' })),
decryptMedia: vi.fn(async () => ({ bytes: Buffer.from('media'), mimeType: 'image/png' })),
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
const mediaStorage = new FilesystemMediaStorage('/tmp/admin-route-media', 'test-signing-secret', 'http://localhost:8787');
vi.spyOn(mediaStorage, 'read').mockResolvedValue(Buffer.from('response-media'));
registerAdminRoutes(app, repository, undefined, mediaStorage);
return { app, repository };
}
describe('admin routes', () => {
it('returns role claims only after server-side admin lookup', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/admin/me');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ role: 'pack_creator', permissions: ['manage_packs'] });
expect(repository.principal).toHaveBeenCalledWith(identity.id);
});
it('denies authenticated users absent from active admin membership', async () => {
const response = await setup(true).app.request('/v1/admin/me');
expect(response.status).toBe(403);
});
it('never accepts actor identity or role from mutation bodies', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/admin/data/question_packs', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ records: [{ name: 'Pack' }], role: 'super_admin', actor_user_id: 'attacker' }),
});
expect(response.status).toBe(400);
expect(repository.insert).not.toHaveBeenCalled();
});
it('passes only validated query and mutation data to the authorized repository', async () => {
const { app, repository } = setup();
const queried = await app.request('/v1/admin/query/question_packs', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ columns: ['id', 'name'], filters: [{ column: 'is_public', op: 'eq', value: true }], limit: 20 }),
});
expect(queried.status).toBe(200);
expect(repository.query).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), 'question_packs', expect.objectContaining({ columns: ['id', 'name'], limit: 20 }));
});
it('issues short-lived admin-authorized media URLs without exposing storage paths', async () => {
const { app, repository } = setup();
const mediaId = '33333333-3333-4333-8333-333333333333';
const response = await app.request(`/v1/admin/media/${mediaId}/url`);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('private, no-store');
expect(await response.json()).toMatchObject({ url: expect.stringContaining(`/media/${mediaId}/content?`) });
expect(repository.authorizeMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), mediaId);
});
it('accepts explicit lifetime premium grants', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/admin/users/${identity.id}/gift-premium`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ days: null }),
});
expect(response.status).toBe(201);
expect(repository.giftPremium).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), identity.id, null, undefined);
});
it('streams response media only after response-scoped repository authorization', async () => {
const { app, repository } = setup();
const responseId = '44444444-4444-4444-8444-444444444444';
const response = await app.request(`/v1/admin/responses/${responseId}/media`);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/jpeg');
expect(response.headers.get('cache-control')).toBe('private, no-store');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(await response.text()).toBe('response-media');
expect(repository.responseMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), responseId);
});
it('rejects malformed response media identifiers before repository access', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/admin/responses/not-a-uuid/media');
expect(response.status).toBe(400);
expect(repository.responseMedia).not.toHaveBeenCalled();
});
});