Files
summercms/admin/tests/account/ApiTokensPanel.test.ts
Jakub Zych 1c930b6ff7 feat(16.3-02): list states, show-revoked toggle, and revoke via ConfirmDialog
Add skeleton/empty/error coverage, revoked-row handling, ConfirmDialog
overflow wrap for long names, and rebuild boardwalk dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:04:54 +02:00

228 lines
8.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { toasts } from '../../src/state/useToasts'
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const TOKENS = `${API}/account/tokens`
const SCOPES = `${API}/account/tokens/scopes`
const TOKEN_ID = `${API}/account/tokens/11`
const envelope = (data: unknown, status = 200) => ({ status, body: { data, meta: {} } })
const existingToken = {
id: 11,
name: 'Blog agent',
hint: 'a1b2',
scopes: [
{
name: 'admin:read',
label: 'Read-only, whole admin',
description: 'View everything',
read_only: true,
known: true,
},
],
created_at: '2026-01-10T12:00:00Z',
last_used_at: '',
expires_at: '2026-04-10T12:00:00Z',
revoked_at: '',
expired: false,
}
const revokedToken = {
...existingToken,
id: 13,
name: 'Old token',
hint: 'dead',
revoked_at: '2026-02-01T08:30:00Z',
}
const createdToken = {
...existingToken,
id: 12,
name: 'CI runner',
hint: 'c3d4',
token: 'g15_0123456789abcdef0123456789abcdef01234567',
}
const scopes = [
{
name: '*',
label: 'Full access',
description: 'Everything',
read_only: false,
known: true,
},
{
name: 'admin:read',
label: 'Read-only, whole admin',
description: 'View everything',
read_only: true,
known: true,
},
]
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
let release!: (reply: Reply) => void
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
return { route: () => pending, release }
}
function tokenRoutes(overrides: Record<string, Route> = {}, list: unknown[] = [existingToken]): Record<string, Route> {
let listed = list
return {
[`GET ${TOKENS}`]: () => envelope(listed),
[`GET ${SCOPES}`]: envelope(scopes),
[`POST ${TOKENS}`]: () => {
listed = [createdToken, ...listed]
return envelope(createdToken, 201)
},
...overrides,
}
}
async function openAccount(routes: Record<string, Route> = {}, list: unknown[] = [existingToken]) {
return mountApp('/account', tokenRoutes(routes, list), { attach: true })
}
const panel = () => document.body.querySelector<HTMLElement>('[data-api-tokens]')
const dialog = () => document.body.querySelector<HTMLElement>('[data-token-dialog]')
const confirmDialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
async function click(selector: string, root: ParentNode = document.body): Promise<void> {
const el = root.querySelector<HTMLElement>(selector)
el?.click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
enableAutoUnmount(afterEach)
describe('ApiTokensPanel', () => {
it('tracer: list, create, show once, hide', async () => {
const { calls } = await openAccount()
await flushPromises()
expect(panel()).toBeTruthy()
expect(panel()!.querySelector('[data-token-row="11"]')).toBeTruthy()
await click('[data-action="create-token"]', panel()!)
expect(dialog()).toBeTruthy()
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
await nameInput.focus()
nameInput.value = 'CI runner'
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
scope.checked = true
scope.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
const getsBeforeCreate = requestsTo(calls, 'GET', TOKENS).length
await click('[data-action="create-token-submit"]', dialog()!)
const [post] = requestsTo(calls, 'POST', TOKENS)
expect(post).toBeTruthy()
expect(await post!.clone().json()).toEqual({
name: 'CI runner',
expires: '90d',
scopes: ['admin:read'],
})
const plaintext = dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!
expect(plaintext.value).toBe(createdToken.token)
expect(requestsTo(calls, 'GET', TOKENS).length).toBeGreaterThan(getsBeforeCreate)
await click('[data-action="hide-token"]', dialog()!)
expect(dialog()).toBeNull()
expect(document.body.textContent).not.toContain(createdToken.token)
})
describe('list states', () => {
it('shows skeleton then the empty block', async () => {
const pending = deferred()
const { wrapper } = await mountApp(
'/account',
tokenRoutes({ [`GET ${TOKENS}`]: pending.route }, []),
{ attach: true },
)
expect(panel()!.querySelector('[data-token-list]')!.getAttribute('aria-busy')).toBe('true')
expect(panel()!.querySelectorAll('[aria-hidden="true"]').length).toBeGreaterThanOrEqual(3)
expect(panel()!.querySelector<HTMLButtonElement>('[data-action="create-token"]')!.disabled).toBe(true)
pending.release(envelope([]))
await flushPromises()
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
expect(panel()!.querySelector('[data-action="create-token"]')!.hasAttribute('disabled')).toBe(false)
wrapper.unmount()
})
it('shows the alert and hides the toggle when the load fails', async () => {
await openAccount({
[`GET ${TOKENS}`]: { status: 500, body: { error: { code: 'x', message: 'fail', details: {} } } },
})
expect(panel()!.querySelector('[data-tokens-error]')!.getAttribute('role')).toBe('alert')
expect(panel()!.querySelector('[data-show-revoked]')).toBeNull()
expect(panel()!.querySelector<HTMLButtonElement>('[data-action="create-token"]')!.disabled).toBe(false)
})
})
describe('revoke', () => {
it('runs DELETE through the shared confirm and toasts success', async () => {
const { calls } = await openAccount({
[`DELETE ${TOKEN_ID}`]: envelope({ ...existingToken, revoked_at: '2026-03-01T10:00:00Z' }),
})
await click('[data-action="revoke-token"]', panel()!)
expect(confirmDialog()).toBeTruthy()
await click('[data-action="confirm"]', confirmDialog()!)
expect(requestsTo(calls, 'DELETE', TOKEN_ID)).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ text: 'Unieważniono token', tone: 'success' })
})
it('toasts gone on 404 and reloads', async () => {
let listed: unknown[] = [existingToken]
const { calls } = await openAccount({
[`GET ${TOKENS}`]: () => envelope(listed),
[`DELETE ${TOKEN_ID}`]: () => {
listed = []
return { status: 404, body: { error: { code: 'not_found', message: 'gone', details: {} } } }
},
})
const getsBefore = requestsTo(calls, 'GET', TOKENS).length
await click('[data-action="revoke-token"]', panel()!)
await click('[data-action="confirm"]', confirmDialog()!)
expect(toasts.value[0]).toMatchObject({
text: 'Ten token już nie istnieje. Lista została odświeżona.',
tone: 'danger',
})
expect(requestsTo(calls, 'GET', TOKENS).length).toBeGreaterThan(getsBefore)
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
})
it('requests revoked=1 and hides revoke on revoked rows', async () => {
const { calls } = await openAccount({
[`GET ${TOKENS}`]: (request) =>
envelope(queryOf(request).get('revoked') === '1' ? [existingToken, revokedToken] : [existingToken]),
})
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
toggle.checked = true
toggle.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
const withRevoked = requestsTo(calls, 'GET', TOKENS).filter((request) => queryOf(request).get('revoked') === '1')
expect(withRevoked.length).toBeGreaterThan(0)
expect(panel()!.querySelector('[data-token-row="13"]')).toBeTruthy()
expect(panel()!.querySelector('[data-token-row="13"] [data-action="revoke-token"]')).toBeNull()
expect(panel()!.querySelector('[data-token-row="11"] [data-action="revoke-token"]')).toBeTruthy()
})
})
})