remora/web/tests/e2e/workflows.spec.ts

422 lines
13 KiB
TypeScript
Raw Normal View History

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 }) => {
await page.routeWebSocket(/\/v1\/events/, () => {})
})
test('completes credential, submission, result, history, and lifecycle workflows', async ({
page,
}) => {
const state = await mockApi(page)
await authenticatedSession(page)
await expect
.poll(() =>
page.evaluate(() =>
sessionStorage.getItem(
`oidc.user:${window.location.origin}/oidc:remora-web`,
),
),
)
.not.toBeNull()
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('')
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()
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')
await page.getByRole('button', { name: /Imported archive/ }).click()
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()
await page.getByRole('button', { name: 'Credentials' }).click()
await page.getByRole('button', { name: /Archive token/ }).click()
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([
'soft-delete',
'restore',
'revoke',
'hard-delete',
])
expect(new Set(state.idempotencyKeys).size).toBe(state.idempotencyKeys.length)
})
async function authenticatedSession(page: Page): Promise<void> {
await page.goto('/')
await page.evaluate(() => {
const authority = `${window.location.origin}/oidc`
sessionStorage.setItem(
`oidc.user:${authority}:remora-web`,
JSON.stringify({
id_token: 'id-token',
access_token: 'access-token',
token_type: 'Bearer',
scope: 'openid profile',
profile: { iss: authority, sub: 'subject-0001' },
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
)
})
await page.reload()
}
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')
await page.route(/\/v1\//, async (route) => {
const request = route.request()
const url = new URL(request.url())
const path = url.pathname.replace('/v1/', '')
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),
})
}