Implement authorization-fenced web workflows
This commit is contained in:
parent
459ffa04c6
commit
267665bb35
1 changed files with 3853 additions and 156 deletions
422
web/tests/e2e/workflows.spec.ts
Normal file
422
web/tests/e2e/workflows.spec.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
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(/\/api\/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(/\/api\/v1\//, async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = url.pathname.replace('/api/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),
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue