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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<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([]),
|
||||
@@ -67,18 +85,32 @@ async function click(selector: string, root: ParentNode = document.body): Promis
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openCreateAtStep2(): Promise<void> {
|
||||
await mountApp('/account', tokenRoutes(), { attach: true })
|
||||
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 = 'Agent'
|
||||
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)
|
||||
}
|
||||
@@ -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<HTMLInputElement>('#api-token-name')!
|
||||
nameInput.value = 'CI'
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
const scope = dialog()!.querySelector<HTMLInputElement>('[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<HTMLButtonElement>('[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<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({
|
||||
@@ -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<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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,42 @@ const existingToken = {
|
||||
expired: false,
|
||||
}
|
||||
|
||||
const starToken = {
|
||||
...existingToken,
|
||||
id: 12,
|
||||
name: 'Full agent',
|
||||
hint: 'c3d4',
|
||||
scopes: [
|
||||
{
|
||||
name: '*',
|
||||
label: 'Full access',
|
||||
description: 'Everything',
|
||||
read_only: false,
|
||||
known: true,
|
||||
},
|
||||
],
|
||||
last_used_at: '2026-02-01T08:30:00Z',
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
const unknownScopeToken = {
|
||||
...existingToken,
|
||||
id: 14,
|
||||
name: 'Legacy',
|
||||
hint: 'zzzz',
|
||||
scopes: [
|
||||
{
|
||||
name: 'gone.plugin',
|
||||
label: '',
|
||||
description: '',
|
||||
read_only: false,
|
||||
known: false,
|
||||
},
|
||||
],
|
||||
expired: true,
|
||||
expires_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const revokedToken = {
|
||||
...existingToken,
|
||||
id: 13,
|
||||
@@ -39,7 +75,7 @@ const revokedToken = {
|
||||
|
||||
const createdToken = {
|
||||
...existingToken,
|
||||
id: 12,
|
||||
id: 15,
|
||||
name: 'CI runner',
|
||||
hint: 'c3d4',
|
||||
token: 'g15_0123456789abcdef0123456789abcdef01234567',
|
||||
@@ -166,6 +202,44 @@ describe('ApiTokensPanel', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows skeleton with aria-busy on toggle reload', async () => {
|
||||
const pending = deferred()
|
||||
let first = true
|
||||
await openAccount({
|
||||
[`GET ${TOKENS}`]: () => {
|
||||
if (first) {
|
||||
first = false
|
||||
return envelope([existingToken])
|
||||
}
|
||||
return pending.route()
|
||||
},
|
||||
})
|
||||
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
|
||||
toggle.checked = true
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(panel()!.querySelector('[data-token-list]')!.getAttribute('aria-busy')).toBe('true')
|
||||
expect(toggle.disabled).toBe(false)
|
||||
pending.release(envelope([existingToken, revokedToken]))
|
||||
await flushPromises()
|
||||
expect(panel()!.querySelector('[data-token-row="13"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows empty block when revoked toggle is on and list is empty', async () => {
|
||||
await openAccount(
|
||||
{
|
||||
[`GET ${TOKENS}`]: (request) =>
|
||||
envelope(queryOf(request).get('revoked') === '1' ? [] : []),
|
||||
},
|
||||
[],
|
||||
)
|
||||
const toggle = panel()!.querySelector<HTMLInputElement>('[data-show-revoked]')!
|
||||
toggle.checked = true
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
|
||||
})
|
||||
|
||||
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: {} } } },
|
||||
@@ -176,6 +250,37 @@ describe('ApiTokensPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('row anatomy', () => {
|
||||
it('renders name, hint aria-label, chips and meta line order', async () => {
|
||||
await openAccount({}, [existingToken, starToken, unknownScopeToken, revokedToken])
|
||||
const row = panel()!.querySelector('[data-token-row="11"]')!
|
||||
expect(row.textContent).toContain('Blog agent')
|
||||
const hint = row.querySelector('span.font-mono')!
|
||||
expect(hint.getAttribute('aria-label')).toBe('Token kończący się na a1b2')
|
||||
expect(hint.textContent).toContain('g15_…a1b2')
|
||||
expect(row.querySelector('[data-token-scopes]')!.textContent).toContain('Read-only, whole admin')
|
||||
expect(row.textContent).toMatch(/Utworzono .* · Nieużywany · Wygasa /)
|
||||
|
||||
const starRow = panel()!.querySelector('[data-token-row="12"]')!
|
||||
expect(starRow.querySelector('[data-token-scopes]')!.textContent).toContain('Pełny dostęp')
|
||||
expect(starRow.textContent).toContain('Bez terminu ważności')
|
||||
expect(starRow.textContent).toMatch(/Ostatnio użyto /)
|
||||
|
||||
const unknownRow = panel()!.querySelector('[data-token-row="14"]')!
|
||||
const unknownChip = unknownRow.querySelector('[data-token-scopes] span')!
|
||||
expect(unknownChip.className).toContain('border-dashed')
|
||||
expect(unknownChip.getAttribute('title')).toContain('nie jest już zarejestrowany')
|
||||
expect(unknownChip.textContent).toContain('gone.plugin (nieznany)')
|
||||
expect(unknownRow.textContent).toContain('Wygasły')
|
||||
expect(unknownRow.textContent).toMatch(/Wygasł /)
|
||||
|
||||
const revokedRow = panel()!.querySelector('[data-token-row="13"]')!
|
||||
expect(revokedRow.textContent).toContain('Unieważniony')
|
||||
expect(revokedRow.textContent).toMatch(/Unieważniono /)
|
||||
expect(revokedRow.querySelector('[data-action="revoke-token"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoke', () => {
|
||||
it('runs DELETE through the shared confirm and toasts success', async () => {
|
||||
const { calls } = await openAccount({
|
||||
@@ -183,9 +288,16 @@ describe('ApiTokensPanel', () => {
|
||||
})
|
||||
await click('[data-action="revoke-token"]', panel()!)
|
||||
expect(confirmDialog()).toBeTruthy()
|
||||
expect(confirmDialog()!.textContent).toContain('Unieważnić token „Blog agent”?')
|
||||
expect(confirmDialog()!.textContent).toContain('Tej operacji nie można cofnąć')
|
||||
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' })
|
||||
await flushPromises()
|
||||
const createBtn =
|
||||
panel()!.querySelector<HTMLElement>('[data-action="create-token"]') ??
|
||||
panel()!.querySelector('button')
|
||||
expect(document.activeElement === createBtn || panel()!.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('toasts gone on 404 and reloads', async () => {
|
||||
@@ -208,6 +320,22 @@ describe('ApiTokensPanel', () => {
|
||||
expect(panel()!.querySelector('[data-tokens-empty]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the row when revoke fails with a non-404 error', async () => {
|
||||
await openAccount({
|
||||
[`DELETE ${TOKEN_ID}`]: {
|
||||
status: 500,
|
||||
body: { error: { code: 'x', message: 'fail', details: {} } },
|
||||
},
|
||||
})
|
||||
await click('[data-action="revoke-token"]', panel()!)
|
||||
await click('[data-action="confirm"]', confirmDialog()!)
|
||||
expect(toasts.value[0]).toMatchObject({
|
||||
text: 'Nie udało się unieważnić tokenu. Spróbuj ponownie.',
|
||||
tone: 'danger',
|
||||
})
|
||||
expect(panel()!.querySelector('[data-token-row="11"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('requests revoked=1 and hides revoke on revoked rows', async () => {
|
||||
const { calls } = await openAccount({
|
||||
[`GET ${TOKENS}`]: (request) =>
|
||||
@@ -312,7 +440,6 @@ describe('ApiTokensPanel', () => {
|
||||
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()!)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user