import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import ApiTokensPanel from '../../src/components/account/ApiTokensPanel.vue' import { useConfirm } from '../../src/components/ui/confirm' import { toasts } from '../../src/state/useToasts' import { API, keydown, mockApi, 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; release: (reply: Reply) => void } { let release!: (reply: Reply) => void const pending = new Promise((resolve) => { release = resolve }) return { route: () => pending, release } } function tokenRoutes(overrides: Record = {}, list: unknown[] = [existingToken]): Record { 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 = {}, list: unknown[] = [existingToken]) { return mountApp('/account', tokenRoutes(routes, list), { attach: true }) } const panel = () => document.body.querySelector('[data-api-tokens]') const dialog = () => document.body.querySelector('[data-token-dialog]') const confirmDialog = () => document.body.querySelector('[data-confirm-dialog]') async function click(selector: string, root: ParentNode = document.body): Promise { const el = root.querySelector(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('#api-token-name')! await nameInput.focus() nameInput.value = 'CI runner' nameInput.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() const scope = dialog()!.querySelector('[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('[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('[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('[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('[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('[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('[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('[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 = {}) { const mounted = await openAccount(routes) await click('[data-action="create-token"]', panel()!) await flushPromises() return mounted } async function fillName(value: string): Promise { const nameInput = dialog()!.querySelector('#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('[data-scope="*"] input')! star.checked = true star.dispatchEvent(new Event('change', { bubbles: true })) await flushPromises() expect(dialog()!.querySelector('[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('keeps submit disabled until a scope is selected', async () => { await openCreateDialog() await fillName('No scope') expect(dialog()!.querySelector('[data-action="create-token-submit"]')!.disabled).toBe( true, ) const scope = dialog()!.querySelector('[data-scope="admin:read"] input')! scope.checked = true scope.dispatchEvent(new Event('change', { bubbles: true })) await flushPromises() expect(dialog()!.querySelector('[data-action="create-token-submit"]')!.disabled).toBe( false, ) }) 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('Scoped') const scope = dialog()!.querySelector('[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()!.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('[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('[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('[data-token-plaintext]')!.value).toBe( createdToken.token, ) }) }) }) describe('ApiTokensPanel mounted directly', () => { async function mountPanel(list: Route) { const calls = mockApi({ [`GET ${TOKENS}`]: list, [`GET ${SCOPES}`]: envelope(scopes) }) const wrapper = mount(ApiTokensPanel, { props: { confirm: useConfirm() }, attachTo: document.body }) await flushPromises() return { wrapper, calls } } it('lists the tokens from one GET', async () => { const { wrapper, calls } = await mountPanel(envelope([existingToken])) const row = wrapper.find('[data-token-row="11"]') expect(row.exists()).toBe(true) expect(row.text()).toContain('Blog agent') expect(wrapper.find('[data-tokens-empty]').exists()).toBe(false) expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1) }) it('shows the empty block for an empty list', async () => { const { wrapper, calls } = await mountPanel(envelope([])) expect(wrapper.find('[data-tokens-empty]').exists()).toBe(true) expect(wrapper.find('[data-token-row]').exists()).toBe(false) expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1) }) it('shows the error block when the list fails', async () => { const { wrapper, calls } = await mountPanel({ status: 500, body: { error: { code: 'internal', message: 'fail', details: {} } }, }) expect(wrapper.find('[data-tokens-error]').exists()).toBe(true) expect(wrapper.find('[data-token-row]').exists()).toBe(false) expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1) }) })