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.
239 lines
7.2 KiB
TypeScript
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)
|
|
})
|
|
})
|