Files
summercms/admin/tests/account/ApiTokenCreateDialog.test.ts
Jakub Zych cb4b0a9c75 fix(16.3): disable Create token until a scope is selected
Client-side canSubmit now matches the 422 rule so Submit stays disabled
with an empty scope set (WR-02).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:53:32 +02:00

455 lines
17 KiB
TypeScript

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<Reply>; release: (reply: Reply) => void } {
let release!: (reply: Reply) => void
const pending = new Promise<Reply>((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<string, Route> = {}): Record<string, Route> {
return {
[`GET ${TOKENS}`]: envelope([]),
[`GET ${SCOPES}`]: envelope(scopes),
[`POST ${TOKENS}`]: envelope(createdToken, 201),
...overrides,
}
}
const panel = () => document.body.querySelector<HTMLElement>('[data-api-tokens]')
const dialog = () => document.body.querySelector<HTMLElement>('[data-token-dialog]')
const confirmDialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
async function click(selector: string, root: ParentNode = document.body): Promise<void> {
const el = root.querySelector<HTMLElement>(selector)
el?.click()
await flushPromises()
}
async function openCreate(routes: Record<string, Route> = {}) {
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<void> {
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
nameInput.value = value
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function pickAdminRead(): Promise<void> {
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
scope.checked = true
scope.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
}
async function openCreateAtStep2(routes: Record<string, Route> = {}): Promise<void> {
await openCreate(routes)
await fillName('Agent')
await pickAdminRead()
await click('[data-action="create-token-submit"]', dialog()!)
expect(dialog()!.querySelector<HTMLInputElement>('[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<HTMLInputElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>('[data-scope="*"] input')!
const admin = dialog()!.querySelector<HTMLInputElement>('[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<HTMLElement>('[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<HTMLButtonElement>('[data-action="create-token-submit"]')!
submit.click()
await flushPromises()
expect(dialog()!.querySelector('fieldset')!.disabled).toBe(true)
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="create-token-cancel"]')!.disabled).toBe(
true,
)
expect(dialog()!.querySelector<HTMLButtonElement>('[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<HTMLInputElement>('#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<HTMLInputElement>('[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 <button> or a wrapper; accept either.
expect(
active === copy ||
copy.contains(active) ||
active?.closest('[data-action="copy-token"]') === copy ||
active?.textContent?.includes('Kopiuj token') === true,
).toBe(true)
})
})