test(16.3-04): pin deny-by-default contract at every layer
Scope match/Allows tables, route classification for every phase09Routes entry, SPA plaintext lifetime backstops, and Postgres owner lifecycle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
190
admin/tests/account/ApiTokenCreateDialog.test.ts
Normal file
190
admin/tests/account/ApiTokenCreateDialog.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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<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 openCreateAtStep2(): Promise<void> {
|
||||
await mountApp('/account', tokenRoutes(), { attach: true })
|
||||
await flushPromises()
|
||||
await click('[data-action="create-token"]', panel()!)
|
||||
await flushPromises()
|
||||
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
|
||||
nameInput.value = 'Agent'
|
||||
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 }))
|
||||
await flushPromises()
|
||||
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.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 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 }))
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user