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.
This commit is contained in:
Matthias G
2026-08-14 09:08:43 +02:00
parent 7b4b5e1a14
commit 97b8092914
10 changed files with 325 additions and 43 deletions
+80 -1
View File
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { findActiveClientByTokenHash, listClients } from '~/data/repositories/clients'
import { performCreateClient, performRevokeClient, tokenPrefix } from '~/lib/admin-clients'
import {
performCreateClient,
performDeleteClient,
performRevokeClient,
performRotateClient,
tokenPrefix,
} from '~/lib/admin-clients'
import { hashToken } from '~/lib/api-auth'
import {
adminViewer,
@@ -157,3 +163,76 @@ describe('performRevokeClient', () => {
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)
})
})