Add complete system qualification evidence
This commit is contained in:
parent
775dd61963
commit
6952767503
8 changed files with 1256 additions and 84 deletions
|
|
@ -30,6 +30,7 @@
|
|||
"@playwright/test": "1.61.1",
|
||||
"@redocly/cli": "2.33.1",
|
||||
"@sveltejs/vite-plugin-svelte": "7.2.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"oxlint": "1.74.0",
|
||||
"oxlint-tsgolint": "0.24.0",
|
||||
|
|
|
|||
30
web/playwright.system.config.ts
Normal file
30
web/playwright.system.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/system',
|
||||
fullyParallel: true,
|
||||
reporter: 'line',
|
||||
use: {
|
||||
baseURL: process.env.REMORA_SYSTEM_BASE_URL ?? 'http://127.0.0.1:4174',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'node tests/system/proxy.mjs',
|
||||
url: process.env.REMORA_SYSTEM_BASE_URL ?? 'http://127.0.0.1:4174',
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: devices['Desktop Chrome'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: devices['Desktop Firefox'],
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: devices['Desktop Safari'],
|
||||
},
|
||||
],
|
||||
})
|
||||
71
web/pnpm-lock.yaml
generated
71
web/pnpm-lock.yaml
generated
|
|
@ -22,10 +22,13 @@ importers:
|
|||
version: 2.33.1(@opentelemetry/[email protected])([email protected])
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: 7.2.0
|
||||
version: 7.2.0([email protected](@typescript-eslint/[email protected]))([email protected])
|
||||
version: 7.2.0([email protected](@typescript-eslint/[email protected]))([email protected](@types/[email protected]))
|
||||
'@types/node':
|
||||
specifier: 26.1.1
|
||||
version: 26.1.1
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10([email protected])([email protected])([email protected])
|
||||
version: 4.1.10([email protected])([email protected](@types/[email protected]))([email protected])
|
||||
oxlint:
|
||||
specifier: 1.74.0
|
||||
version: 1.74.0([email protected])
|
||||
|
|
@ -43,10 +46,10 @@ importers:
|
|||
version: 7.0.2
|
||||
vite:
|
||||
specifier: 8.1.4
|
||||
version: 8.1.4
|
||||
version: 8.1.4(@types/[email protected])
|
||||
vitest:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@opentelemetry/[email protected])(@vitest/[email protected])([email protected])
|
||||
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))
|
||||
vitest-browser-svelte:
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0([email protected](@typescript-eslint/[email protected]))([email protected])
|
||||
|
|
@ -827,6 +830,12 @@ packages:
|
|||
integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==,
|
||||
}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==,
|
||||
}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -2492,6 +2501,12 @@ packages:
|
|||
}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==,
|
||||
}
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3140,14 +3155,14 @@ snapshots:
|
|||
|
||||
'@sveltejs/[email protected]': {}
|
||||
|
||||
'@sveltejs/[email protected]([email protected](@typescript-eslint/[email protected]))([email protected])':
|
||||
'@sveltejs/[email protected]([email protected](@typescript-eslint/[email protected]))([email protected](@types/[email protected]))':
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
svelte: 5.56.5(@typescript-eslint/[email protected])
|
||||
vite: 8.1.4
|
||||
vitefu: 1.1.3([email protected])
|
||||
vite: 8.1.4(@types/[email protected])
|
||||
vitefu: 1.1.3([email protected](@types/[email protected]))
|
||||
|
||||
'@testing-library/[email protected]([email protected](@typescript-eslint/[email protected]))':
|
||||
dependencies:
|
||||
|
|
@ -3169,6 +3184,10 @@ snapshots:
|
|||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@typescript-eslint/[email protected]':
|
||||
|
|
@ -3234,29 +3253,29 @@ snapshots:
|
|||
'@typescript/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected]([email protected])([email protected])([email protected])':
|
||||
'@vitest/[email protected]([email protected])([email protected](@types/[email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.1.10([email protected])([email protected])
|
||||
'@vitest/mocker': 4.1.10([email protected])
|
||||
'@vitest/browser': 4.1.10([email protected](@types/[email protected]))([email protected])
|
||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected]))
|
||||
playwright: 1.61.1
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.10(@opentelemetry/[email protected])(@vitest/[email protected])([email protected])
|
||||
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/[email protected]([email protected])([email protected])':
|
||||
'@vitest/[email protected]([email protected](@types/[email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@blazediff/core': 1.9.1
|
||||
'@vitest/mocker': 4.1.10([email protected])
|
||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected]))
|
||||
'@vitest/utils': 4.1.10
|
||||
magic-string: 0.30.21
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.10(@opentelemetry/[email protected])(@vitest/[email protected])([email protected])
|
||||
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
|
|
@ -3273,13 +3292,13 @@ snapshots:
|
|||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/[email protected]([email protected])':
|
||||
'@vitest/[email protected]([email protected](@types/[email protected]))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.1.4
|
||||
vite: 8.1.4(@types/[email protected])
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
|
|
@ -4081,6 +4100,8 @@ snapshots:
|
|||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
|
@ -4093,7 +4114,7 @@ snapshots:
|
|||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
[email protected](@types/[email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.5
|
||||
|
|
@ -4101,22 +4122,23 @@ snapshots:
|
|||
rolldown: 1.1.5
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.1.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]([email protected]):
|
||||
[email protected]([email protected](@types/[email protected])):
|
||||
optionalDependencies:
|
||||
vite: 8.1.4
|
||||
vite: 8.1.4(@types/[email protected])
|
||||
|
||||
[email protected]([email protected](@typescript-eslint/[email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@testing-library/svelte-core': 1.1.3([email protected](@typescript-eslint/[email protected]))
|
||||
svelte: 5.56.5(@typescript-eslint/[email protected])
|
||||
vitest: 4.1.10(@opentelemetry/[email protected])(@vitest/[email protected])([email protected])
|
||||
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))
|
||||
|
||||
[email protected](@opentelemetry/[email protected])(@vitest/[email protected])([email protected]):
|
||||
[email protected](@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10([email protected])
|
||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected]))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
|
|
@ -4133,11 +4155,12 @@ snapshots:
|
|||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.1.4
|
||||
vite: 8.1.4(@types/[email protected])
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@vitest/browser-playwright': 4.1.10([email protected])([email protected])([email protected])
|
||||
'@types/node': 26.1.1
|
||||
'@vitest/browser-playwright': 4.1.10([email protected])([email protected](@types/[email protected]))([email protected])
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,26 @@ test('submits an authorized HTTP job and retains its idempotency key on retry',
|
|||
})
|
||||
})
|
||||
|
||||
test('renders job status progress and failure', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
fixture.api.snapshot = job(2, {
|
||||
label: 'Failed archive',
|
||||
phase: 'Failed',
|
||||
failure: {
|
||||
class: 'internal',
|
||||
message: 'Structured failure',
|
||||
retryable: true,
|
||||
},
|
||||
})
|
||||
const screen = await render(App, { runtime: fixture.runtime })
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await screen.getByRole('button', { name: /Failed archive.*Failed/ }).click()
|
||||
await expect
|
||||
.element(screen.getByText('10 / 100 bytes; 0 members'))
|
||||
.toBeVisible()
|
||||
await expect.element(screen.getByText('Structured failure')).toBeVisible()
|
||||
})
|
||||
|
||||
test('covers history, result access, lifecycle confirmations, and admin revocation', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
fixture.api.snapshot = job(3, {
|
||||
|
|
|
|||
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
|
||||
}
|
||||
46
web/tests/system/proxy.mjs
Normal file
46
web/tests/system/proxy.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
|
||||
const upstreamHost = 'qualification-ingress'
|
||||
const upstreamPort = 80
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const upstream = http.request(
|
||||
{
|
||||
host: upstreamHost,
|
||||
port: upstreamPort,
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
headers: { ...request.headers, host: upstreamHost },
|
||||
},
|
||||
(upstreamResponse) => {
|
||||
response.writeHead(
|
||||
upstreamResponse.statusCode ?? 502,
|
||||
upstreamResponse.headers,
|
||||
)
|
||||
upstreamResponse.pipe(response)
|
||||
},
|
||||
)
|
||||
upstream.on('error', () => {
|
||||
response.writeHead(502)
|
||||
response.end()
|
||||
})
|
||||
request.pipe(upstream)
|
||||
})
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const upstream = net.connect(upstreamPort, upstreamHost, () => {
|
||||
const headers = Object.entries(request.headers)
|
||||
.filter(([name]) => name.toLowerCase() !== 'host')
|
||||
.map(([name, value]) => `${name}: ${String(value)}`)
|
||||
.join('\r\n')
|
||||
upstream.write(
|
||||
`${request.method} ${request.url} HTTP/${request.httpVersion}\r\nHost: ${upstreamHost}\r\n${headers}\r\n\r\n`,
|
||||
)
|
||||
if (head.length > 0) upstream.write(head)
|
||||
socket.pipe(upstream).pipe(socket)
|
||||
})
|
||||
upstream.on('error', () => socket.destroy())
|
||||
})
|
||||
|
||||
server.listen(4174, '127.0.0.1')
|
||||
|
|
@ -6,7 +6,8 @@
|
|||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2024",
|
||||
"tsBuildInfoFile": "./.cache/tsconfig.node.tsbuildinfo"
|
||||
"tsBuildInfoFile": "./.cache/tsconfig.node.tsbuildinfo",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.config.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./.cache/tsconfig.test.tsbuildinfo"
|
||||
"tsBuildInfoFile": "./.cache/tsconfig.test.tsbuildinfo",
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue