remora/web/tests/system/deployed.spec.ts

180 lines
5.9 KiB
TypeScript

import { createPrivateKey, randomBytes, sign } from 'node:crypto'
import { expect, test } from '@playwright/test'
import { createClient } from 'redis'
test('authenticates and completes a deployed job lifecycle through ingress', async ({
page,
}) => {
const token = await mintZitadelToken()
const claims = jwtClaims(token)
const issuer = requiredEnv('REMORA_OIDC_ISSUER')
const sessionId = randomBytes(32).toString('base64url')
const csrfToken = randomBytes(24).toString('base64url')
const coordination = createClient({
url: requiredEnv('REMORA_COORDINATION_URL'),
})
await coordination.connect()
await coordination.set(
`${requiredEnv('REMORA_COORDINATION_NAMESPACE')}web-session:${sessionId}`,
JSON.stringify({
kind: 'authenticated',
identity: `${issuer}\u0000${stringValue(claims.sub, 'subject')}`,
csrfToken,
accessToken: token,
refreshToken: null,
idToken: 'qualification-session',
accessExpiresAt: Number(claims.exp),
}),
{ EX: 3600 },
)
await coordination.quit()
await page.context().addCookies([
{
name: 'remora-qualification-session',
value: sessionId,
url: requiredEnv('REMORA_SYSTEM_BASE_URL'),
sameSite: 'Strict',
},
])
const assetResponse = page.waitForResponse((response) => {
const path = new URL(response.url()).pathname
return path.startsWith('/assets/') && path.endsWith('.js')
})
await page.goto('/')
expect(['br', 'gzip']).toContain(
(await assetResponse).headers()['content-encoding'],
)
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', {
timeout: 30_000,
})
await expect(page.getByText('deployment-coordination-rebuild')).toBeVisible()
await page.getByRole('button', { name: 'Submit job' }).click()
await page.getByLabel('Label').fill('Browser system qualification')
await page.getByLabel('URL').fill('https://example.com/system.bin')
await page.getByLabel('Result path').fill('system.bin')
const submitted = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === '/bff/api/jobs',
)
await page.getByRole('button', { name: 'Submit job now' }).click()
const response = await submitted
if (!response.ok())
throw new Error(
`job submission failed: ${response.status()} ${await response.text()}`,
)
await expect(page.getByText('Job accepted')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Browser system qualification' }),
).toBeVisible()
await page.getByRole('button', { name: 'Cancel job' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(page.getByText('Cancellation requested')).toBeVisible()
})
async function mintZitadelToken(): Promise<string> {
const issuer = requiredEnv('REMORA_OIDC_ISSUER').replace(/\/$/, '')
const tokenUrl = `${issuer}/oauth/v2/token`
const key = jsonObject(
Buffer.from(
requiredEnv('REMORA_OIDC_USER_KEY_B64'),
'base64url',
).toString(),
)
const subject = stringValue(key.userId ?? key.userID, 'user id')
const keyId = stringValue(key.keyId ?? key.key_id, 'key id')
const privateKey = stringValue(key.key, 'private key').replaceAll(
String.raw`\n`,
'\n',
)
const projectId = requiredEnv('REMORA_OIDC_PROJECT_ID')
const scope = [
'openid',
'profile',
`urn:zitadel:iam:org:project:id:${projectId}:aud`,
'urn:zitadel:iam:org:project:roles',
'urn:zitadel:iam:org:projects:roles',
`urn:zitadel:iam:org:project:${projectId}:roles`,
].join(' ')
let failure = ''
for (const audience of [tokenUrl, issuer]) {
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: assertion(subject, keyId, audience, privateKey),
scope,
}),
})
if (response.ok) {
const body = jsonObject(await response.text())
return stringValue(body.access_token, 'access token')
}
failure = `${response.status} ${await response.text()}`
}
throw new Error(`token endpoint rejected assertion: ${failure}`)
}
function assertion(
subject: string,
keyId: string,
audience: string,
privateKey: string,
): string {
const now = Math.floor(Date.now() / 1000)
const header = encode({ alg: 'RS256', kid: keyId, typ: 'JWT' })
const payload = encode({
iss: subject,
sub: subject,
aud: audience,
iat: now,
exp: now + 3600,
})
const unsigned = `${header}.${payload}`
const signature = sign(
'RSA-SHA256',
Buffer.from(unsigned),
createPrivateKey(privateKey),
).toString('base64url')
return `${unsigned}.${signature}`
}
function jwtClaims(token: string): Record<string, unknown> {
const payload = token.split('.')[1]
if (payload === undefined) throw new Error('access token is not a JWT')
return jsonObject(Buffer.from(payload, 'base64url').toString())
}
function encode(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}
function stringValue(value: unknown, name: string): string {
if (typeof value !== 'string' || value === '')
throw new Error(`missing ${name}`)
return value
}
function jsonObject(source: string): Record<string, unknown> {
const value: unknown = JSON.parse(source)
if (!isJsonObject(value)) throw new Error('expected a JSON object')
return value
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function requiredEnv(name: string): string {
const value = process.env[name]
if (value === undefined || value === '')
throw new Error(`${name} is required`)
return value
}