Pin list states, row anatomy, revoke paths and create-dialog step 1/2 behaviours from the UI-SPEC, including plaintext lifetime backstops. Co-authored-by: Cursor <cursoragent@cursor.com>
455 lines
17 KiB
TypeScript
455 lines
17 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, keydown, 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 starToken = {
|
|
...existingToken,
|
|
id: 12,
|
|
name: 'Full agent',
|
|
hint: 'c3d4',
|
|
scopes: [
|
|
{
|
|
name: '*',
|
|
label: 'Full access',
|
|
description: 'Everything',
|
|
read_only: false,
|
|
known: true,
|
|
},
|
|
],
|
|
last_used_at: '2026-02-01T08:30:00Z',
|
|
expires_at: '',
|
|
}
|
|
|
|
const unknownScopeToken = {
|
|
...existingToken,
|
|
id: 14,
|
|
name: 'Legacy',
|
|
hint: 'zzzz',
|
|
scopes: [
|
|
{
|
|
name: 'gone.plugin',
|
|
label: '',
|
|
description: '',
|
|
read_only: false,
|
|
known: false,
|
|
},
|
|
],
|
|
expired: true,
|
|
expires_at: '2026-01-01T00:00:00Z',
|
|
}
|
|
|
|
const revokedToken = {
|
|
...existingToken,
|
|
id: 13,
|
|
name: 'Old token',
|
|
hint: 'dead',
|
|
revoked_at: '2026-02-01T08:30:00Z',
|
|
}
|
|
|
|
const createdToken = {
|
|
...existingToken,
|
|
id: 15,
|
|
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 skeleton with aria-busy on toggle reload', async () => {
|
|
const pending = deferred()
|
|
let first = true
|
|
await openAccount({
|
|
[`GET ${TOKENS}`]: () => {
|
|
if (first) {
|
|
first = false
|
|
return envelope([existingToken])
|
|
}
|
|
return pending.route()
|
|
},
|
|
})
|
|
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
|
|
toggle.checked = true
|
|
toggle.dispatchEvent(new Event('change', { bubbles: true }))
|
|
await flushPromises()
|
|
expect(panel()!.querySelector('[data-token-list]')!.getAttribute('aria-busy')).toBe('true')
|
|
expect(toggle.disabled).toBe(false)
|
|
pending.release(envelope([existingToken, revokedToken]))
|
|
await flushPromises()
|
|
expect(panel()!.querySelector('[data-token-row="13"]')).toBeTruthy()
|
|
})
|
|
|
|
it('shows empty block when revoked toggle is on and list is empty', async () => {
|
|
await openAccount(
|
|
{
|
|
[`GET ${TOKENS}`]: (request) =>
|
|
envelope(queryOf(request).get('revoked') === '1' ? [] : []),
|
|
},
|
|
[],
|
|
)
|
|
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
|
|
toggle.checked = true
|
|
toggle.dispatchEvent(new Event('change', { bubbles: true }))
|
|
await flushPromises()
|
|
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
|
|
})
|
|
|
|
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('row anatomy', () => {
|
|
it('renders name, hint aria-label, chips and meta line order', async () => {
|
|
await openAccount({}, [existingToken, starToken, unknownScopeToken, revokedToken])
|
|
const row = panel()!.querySelector('[data-token-row="11"]')!
|
|
expect(row.textContent).toContain('Blog agent')
|
|
const hint = row.querySelector('span.font-mono')!
|
|
expect(hint.getAttribute('aria-label')).toBe('Token kończący się na a1b2')
|
|
expect(hint.textContent).toContain('g15_…a1b2')
|
|
expect(row.querySelector('[data-token-scopes]')!.textContent).toContain('Read-only, whole admin')
|
|
expect(row.textContent).toMatch(/Utworzono .* · Nieużywany · Wygasa /)
|
|
|
|
const starRow = panel()!.querySelector('[data-token-row="12"]')!
|
|
expect(starRow.querySelector('[data-token-scopes]')!.textContent).toContain('Pełny dostęp')
|
|
expect(starRow.textContent).toContain('Bez terminu ważności')
|
|
expect(starRow.textContent).toMatch(/Ostatnio użyto /)
|
|
|
|
const unknownRow = panel()!.querySelector('[data-token-row="14"]')!
|
|
const unknownChip = unknownRow.querySelector('[data-token-scopes] span')!
|
|
expect(unknownChip.className).toContain('border-dashed')
|
|
expect(unknownChip.getAttribute('title')).toContain('nie jest już zarejestrowany')
|
|
expect(unknownChip.textContent).toContain('gone.plugin (nieznany)')
|
|
expect(unknownRow.textContent).toContain('Wygasły')
|
|
expect(unknownRow.textContent).toMatch(/Wygasł /)
|
|
|
|
const revokedRow = panel()!.querySelector('[data-token-row="13"]')!
|
|
expect(revokedRow.textContent).toContain('Unieważniony')
|
|
expect(revokedRow.textContent).toMatch(/Unieważniono /)
|
|
expect(revokedRow.querySelector('[data-action="revoke-token"]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
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()
|
|
expect(confirmDialog()!.textContent).toContain('Unieważnić token „Blog agent”?')
|
|
expect(confirmDialog()!.textContent).toContain('Tej operacji nie można cofnąć')
|
|
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' })
|
|
await flushPromises()
|
|
const createBtn =
|
|
panel()!.querySelector<HTMLElement>('[data-action="create-token"]') ??
|
|
panel()!.querySelector('button')
|
|
expect(document.activeElement === createBtn || panel()!.contains(document.activeElement)).toBe(true)
|
|
})
|
|
|
|
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('keeps the row when revoke fails with a non-404 error', async () => {
|
|
await openAccount({
|
|
[`DELETE ${TOKEN_ID}`]: {
|
|
status: 500,
|
|
body: { error: { code: 'x', message: 'fail', details: {} } },
|
|
},
|
|
})
|
|
await click('[data-action="revoke-token"]', panel()!)
|
|
await click('[data-action="confirm"]', confirmDialog()!)
|
|
expect(toasts.value[0]).toMatchObject({
|
|
text: 'Nie udało się unieważnić tokenu. Spróbuj ponownie.',
|
|
tone: 'danger',
|
|
})
|
|
expect(panel()!.querySelector('[data-token-row="11"]')).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()
|
|
})
|
|
})
|
|
|
|
describe('dialog rules', () => {
|
|
async function openCreateDialog(routes: Record<string, Route> = {}) {
|
|
const mounted = await openAccount(routes)
|
|
await click('[data-action="create-token"]', panel()!)
|
|
await flushPromises()
|
|
return mounted
|
|
}
|
|
|
|
async function fillName(value: string): Promise<void> {
|
|
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
|
|
nameInput.value = value
|
|
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
}
|
|
|
|
it('disables other scopes when * is checked and posts only ["*"]', async () => {
|
|
const { calls } = await openCreateDialog()
|
|
await fillName('Full')
|
|
const star = dialog()!.querySelector<HTMLInputElement>('[data-scope="*"] input')!
|
|
star.checked = true
|
|
star.dispatchEvent(new Event('change', { bubbles: true }))
|
|
await flushPromises()
|
|
expect(dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!.disabled).toBe(true)
|
|
expect(dialog()!.textContent).toContain('Pełny dostęp obejmuje wszystkie pozostałe zakresy.')
|
|
await click('[data-action="create-token-submit"]', dialog()!)
|
|
const [post] = requestsTo(calls, 'POST', TOKENS)
|
|
expect(await post!.clone().json()).toEqual({ name: 'Full', expires: '90d', scopes: ['*'] })
|
|
})
|
|
|
|
it('renders a 422 on scopes and focuses the first scope checkbox', async () => {
|
|
await openCreateDialog({
|
|
[`POST ${TOKENS}`]: {
|
|
status: 422,
|
|
body: {
|
|
error: {
|
|
code: 'validation_failed',
|
|
message: 'Validation failed',
|
|
details: { scopes: ['Wybierz co najmniej jeden zakres.'] },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
await fillName('No scope')
|
|
await click('[data-action="create-token-submit"]', dialog()!)
|
|
expect(dialog()!.textContent).toContain('Wybierz co najmniej jeden zakres.')
|
|
expect(document.activeElement).toBe(dialog()!.querySelector('[data-scope="*"] input'))
|
|
})
|
|
|
|
it('shows Copied on clipboard success and copy_failed on rejection', async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText },
|
|
})
|
|
await openCreateDialog()
|
|
await fillName('Copy me')
|
|
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
|
|
scope.checked = true
|
|
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
|
await flushPromises()
|
|
await click('[data-action="create-token-submit"]', dialog()!)
|
|
await click('[data-action="copy-token"]', dialog()!)
|
|
expect(writeText).toHaveBeenCalledWith(createdToken.token)
|
|
expect(dialog()!.querySelector('[data-action="copy-token"]')!.textContent).toContain('Skopiowano')
|
|
expect(dialog()!.querySelector('[aria-live="polite"]')!.textContent).toContain(
|
|
'Skopiowano token do schowka.',
|
|
)
|
|
|
|
writeText.mockRejectedValueOnce(new Error('denied'))
|
|
await click('[data-action="copy-token"]', dialog()!)
|
|
expect(dialog()!.querySelector('[role="alert"]')!.textContent).toContain(
|
|
'Nie udało się skopiować automatycznie',
|
|
)
|
|
expect(document.activeElement).toBe(dialog()!.querySelector('[data-token-plaintext]'))
|
|
})
|
|
|
|
it('asks before closing step 2 without a copy and keeps the step on cancel', async () => {
|
|
await openCreateDialog()
|
|
await fillName('Guard')
|
|
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
|
|
scope.checked = true
|
|
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
|
await flushPromises()
|
|
await click('[data-action="create-token-submit"]', dialog()!)
|
|
expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy()
|
|
keydown(dialog()!, 'Escape')
|
|
await flushPromises()
|
|
if (!confirmDialog()) {
|
|
await click('[data-action="close"]', dialog()!)
|
|
}
|
|
expect(confirmDialog()).toBeTruthy()
|
|
expect(confirmDialog()!.textContent).toContain('Zamknąć bez kopiowania')
|
|
await click('[data-action="cancel"]', confirmDialog()!)
|
|
expect(dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!.value).toBe(
|
|
createdToken.token,
|
|
)
|
|
})
|
|
})
|
|
})
|