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 Reply, 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 deferred(): { route: () => Promise; release: (reply: Reply) => void } { let release!: (reply: Reply) => void const pending = new Promise((resolve) => { release = resolve }) return { route: () => pending, release } } function pad(value: number): string { return String(value).padStart(2, '0') } function localDatePlus(days: number): string { const date = new Date() date.setDate(date.getDate() + days) return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } 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 openCreate(routes: Record = {}) { const mounted = await mountApp('/account', tokenRoutes(routes), { attach: true }) await flushPromises() 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() } async function pickAdminRead(): Promise { const scope = dialog()!.querySelector('[data-scope="admin:read"] input')! scope.checked = true scope.dispatchEvent(new Event('change', { bubbles: true })) await flushPromises() } async function openCreateAtStep2(routes: Record = {}): Promise { await openCreate(routes) await fillName('Agent') await pickAdminRead() 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.useRealTimers() 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', () => { it('focuses the name input on open', async () => { await openCreate() expect(document.activeElement).toBe(dialog()!.querySelector('#api-token-name')) }) it('shows scopes skeleton and disables submit while pending', async () => { const pending = deferred() await openCreate({ [`GET ${SCOPES}`]: pending.route }) expect(dialog()!.querySelector('[data-scope-picker]')!.querySelectorAll('[aria-hidden="true"]').length).toBe(3) expect(dialog()!.querySelector('[data-action="create-token-submit"]')!.disabled).toBe(true) pending.release(envelope(scopes)) await flushPromises() expect(dialog()!.querySelector('[data-scope="admin:read"]')).toBeTruthy() // No scope selected yet — submit stays disabled (WR-02). expect(dialog()!.querySelector('[data-action="create-token-submit"]')!.disabled).toBe(true) }) it('shows scopes failure alert when the scopes request fails', async () => { await openCreate({ [`GET ${SCOPES}`]: { status: 500, body: { error: { code: 'x', message: 'fail', details: {} } } }, }) expect(dialog()!.textContent).toContain('Nie udało się wczytać zakresów') expect(dialog()!.querySelector('[data-action="create-token-submit"]')!.disabled).toBe(true) }) it('shows scopes failure alert when the scopes list is empty', async () => { await openCreate({ [`GET ${SCOPES}`]: envelope([]) }) expect(dialog()!.textContent).toContain('Nie udało się wczytać zakresów') expect(dialog()!.querySelector('[data-action="create-token-submit"]')!.disabled).toBe(true) }) it('star checked disables others; unchecking restores without picks; no preselection', async () => { await openCreate() const star = dialog()!.querySelector('[data-scope="*"] input')! const admin = dialog()!.querySelector('[data-scope="admin:read"] input')! expect(star.checked).toBe(false) expect(admin.checked).toBe(false) admin.checked = true admin.dispatchEvent(new Event('change', { bubbles: true })) await flushPromises() star.checked = true star.dispatchEvent(new Event('change', { bubbles: true })) await flushPromises() expect(admin.disabled).toBe(true) expect(admin.checked).toBe(false) expect(dialog()!.textContent).toContain('Pełny dostęp obejmuje wszystkie pozostałe zakresy.') star.checked = false star.dispatchEvent(new Event('change', { bubbles: true })) await flushPromises() expect(admin.disabled).toBe(false) expect(admin.checked).toBe(false) }) it('preselects 90d and previews 30, 90, 365 and never', async () => { await openCreate() const segments = [...dialog()!.querySelectorAll('[data-expiry-segment]')] expect(segments.map((el) => el.getAttribute('value') ?? el.textContent)).toBeTruthy() const checked = dialog()!.querySelector('[data-expiry-segment][data-state="checked"]') expect(checked?.textContent).toContain('90 dni') expect(dialog()!.textContent).toContain(`Wygasa ${localDatePlus(90)}.`) const clickSegment = async (label: string) => { const el = segments.find((s) => s.textContent?.includes(label)) el?.click() await flushPromises() } await clickSegment('30 dni') expect(dialog()!.textContent).toContain(`Wygasa ${localDatePlus(30)}.`) await clickSegment('365 dni') expect(dialog()!.textContent).toContain(`Wygasa ${localDatePlus(365)}.`) await clickSegment('Bez terminu') expect(dialog()!.textContent).toContain('Ten token nie wygasa') }) it('renders 422 for name and expires with aria-invalid and focuses first control', async () => { await openCreate({ [`POST ${TOKENS}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Podaj nazwę tokenu.'], expires: ['Wybierz poprawny termin ważności.'], }, }, }, }, }) await pickAdminRead() await click('[data-action="create-token-submit"]', dialog()!) expect(dialog()!.textContent).toContain('Podaj nazwę tokenu.') expect(dialog()!.querySelector('#api-token-name')!.getAttribute('aria-invalid')).toBe('true') expect(document.activeElement).toBe(dialog()!.querySelector('#api-token-name')) }) it('busy disables fieldset, buttons, Esc and overlay and ignores a second submit', async () => { const pending = deferred() let posts = 0 await openCreate({ [`POST ${TOKENS}`]: () => { posts += 1 return pending.route() }, }) await fillName('Busy') await pickAdminRead() const submit = dialog()!.querySelector('[data-action="create-token-submit"]')! submit.click() await flushPromises() expect(dialog()!.querySelector('fieldset')!.disabled).toBe(true) expect(dialog()!.querySelector('[data-action="create-token-cancel"]')!.disabled).toBe( true, ) expect(dialog()!.querySelector('[data-action="close"]')!.disabled).toBe(true) expect(dialog()!.textContent).toContain('Tworzenie…') submit.click() await flushPromises() expect(posts).toBe(1) keydown(dialog()!, 'Escape') await flushPromises() expect(dialog()).toBeTruthy() dialog()!.querySelector('[data-dialog-overlay]')?.dispatchEvent(new Event('pointerdown', { bubbles: true })) await flushPromises() expect(dialog()).toBeTruthy() pending.release(envelope(createdToken, 201)) await flushPromises() expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy() }) it('non-422 failure toast keeps input; discard closes and returns focus to Create', async () => { await openCreate({ [`POST ${TOKENS}`]: { status: 500, body: { error: { code: 'x', message: 'fail', details: {} } }, }, }) await fillName('Keep me') await pickAdminRead() await click('[data-action="create-token-submit"]', dialog()!) expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się utworzyć tokenu. Spróbuj ponownie.', tone: 'danger', }) expect(dialog()!.querySelector('#api-token-name')!.value).toBe('Keep me') await click('[data-action="create-token-cancel"]', dialog()!) expect(dialog()).toBeNull() await flushPromises() const create = panel()!.querySelector('[data-action="create-token"]') expect(document.activeElement === create || panel()!.contains(document.activeElement)).toBe(true) }) it('posts selected scopes and never leaves plaintext after Done', async () => { const { calls } = await openCreate() await fillName('CI') await pickAdminRead() 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) }) }) describe('ApiTokenCreateDialog step 2', () => { it('copy success flashes for two seconds and announces politely', async () => { vi.useFakeTimers() const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) await openCreateAtStep2() await click('[data-action="copy-token"]', dialog()!) expect(dialog()!.querySelector('[data-action="copy-token"]')!.textContent).toContain('Skopiowano') expect(dialog()!.querySelector('[aria-live="polite"]')!.textContent).toContain( 'Skopiowano token do schowka.', ) await vi.advanceTimersByTimeAsync(2000) await flushPromises() expect(dialog()!.querySelector('[data-action="copy-token"]')!.textContent).toContain('Kopiuj token') }) it('missing clipboard selects the input and shows the alert', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined, }) await openCreateAtStep2() 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('manual copy event counts as copied so Esc closes without confirm', async () => { await openCreateAtStep2() const input = dialog()!.querySelector('[data-token-plaintext]')! input.dispatchEvent(new Event('copy', { bubbles: true })) await flushPromises() keydown(dialog()!, 'Escape') await flushPromises() expect(confirmDialog()).toBeNull() expect(dialog()).toBeNull() expect(plaintextRetained()).toBe(false) }) it('overlay never closes step 2; after copy Esc closes at once; Done needs no confirm', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) await openCreateAtStep2() const overlay = document.body.querySelector('[data-dialog-overlay]') overlay?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) await flushPromises() expect(dialog()).toBeTruthy() expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy() await click('[data-action="copy-token"]', dialog()!) keydown(dialog()!, 'Escape') await flushPromises() expect(confirmDialog()).toBeNull() expect(dialog()).toBeNull() expect(plaintextRetained()).toBe(false) await openCreateAtStep2() await click('[data-action="hide-token"]', dialog()!) expect(dialog()).toBeNull() expect(plaintextRetained()).toBe(false) }) it('focuses Copy token when entering step 2', async () => { await openCreateAtStep2() const copy = dialog()!.querySelector('[data-action="copy-token"]')! const active = document.activeElement // Button may put data-action on the