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, requestsTo, resetState, type Route } from '../helpers' const TOKENS = `${API}/account/tokens` const SCOPES = `${API}/account/tokens/scopes` const envelope = (data: unknown, status = 200) => ({ status, body: { data, meta: {} } }) const PLAIN = 'g15_0123456789abcdef0123456789abcdef01234567' const createdToken = { id: 12, name: 'CI runner', hint: 'c3d4', 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, token: PLAIN, } 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 tokenRoutes(overrides: Record = {}): Record { return { [`GET ${TOKENS}`]: envelope([]), [`GET ${SCOPES}`]: envelope(scopes), [`POST ${TOKENS}`]: envelope(createdToken, 201), ...overrides, } } 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() } async function openCreateAtStep2(): Promise { await mountApp('/account', tokenRoutes(), { attach: true }) await flushPromises() await click('[data-action="create-token"]', panel()!) await flushPromises() const nameInput = dialog()!.querySelector('#api-token-name')! nameInput.value = 'Agent' nameInput.dispatchEvent(new Event('input', { bubbles: true })) 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]')!.value).toBe(PLAIN) } function plaintextRetained(): boolean { if (document.body.innerHTML.includes(PLAIN)) return true if (document.body.textContent?.includes(PLAIN)) return true for (const toast of toasts.value) { if (toast.text.includes(PLAIN)) return true } return false } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' vi.unstubAllGlobals() }) enableAutoUnmount(afterEach) describe('ApiTokenCreateDialog plaintext lifetime', () => { it('clears plaintext from the DOM and toasts after Done (hide)', async () => { await openCreateAtStep2() await click('[data-action="hide-token"]', dialog()!) expect(dialog()).toBeNull() expect(plaintextRetained()).toBe(false) }) it('clears plaintext after Esc-confirm close and reopening starts at step 1', async () => { await openCreateAtStep2() keydown(dialog()!, 'Escape') await flushPromises() if (!confirmDialog()) { await click('[data-action="close"]', dialog()!) } expect(confirmDialog()).toBeTruthy() await click('[data-action="confirm"]', confirmDialog()!) await flushPromises() expect(dialog()).toBeNull() expect(plaintextRetained()).toBe(false) await click('[data-action="create-token"]', panel()!) await flushPromises() expect(dialog()).toBeTruthy() expect(dialog()!.querySelector('[data-token-plaintext]')).toBeNull() expect(dialog()!.querySelector('#api-token-name')).toBeTruthy() expect(plaintextRetained()).toBe(false) }) it('copy success announces; clipboard failure selects and alerts', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) await openCreateAtStep2() await click('[data-action="copy-token"]', dialog()!) expect(writeText).toHaveBeenCalledWith(PLAIN) 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('Esc before copying asks and cancel keeps step 2', async () => { await openCreateAtStep2() keydown(dialog()!, 'Escape') await flushPromises() if (!confirmDialog()) { await click('[data-action="close"]', dialog()!) } expect(confirmDialog()).toBeTruthy() await click('[data-action="cancel"]', confirmDialog()!) expect(dialog()!.querySelector('[data-token-plaintext]')!.value).toBe(PLAIN) }) }) describe('ApiTokenCreateDialog step 1 smoke from dedicated file', () => { it('posts selected scopes and never leaves plaintext after Done', async () => { const { calls } = await mountApp('/account', tokenRoutes(), { attach: true }) await click('[data-action="create-token"]', panel()!) const nameInput = dialog()!.querySelector('#api-token-name')! nameInput.value = 'CI' nameInput.dispatchEvent(new Event('input', { bubbles: true })) 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()!) const [post] = requestsTo(calls, 'POST', TOKENS) expect(await post!.clone().json()).toEqual({ name: 'CI', expires: '90d', scopes: ['admin:read'], }) await click('[data-action="hide-token"]', dialog()!) expect(plaintextRetained()).toBe(false) }) })