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 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; 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 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('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('[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('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('[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() // Reka Escape may be handled as escape-key-down; fall back to the close button. 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, ) }) }) })