Add complete system qualification evidence

This commit is contained in:
ms2k 2026-07-18 14:09:04 +09:00
commit 6952767503
8 changed files with 1256 additions and 84 deletions

View file

@ -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",

View 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
View file

@ -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

View file

@ -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, {

View 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
}

View 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')

View file

@ -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"]
}

View file

@ -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"]
}