import { beforeEach, describe, expect, it } from 'vitest' import { findActiveClientByTokenHash, listClients } from '~/data/repositories/clients' import { performCreateClient, performDeleteClient, performRevokeClient, performRotateClient, tokenPrefix, } from '~/lib/admin-clients' import { hashToken } from '~/lib/api-auth' import { adminViewer, auditEntries, form, makeBrand, makeProject, moderatorViewer, resetAccounts, } from '../support/admin' beforeEach(resetAccounts) describe('performCreateClient', () => { it('legt einen Zugang an, zeigt das Token einmal und speichert nur den Hash', async () => { const brand = await makeBrand() const project = await makeProject(brand.id) const state = await performCreateClient( adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer', projectId: project.id }), ) expect(state.status).toBe('ok') expect(state.token?.startsWith(tokenPrefix)).toBe(true) const clients = await listClients() expect(clients).toHaveLength(1) expect(clients[0]).toMatchObject({ name: 'Trakk Web', mode: 'read', scope: 'customer', projectId: project.id, canPublish: false, revokedAt: null, }) expect(clients[0]?.tokenHash).not.toBe(state.token) expect(clients[0]?.tokenHash).toBe(hashToken(state.token!)) const found = await findActiveClientByTokenHash(hashToken(state.token!)) expect(found?.id).toBe(clients[0]?.id) }) it('protokolliert das Anlegen ohne das Token', async () => { await performCreateClient(adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer' })) const entries = await auditEntries() expect(entries).toHaveLength(1) expect(entries[0]).toMatchObject({ action: 'client.create', entity: 'api_client' }) expect(JSON.stringify(entries[0]?.data)).not.toContain(tokenPrefix) }) it('lässt die Projektbindung leer, wenn kein Projekt gewählt ist', async () => { const state = await performCreateClient( adminViewer(), form({ name: 'Alle Projekte', mode: 'read', scope: 'internal', projectId: '' }), ) expect(state.status).toBe('ok') expect((await listClients())[0]?.projectId).toBeNull() }) it('lehnt ein unbekanntes Projekt ab', async () => { const state = await performCreateClient( adminViewer(), form({ name: 'Fremd', mode: 'read', scope: 'customer', projectId: '11111111-1111-4111-8111-111111111111', }), ) expect(state.fields?.projectId).toBe('projectUnknown') expect(await listClients()).toHaveLength(0) }) it('lehnt einen unbekannten Modus ab', async () => { const state = await performCreateClient( adminViewer(), form({ name: 'Fremd', mode: 'alles', scope: 'customer' }), ) expect(state.fields?.mode).toBe('modeInvalid') }) it('lässt einen Moderator keinen Zugang anlegen', async () => { const brand = await makeBrand() const project = await makeProject(brand.id) const state = await performCreateClient( moderatorViewer([project.id]), form({ name: 'Eigener Zugang', mode: 'read', scope: 'customer', projectId: project.id }), ) expect(state.message).toBe('forbidden') expect(await listClients()).toHaveLength(0) expect(await auditEntries()).toHaveLength(0) }) }) describe('performRevokeClient', () => { it('widerruft einen Zugang und protokolliert das', async () => { const created = await performCreateClient( adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }), ) const state = await performRevokeClient(adminViewer(), form({ id: created.id! })) expect(state.status).toBe('ok') const clients = await listClients() expect(clients[0]?.revokedAt).toBeInstanceOf(Date) expect((await auditEntries())[0]?.action).toBe('client.revoke') }) it('lässt ein widerrufenes Token nicht mehr durch', async () => { const created = await performCreateClient( adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }), ) await performRevokeClient(adminViewer(), form({ id: created.id! })) expect(await findActiveClientByTokenHash(hashToken(created.token!))).toBeUndefined() }) it('meldet einen zweiten Widerruf als unbekannt', async () => { const created = await performCreateClient( adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }), ) await performRevokeClient(adminViewer(), form({ id: created.id! })) const state = await performRevokeClient(adminViewer(), form({ id: created.id! })) expect(state.message).toBe('notFound') }) it('lässt einen Moderator keinen Zugang widerrufen', async () => { const created = await performCreateClient( adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }), ) const state = await performRevokeClient(moderatorViewer(), form({ id: created.id! })) expect(state.message).toBe('forbidden') expect((await listClients())[0]?.revokedAt).toBeNull() }) }) async function makeClient(name = 'Claude'): Promise<{ id: string, token: string }> { const state = await performCreateClient( adminViewer(), form({ name, mode: 'write', scope: 'internal' }), ) if (state.status !== 'ok' || !state.id || !state.token) { throw new Error('Zugang nicht angelegt') } return { id: state.id, token: state.token } } function withId(id: string): FormData { return form({ id }) } describe('Token erneuern', () => { it('macht das alte Token ungültig und gibt ein neues zurück', async () => { const { id, token } = await makeClient() const state = await performRotateClient(adminViewer(), withId(id)) expect(state.status).toBe('ok') expect(state.token).toBeDefined() expect(state.token).not.toBe(token) expect(await findActiveClientByTokenHash(hashToken(token))).toBeUndefined() expect(await findActiveClientByTokenHash(hashToken(state.token!))).toMatchObject({ id }) }) it('weckt einen widerrufenen Zugang wieder auf', async () => { const { id } = await makeClient() await performRevokeClient(adminViewer(), withId(id)) const state = await performRotateClient(adminViewer(), withId(id)) expect(await findActiveClientByTokenHash(hashToken(state.token!))).toMatchObject({ id, revokedAt: null }) }) it('bleibt Moderatoren verwehrt', async () => { const { id } = await makeClient() const state = await performRotateClient(moderatorViewer(), withId(id)) expect(state).toMatchObject({ status: 'error', message: 'forbidden' }) }) }) describe('Zugang löschen', () => { it('entfernt ihn wirklich, nicht nur als widerrufen', async () => { const { id, token } = await makeClient() const state = await performDeleteClient(adminViewer(), withId(id)) expect(state).toMatchObject({ status: 'ok', message: 'clientDeleted' }) expect(await listClients()).toHaveLength(0) expect(await findActiveClientByTokenHash(hashToken(token))).toBeUndefined() }) it('meldet einen unbekannten Zugang', async () => { const state = await performDeleteClient(adminViewer(), withId('11111111-1111-4111-8111-111111111111')) expect(state).toMatchObject({ status: 'error', message: 'clientUnknown' }) }) it('bleibt Moderatoren verwehrt', async () => { const { id } = await makeClient() const state = await performDeleteClient(moderatorViewer(), withId(id)) expect(state).toMatchObject({ status: 'error', message: 'forbidden' }) expect(await listClients()).toHaveLength(1) }) })