Add complete system qualification evidence
This commit is contained in:
parent
775dd61963
commit
6952767503
1 changed files with 1256 additions and 84 deletions
158
web/tests/system/deployed.spec.ts
Normal file
158
web/tests/system/deployed.spec.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { createPrivateKey, sign } from 'node:crypto'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
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')
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
({ accessToken, authority, profile }) => {
|
||||
sessionStorage.setItem(
|
||||
`oidc.user:${authority}:remora-web`,
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
scope: 'openid profile',
|
||||
profile,
|
||||
expires_at: Number(profile.exp),
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ accessToken: token, authority: issuer, profile: claims },
|
||||
)
|
||||
await page.reload()
|
||||
|
||||
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(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 === '/v1/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
|
||||
}
|
||||
Loading…
Reference in a new issue