180 lines
5.9 KiB
TypeScript
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
|
|
}
|