Files
logbuch/tests/lib/admin-clients.test.ts
T
Matthias G 97b8092914 Rotate and delete API access, not just revoke
Rotating hands out a fresh token, voids the old one and lifts a revocation.
Deleting removes the row for good, taking its idempotency keys with it.
Both are admin only and land in the audit log.
2026-08-14 09:08:43 +02:00

239 lines
7.2 KiB
TypeScript

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)
})
})