2026-07-18 01:49:00 +09:00
|
|
|
import { expect, test, type Page, type Route } from '@playwright/test'
|
|
|
|
|
|
|
|
|
|
const observedAt = '2026-07-18T00:00:00Z'
|
|
|
|
|
const result: MockResult = {
|
|
|
|
|
kind: 'manifest',
|
|
|
|
|
manifest_id: 'manifest_001',
|
|
|
|
|
schema_version: 1,
|
|
|
|
|
manifest: {
|
|
|
|
|
store: 'target-main',
|
|
|
|
|
bucket: 'fixture-bucket',
|
|
|
|
|
key: 'manifest.json',
|
|
|
|
|
checksum: `sha256:${'a'.repeat(64)}`,
|
|
|
|
|
size: 12,
|
|
|
|
|
},
|
|
|
|
|
member_count: 1,
|
|
|
|
|
total_bytes: 12,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test.beforeEach(async ({ page }) => {
|
2026-07-19 18:21:17 +09:00
|
|
|
await page.routeWebSocket(/\/bff\/events/, () => {})
|
2026-07-18 01:49:00 +09:00
|
|
|
})
|
|
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
test('creates, rotates, and revokes a credential', async ({ page }) => {
|
2026-07-18 01:49:00 +09:00
|
|
|
const state = await mockApi(page)
|
|
|
|
|
await authenticatedSession(page)
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Remora' })).toBeVisible()
|
|
|
|
|
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
|
|
|
|
|
await expect(page.getByLabel('Synchronization status')).toHaveText('live')
|
|
|
|
|
await expect.poll(() => state.paths).toContain('jobs')
|
|
|
|
|
await expect(page.getByText('Existing archive')).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await page.keyboard.press('Tab')
|
|
|
|
|
await expect(page.getByRole('button', { name: 'Sign out' })).toBeFocused()
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: 'Credentials' }).click()
|
|
|
|
|
await page.getByLabel('Label').fill('Archive token')
|
|
|
|
|
const newToken = page.getByRole('textbox', {
|
|
|
|
|
name: 'New token',
|
|
|
|
|
exact: true,
|
|
|
|
|
})
|
|
|
|
|
await newToken.fill('secret-token')
|
|
|
|
|
await page.getByRole('button', { name: 'Create credential' }).click()
|
|
|
|
|
await expect(newToken).toHaveValue('')
|
|
|
|
|
await page.getByLabel('Replacement token').fill('replacement-token')
|
|
|
|
|
await page.getByRole('button', { name: 'Rotate credential' }).click()
|
|
|
|
|
await expect(page.getByLabel('Replacement token')).toHaveValue('')
|
2026-07-19 18:21:17 +09:00
|
|
|
await page.getByRole('button', { name: 'Revoke credential' }).click()
|
|
|
|
|
await expect(page.getByRole('alertdialog')).toContainText('future execution')
|
|
|
|
|
await page.getByRole('button', { name: 'Confirm' }).click()
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByText('Credential revoked for future grants'),
|
|
|
|
|
).toBeVisible()
|
|
|
|
|
expect(state.mutations).toEqual([])
|
|
|
|
|
expect(new Set(state.idempotencyKeys).size).toBe(state.idempotencyKeys.length)
|
|
|
|
|
})
|
2026-07-18 01:49:00 +09:00
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
test('submits a job and browses its history and result', async ({ page }) => {
|
|
|
|
|
const state = await mockApi(page)
|
|
|
|
|
await authenticatedSession(page)
|
2026-07-18 01:49:00 +09:00
|
|
|
await page.getByRole('button', { name: 'Submit job' }).click()
|
|
|
|
|
await page.getByLabel('Label').fill('Imported archive')
|
|
|
|
|
await page
|
|
|
|
|
.getByLabel('URL')
|
|
|
|
|
.fill('https://example.invalid/archive?token=sensitive')
|
|
|
|
|
await page.getByLabel('Result path').fill('archive.bin')
|
|
|
|
|
await page.getByLabel('Source credential').selectOption('credential_001')
|
|
|
|
|
await page.getByRole('button', { name: 'Submit job now' }).click()
|
|
|
|
|
await expect(page.getByText('Job accepted')).toBeVisible()
|
|
|
|
|
await expect.poll(() => state.submission?.label).toBe('Imported archive')
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByRole('heading', { name: 'Imported archive' }),
|
|
|
|
|
).toBeVisible()
|
|
|
|
|
expect(state.submission?.source.credential_id).toBe('credential_001')
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: 'Load attempt history' }).click()
|
|
|
|
|
await expect(page.getByText('AttemptCommitted')).toBeVisible()
|
|
|
|
|
await page.getByRole('button', { name: 'Browse result' }).click()
|
|
|
|
|
await expect(page.getByText('archive.bin')).toBeVisible()
|
2026-07-19 18:21:17 +09:00
|
|
|
expect(new Set(state.idempotencyKeys).size).toBe(state.idempotencyKeys.length)
|
|
|
|
|
})
|
2026-07-18 01:49:00 +09:00
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
test('confirms reversible and destructive job lifecycle actions', async ({
|
|
|
|
|
page,
|
|
|
|
|
}) => {
|
|
|
|
|
const state = await mockApi(page)
|
|
|
|
|
await authenticatedSession(page)
|
|
|
|
|
await page.getByRole('button', { name: /Existing archive/ }).click()
|
2026-07-18 01:49:00 +09:00
|
|
|
const softDelete = page.getByRole('button', { name: 'Soft delete' })
|
|
|
|
|
await softDelete.click()
|
|
|
|
|
await expect(page.getByRole('alertdialog')).toContainText(
|
|
|
|
|
'may still be restored',
|
|
|
|
|
)
|
|
|
|
|
await expect(page.getByRole('button', { name: 'Keep job' })).toBeFocused()
|
|
|
|
|
await page.keyboard.press('Escape')
|
|
|
|
|
await expect(page.getByRole('alertdialog')).toBeHidden()
|
|
|
|
|
await expect(softDelete).toBeFocused()
|
|
|
|
|
await softDelete.click()
|
|
|
|
|
await page.getByRole('button', { name: 'Confirm' }).click()
|
|
|
|
|
await page.reload()
|
|
|
|
|
await expect(page.getByLabel('Synchronization status')).toHaveText('live')
|
2026-07-19 18:21:17 +09:00
|
|
|
await page.getByRole('button', { name: /Existing archive/ }).click()
|
2026-07-18 01:49:00 +09:00
|
|
|
expect(state.includeDeletedRequests).toBeGreaterThan(1)
|
|
|
|
|
await expect(page.getByRole('button', { name: 'Browse result' })).toBeHidden()
|
|
|
|
|
await page.getByRole('button', { name: 'Restore' }).click()
|
|
|
|
|
await page.getByRole('button', { name: 'Confirm' }).click()
|
|
|
|
|
await page.getByRole('button', { name: 'Revoke execution grant' }).click()
|
|
|
|
|
await page.getByRole('button', { name: 'Confirm' }).click()
|
|
|
|
|
await page.getByRole('button', { name: 'Hard delete' }).click()
|
|
|
|
|
await expect(page.getByRole('alertdialog')).toContainText('irreversible')
|
|
|
|
|
await page.getByRole('button', { name: 'Confirm' }).click()
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByRole('list', { name: 'Deletion operations' }).getByText('queued'),
|
|
|
|
|
).toBeVisible()
|
|
|
|
|
|
|
|
|
|
expect(state.mutations).toEqual([
|
|
|
|
|
'soft-delete',
|
|
|
|
|
'restore',
|
|
|
|
|
'revoke',
|
|
|
|
|
'hard-delete',
|
|
|
|
|
])
|
|
|
|
|
expect(new Set(state.idempotencyKeys).size).toBe(state.idempotencyKeys.length)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function authenticatedSession(page: Page): Promise<void> {
|
2026-07-19 18:21:17 +09:00
|
|
|
await page.route('**/auth/session', (route) =>
|
|
|
|
|
json(route, {
|
|
|
|
|
kind: 'authenticated',
|
|
|
|
|
identity: 'issuer\u0000subject-0001',
|
|
|
|
|
csrfToken: 'csrf-value',
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-07-18 01:49:00 +09:00
|
|
|
await page.goto('/')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function mockApi(page: Page): Promise<{
|
|
|
|
|
submission: MockSubmission | null
|
|
|
|
|
mutations: string[]
|
|
|
|
|
idempotencyKeys: string[]
|
|
|
|
|
paths: string[]
|
|
|
|
|
includeDeletedRequests: number
|
|
|
|
|
}> {
|
|
|
|
|
const state: {
|
|
|
|
|
submission: MockSubmission | null
|
|
|
|
|
mutations: string[]
|
|
|
|
|
idempotencyKeys: string[]
|
|
|
|
|
paths: string[]
|
|
|
|
|
includeDeletedRequests: number
|
|
|
|
|
} = {
|
|
|
|
|
submission: null,
|
|
|
|
|
mutations: [],
|
|
|
|
|
idempotencyKeys: [],
|
|
|
|
|
paths: [],
|
|
|
|
|
includeDeletedRequests: 0,
|
|
|
|
|
}
|
|
|
|
|
let credential = sourceCredential('active')
|
|
|
|
|
let currentJob = job('Existing archive')
|
2026-07-19 18:21:17 +09:00
|
|
|
await page.route(/\/bff\/api\//, async (route) => {
|
2026-07-18 01:49:00 +09:00
|
|
|
const request = route.request()
|
|
|
|
|
const url = new URL(request.url())
|
2026-07-19 18:21:17 +09:00
|
|
|
const path = url.pathname.replace('/bff/api/', '')
|
2026-07-18 01:49:00 +09:00
|
|
|
state.paths.push(path)
|
|
|
|
|
if (
|
|
|
|
|
path === 'jobs' &&
|
|
|
|
|
request.method() === 'GET' &&
|
|
|
|
|
url.searchParams.get('include_deleted') === 'true'
|
|
|
|
|
)
|
|
|
|
|
state.includeDeletedRequests += 1
|
|
|
|
|
if (request.method() === 'POST') {
|
|
|
|
|
const key = request.headers()['idempotency-key']
|
|
|
|
|
if (path !== 'event-tickets' && key !== undefined)
|
|
|
|
|
state.idempotencyKeys.push(key)
|
|
|
|
|
}
|
|
|
|
|
if (path === 'jobs' && request.method() === 'GET')
|
|
|
|
|
return json(route, {
|
|
|
|
|
items: [currentJob],
|
|
|
|
|
next_cursor: null,
|
|
|
|
|
event_cursor: 'cursor-snapshot-0001',
|
|
|
|
|
management_capabilities: { revoke_execution_grants: true },
|
|
|
|
|
})
|
|
|
|
|
if (path === 'event-tickets')
|
|
|
|
|
return json(
|
|
|
|
|
route,
|
|
|
|
|
{
|
|
|
|
|
ticket: 'ticket_00001',
|
|
|
|
|
expires_at: '2026-07-18T00:01:00Z',
|
|
|
|
|
},
|
|
|
|
|
201,
|
|
|
|
|
)
|
|
|
|
|
if (path === 'source-credentials' && request.method() === 'GET')
|
|
|
|
|
return json(route, { items: [credential], next_cursor: null })
|
|
|
|
|
if (path === 'source-credentials' && request.method() === 'POST') {
|
|
|
|
|
const body = object(request.postDataJSON())
|
|
|
|
|
credential = {
|
|
|
|
|
...sourceCredential('active'),
|
|
|
|
|
label: String(body.label),
|
|
|
|
|
}
|
|
|
|
|
return json(route, credential, 201)
|
|
|
|
|
}
|
|
|
|
|
if (path.endsWith('/versions')) {
|
|
|
|
|
credential = {
|
|
|
|
|
...credential,
|
|
|
|
|
active_version: { id: 'version_0002', created_at: observedAt },
|
|
|
|
|
}
|
|
|
|
|
return json(route, credential, 201)
|
|
|
|
|
}
|
|
|
|
|
if (path.endsWith('/revoke') && path.startsWith('source-credentials/')) {
|
|
|
|
|
credential = { ...credential, status: 'revoked', active_version: null }
|
|
|
|
|
return json(route, credential)
|
|
|
|
|
}
|
|
|
|
|
if (path === 'jobs' && request.method() === 'POST') {
|
|
|
|
|
const body = object(request.postDataJSON())
|
|
|
|
|
const source = object(body.source)
|
|
|
|
|
state.submission = {
|
|
|
|
|
...(typeof body.label === 'string' ? { label: body.label } : {}),
|
|
|
|
|
source: {
|
|
|
|
|
...(typeof source.credential_id === 'string'
|
|
|
|
|
? { credential_id: source.credential_id }
|
|
|
|
|
: {}),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
currentJob = {
|
|
|
|
|
...job(state.submission.label ?? 'Submitted job'),
|
|
|
|
|
source: {
|
|
|
|
|
kind: 'http',
|
|
|
|
|
display_url: 'https://example.invalid/archive',
|
|
|
|
|
result_path: 'archive.bin',
|
|
|
|
|
...(state.submission.source.credential_id === undefined
|
|
|
|
|
? {}
|
|
|
|
|
: { credential_id: state.submission.source.credential_id }),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
return json(route, currentJob, 202)
|
|
|
|
|
}
|
|
|
|
|
if (path.endsWith('/attempts'))
|
|
|
|
|
return json(route, {
|
|
|
|
|
items: [
|
|
|
|
|
{
|
|
|
|
|
id: 'attempt_0001',
|
|
|
|
|
fence: 1,
|
|
|
|
|
worker_incarnation: 'worker_00001',
|
|
|
|
|
state: 'AttemptCommitted',
|
|
|
|
|
progress: null,
|
|
|
|
|
result,
|
|
|
|
|
failure: null,
|
|
|
|
|
created_at: observedAt,
|
|
|
|
|
updated_at: observedAt,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
next_cursor: null,
|
|
|
|
|
})
|
|
|
|
|
if (path.endsWith('/result')) return json(route, result)
|
|
|
|
|
if (path.endsWith('/result/members'))
|
|
|
|
|
return json(route, {
|
|
|
|
|
items: [
|
|
|
|
|
{
|
|
|
|
|
id: 'member_00001',
|
|
|
|
|
path: 'archive.bin',
|
|
|
|
|
size: 12,
|
|
|
|
|
object: result.manifest,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
next_cursor: null,
|
|
|
|
|
})
|
|
|
|
|
for (const operation of ['soft-delete', 'restore', 'revoke']) {
|
|
|
|
|
if (path.endsWith(`/${operation}`)) {
|
|
|
|
|
state.mutations.push(operation)
|
|
|
|
|
currentJob = {
|
|
|
|
|
...currentJob,
|
|
|
|
|
revision: currentJob.revision + 1,
|
|
|
|
|
lifecycle:
|
|
|
|
|
operation === 'soft-delete'
|
|
|
|
|
? 'soft_deleted'
|
|
|
|
|
: operation === 'restore'
|
|
|
|
|
? 'visible'
|
|
|
|
|
: currentJob.lifecycle,
|
|
|
|
|
}
|
|
|
|
|
return json(route, currentJob, operation === 'revoke' ? 202 : 200)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (path.endsWith('/hard-delete')) {
|
|
|
|
|
state.mutations.push('hard-delete')
|
|
|
|
|
return json(
|
|
|
|
|
route,
|
|
|
|
|
{
|
|
|
|
|
id: 'deletion_001',
|
|
|
|
|
job_id: currentJob.id,
|
|
|
|
|
state: 'queued',
|
|
|
|
|
problem: null,
|
|
|
|
|
created_at: observedAt,
|
|
|
|
|
updated_at: observedAt,
|
|
|
|
|
},
|
|
|
|
|
202,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return route.fulfill({ status: 404 })
|
|
|
|
|
})
|
|
|
|
|
return state
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function job(label: string): MockJob {
|
|
|
|
|
return {
|
|
|
|
|
resource_kind: 'job',
|
|
|
|
|
id: 'job_00000001',
|
|
|
|
|
owner: 'owner_000001',
|
|
|
|
|
label,
|
|
|
|
|
revision: 1,
|
|
|
|
|
phase: 'Succeeded',
|
|
|
|
|
lifecycle: 'visible',
|
|
|
|
|
source: {
|
|
|
|
|
kind: 'http',
|
|
|
|
|
display_url: 'https://example.invalid/archive',
|
|
|
|
|
result_path: 'archive.bin',
|
|
|
|
|
},
|
|
|
|
|
target: {
|
|
|
|
|
store: 'target-main',
|
|
|
|
|
bucket: 'fixture-bucket',
|
|
|
|
|
prefix: 'results',
|
|
|
|
|
},
|
|
|
|
|
authoritative_attempt: null,
|
|
|
|
|
progress: null,
|
|
|
|
|
checkpoint: null,
|
|
|
|
|
result,
|
|
|
|
|
failure: null,
|
|
|
|
|
created_at: observedAt,
|
|
|
|
|
updated_at: observedAt,
|
|
|
|
|
event_cursor: 'cursor-snapshot-0001',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sourceCredential(status: MockCredential['status']): MockCredential {
|
|
|
|
|
return {
|
|
|
|
|
id: 'credential_001',
|
|
|
|
|
owner: 'owner_000001',
|
|
|
|
|
label: 'Fixture credential',
|
|
|
|
|
kind: 'bearer_token',
|
|
|
|
|
status,
|
|
|
|
|
active_version:
|
|
|
|
|
status === 'active'
|
|
|
|
|
? { id: 'version_0001', created_at: observedAt }
|
|
|
|
|
: null,
|
|
|
|
|
created_at: observedAt,
|
|
|
|
|
updated_at: observedAt,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function object(value: unknown): Record<string, unknown> {
|
|
|
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
|
|
|
throw new Error('Expected object request')
|
|
|
|
|
return Object.fromEntries(Object.entries(value))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MockSubmission {
|
|
|
|
|
label?: string
|
|
|
|
|
source: { credential_id?: string }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MockResult {
|
|
|
|
|
kind: 'manifest'
|
|
|
|
|
manifest_id: string
|
|
|
|
|
schema_version: number
|
|
|
|
|
manifest: {
|
|
|
|
|
store: string
|
|
|
|
|
bucket: string
|
|
|
|
|
key: string
|
|
|
|
|
checksum: string
|
|
|
|
|
size: number
|
|
|
|
|
}
|
|
|
|
|
member_count: number
|
|
|
|
|
total_bytes: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MockJob {
|
|
|
|
|
resource_kind: 'job'
|
|
|
|
|
id: string
|
|
|
|
|
owner: string
|
|
|
|
|
label: string
|
|
|
|
|
revision: number
|
|
|
|
|
phase: 'Succeeded'
|
|
|
|
|
lifecycle: 'visible' | 'soft_deleted'
|
|
|
|
|
source: {
|
|
|
|
|
kind: 'http'
|
|
|
|
|
display_url: string
|
|
|
|
|
result_path: string
|
|
|
|
|
credential_id?: string
|
|
|
|
|
}
|
|
|
|
|
target: { store: string; bucket: string; prefix: string }
|
|
|
|
|
authoritative_attempt: null
|
|
|
|
|
progress: null
|
|
|
|
|
checkpoint: null
|
|
|
|
|
result: MockResult
|
|
|
|
|
failure: null
|
|
|
|
|
created_at: string
|
|
|
|
|
updated_at: string
|
|
|
|
|
event_cursor: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MockCredential {
|
|
|
|
|
id: string
|
|
|
|
|
owner: string
|
|
|
|
|
label: string
|
|
|
|
|
kind: 'bearer_token'
|
|
|
|
|
status: 'active' | 'revoked'
|
|
|
|
|
active_version: { id: string; created_at: string } | null
|
|
|
|
|
created_at: string
|
|
|
|
|
updated_at: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function json(route: Route, body: unknown, status = 200): Promise<void> {
|
|
|
|
|
return route.fulfill({
|
|
|
|
|
status,
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
})
|
|
|
|
|
}
|