From 55999b29849fd6e072ba81f8b233f3884b013431 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 13:36:17 +0200 Subject: [PATCH] test(16.3-04): full SPA coverage for API tokens panel and dialog 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 --- .../account/ApiTokenCreateDialog.test.ts | 290 +++++++++++++++++- admin/tests/account/ApiTokensPanel.test.ts | 131 +++++++- 2 files changed, 405 insertions(+), 16 deletions(-) diff --git a/admin/tests/account/ApiTokenCreateDialog.test.ts b/admin/tests/account/ApiTokenCreateDialog.test.ts index ac16d83..ab76165 100644 --- a/admin/tests/account/ApiTokenCreateDialog.test.ts +++ b/admin/tests/account/ApiTokenCreateDialog.test.ts @@ -1,7 +1,7 @@ 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' +import { API, keydown, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers' const TOKENS = `${API}/account/tokens` const SCOPES = `${API}/account/tokens/scopes` @@ -48,6 +48,24 @@ const scopes = [ }, ] +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([]), @@ -67,18 +85,32 @@ async function click(selector: string, root: ParentNode = document.body): Promis await flushPromises() } -async function openCreateAtStep2(): Promise { - await mountApp('/account', tokenRoutes(), { attach: true }) +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 = 'Agent' + 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) } @@ -98,6 +130,7 @@ beforeEach(() => { afterEach(() => { document.body.innerHTML = '' + vi.useRealTimers() vi.unstubAllGlobals() }) @@ -166,17 +199,162 @@ describe('ApiTokenCreateDialog plaintext lifetime', () => { }) }) -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 })) +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() + }) + + 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({ @@ -188,3 +366,87 @@ describe('ApiTokenCreateDialog step 1 smoke from dedicated file', () => { 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