diff --git a/packages/qa/src/index.ts b/packages/qa/src/index.ts index d846d61..e635da2 100644 --- a/packages/qa/src/index.ts +++ b/packages/qa/src/index.ts @@ -1,2 +1,19 @@ -/** Public entry point of the Release QA package. Real modules arrive with Stage 1. */ +/** Public entry point of the Release QA package. */ export const toolName = 'release-qa'; + +export { parseCandidate, type Artifact, type Candidate } from './model/candidate.ts'; +export { parseException, type Exception } from './model/exception.ts'; +export { parseProject, type EnvironmentProfile, type Project, type Suite } from './model/project.ts'; +export { parseRequirement, type ExecutionMode, type Requirement, type RequirementKey } from './model/requirement.ts'; +export { + parseReport, + parseUploadProvenance, + type Attempt, + type MeasuredEnvironment, + type Outcome, + type Readiness, + type ReferenceContext, + type Report, + type UploadProvenance, +} from './model/result.ts'; +export { ValidationError, type IssueCode, type ParseResult, type ValidationIssue } from './model/validate.ts'; diff --git a/packages/qa/src/model/candidate.ts b/packages/qa/src/model/candidate.ts new file mode 100644 index 0000000..1cb58f0 --- /dev/null +++ b/packages/qa/src/model/candidate.ts @@ -0,0 +1,71 @@ +import { at, Collector, item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +export interface Artifact { + profile: string; + name: string; + sha256: string; + assetId: number; + actionsArtifactId: number; +} + +export interface Candidate { + schemaVersion: 1; + id: string; + repositoryId: number; + pullRequest: number; + sourceSha: string; + baseSha: string; + sourceTreeSha: string; + testRevision: string; + policyDigest: string; + build: { workflowPath: string; runId: number; attempt: number }; + artifacts: Artifact[]; +} + +const SPEC: FieldSpec = { + required: ['id', 'repositoryId', 'pullRequest', 'sourceSha', 'baseSha', 'sourceTreeSha', 'testRevision', 'policyDigest', 'build', 'artifacts'], +}; +const BUILD_SPEC: FieldSpec = { required: ['workflowPath', 'runId', 'attempt'] }; +const ARTIFACT_SPEC: FieldSpec = { required: ['profile', 'name', 'sha256', 'assetId', 'actionsArtifactId'] }; + +export function parseCandidate(input: unknown): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const build = c.record(rec.build, 'build', BUILD_SPEC); + const artifactValues = c.array(rec.artifacts, 'artifacts', { min: 1 }) ?? []; + const artifacts = artifactValues.map((value, i) => readArtifact(c, value, item('artifacts', i))); + + // An asset is one downloadable file; a profile cannot ship two files of the same name. + c.unique(artifacts.map((a, i) => ({ value: a?.assetId === undefined ? undefined : String(a.assetId), path: at(item('artifacts', i), 'assetId') }))); + c.unique(artifacts.map((a, i) => ({ value: a?.profile === undefined || a.name === undefined ? undefined : `${a.profile}/${a.name}`, path: at(item('artifacts', i), 'name') }))); + + return { + schemaVersion: 1, + id: c.id(rec.id, 'id'), + repositoryId: c.int(rec.repositoryId, 'repositoryId'), + pullRequest: c.int(rec.pullRequest, 'pullRequest'), + sourceSha: c.gitSha(rec.sourceSha, 'sourceSha'), + baseSha: c.gitSha(rec.baseSha, 'baseSha'), + sourceTreeSha: c.gitSha(rec.sourceTreeSha, 'sourceTreeSha'), + testRevision: c.gitSha(rec.testRevision, 'testRevision'), + policyDigest: c.sha256(rec.policyDigest, 'policyDigest'), + build: build && { + workflowPath: c.relativePath(build.workflowPath, 'build.workflowPath'), + runId: c.int(build.runId, 'build.runId'), + attempt: c.int(build.attempt, 'build.attempt'), + }, + artifacts, + } as Candidate; + }); +} + +function readArtifact(c: Collector, value: unknown, path: string): Artifact | undefined { + const rec = c.record(value, path, ARTIFACT_SPEC); + if (rec === undefined) return undefined; + return { + profile: c.profileId(rec.profile, at(path, 'profile')), + name: c.fileName(rec.name, at(path, 'name')), + sha256: c.sha256(rec.sha256, at(path, 'sha256')), + assetId: c.int(rec.assetId, at(path, 'assetId')), + actionsArtifactId: c.int(rec.actionsArtifactId, at(path, 'actionsArtifactId')), + } as Artifact; +} diff --git a/packages/qa/src/model/context.ts b/packages/qa/src/model/context.ts new file mode 100644 index 0000000..3231bff --- /dev/null +++ b/packages/qa/src/model/context.ts @@ -0,0 +1,24 @@ +import type { Candidate } from './candidate.ts'; +import type { RequirementKey } from './requirement.ts'; +import type { Collector } from './validate.ts'; + +/** What a record is allowed to refer to. Parsing a record alone cannot know this, so callers supply it. */ +export interface ReferenceContext { + /** When given, the record must belong to this candidate. */ + candidate?: Candidate; + /** When given, every requirement the record names must be one of these. */ + requirements?: readonly RequirementKey[]; +} + +/** A record that names a candidate must name the one it is being checked against. */ +export function checkCandidateId(c: Collector, context: ReferenceContext, candidateId: string | undefined, path: string): void { + if (context.candidate !== undefined && candidateId !== undefined && candidateId !== context.candidate.id) { + c.add('unknown-reference', path, `belongs to candidate "${candidateId}", not "${context.candidate.id}"`); + } +} + +export function checkRequirementKnown(c: Collector, context: ReferenceContext, key: RequirementKey | undefined, path: string): void { + if (context.requirements !== undefined && key !== undefined && !context.requirements.includes(key)) { + c.add('unknown-reference', path, `"${key}" is not a required check`); + } +} diff --git a/packages/qa/src/model/exception.ts b/packages/qa/src/model/exception.ts new file mode 100644 index 0000000..b8961e2 --- /dev/null +++ b/packages/qa/src/model/exception.ts @@ -0,0 +1,39 @@ +import { checkCandidateId, checkRequirementKnown, type ReferenceContext } from './context.ts'; +import type { RequirementKey } from './requirement.ts'; +import { item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +/** A request to accept named requirements as unmet for one candidate. It carries no authority by itself. */ +export interface Exception { + schemaVersion: 1; + id: string; + candidateId: string; + requirements: RequirementKey[]; + reason: string; + /** Claimed by the uploader; the maintainer's authority is verified separately. */ + actor: string; + /** ISO-8601 UTC, e.g. `2026-09-20T12:00:00Z`. */ + createdAt: string; +} + +const SPEC: FieldSpec = { required: ['id', 'candidateId', 'requirements', 'reason', 'actor', 'createdAt'] }; + +export function parseException(input: unknown, context: ReferenceContext = {}): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const candidateId = c.id(rec.candidateId, 'candidateId'); + checkCandidateId(c, context, candidateId, 'candidateId'); + + const requirements = (c.array(rec.requirements, 'requirements', { min: 1 }) ?? []).map((v, i) => c.requirementKey(v, item('requirements', i))); + c.unique(requirements.map((key, i) => ({ value: key, path: item('requirements', i) }))); + requirements.forEach((key, i) => checkRequirementKnown(c, context, key, item('requirements', i))); + + return { + schemaVersion: 1, + id: c.id(rec.id, 'id'), + candidateId, + requirements, + reason: c.text(rec.reason, 'reason', { max: 2000, multiline: true }), + actor: c.text(rec.actor, 'actor'), + createdAt: c.timestamp(rec.createdAt, 'createdAt'), + } as Exception; + }); +} diff --git a/packages/qa/src/model/project.ts b/packages/qa/src/model/project.ts new file mode 100644 index 0000000..deede2f --- /dev/null +++ b/packages/qa/src/model/project.ts @@ -0,0 +1,105 @@ +import { profileOf, readRequirement, type Requirement, type RequirementKey } from './requirement.ts'; +import { at, Collector, item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +export interface EnvironmentProfile { + id: string; + os: 'windows' | 'linux'; + arch: 'x86_64'; +} + +export interface Suite { + id: string; + requirements: RequirementKey[]; +} + +/** The consumer's `qa/project.json`. Read as data only; discovery never executes project code. */ +export interface Project { + schemaVersion: 1; + projectId: string; + releaseBranch: string; + profiles: EnvironmentProfile[]; + requirements: Requirement[]; + suites: Suite[]; + scenarioFiles: string[]; + lifecycleModule: string; + workflows: { prepare: string; gate: string; publish: string }; + markers: { releaseNotes: string; qa: string }; +} + +const SPEC: FieldSpec = { + required: ['projectId', 'releaseBranch', 'profiles', 'requirements', 'suites', 'scenarioFiles', 'lifecycleModule', 'workflows', 'markers'], +}; +const PROFILE_SPEC: FieldSpec = { required: ['id', 'os', 'arch'] }; +const SUITE_SPEC: FieldSpec = { required: ['id', 'requirements'] }; +const WORKFLOWS_SPEC: FieldSpec = { required: ['prepare', 'gate', 'publish'] }; +const MARKERS_SPEC: FieldSpec = { required: ['releaseNotes', 'qa'] }; + +export function parseProject(input: unknown): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const profiles = (c.array(rec.profiles, 'profiles', { min: 1 }) ?? []).map((v, i) => readProfile(c, v, item('profiles', i))); + c.unique(profiles.map((p, i) => ({ value: p?.id, path: at(item('profiles', i), 'id') }))); + const profileIds = new Set(profiles.flatMap((p) => (p?.id === undefined ? [] : [p.id]))); + + const requirements = (c.array(rec.requirements, 'requirements', { min: 1 }) ?? []).map((v, i) => readRequirement(c, v, item('requirements', i))); + c.unique(requirements.map((r, i) => ({ value: r?.key, path: at(item('requirements', i), 'key') }))); + requirements.forEach((r, i) => { + if (r?.key !== undefined && !profileIds.has(profileOf(r.key))) { + c.add('unknown-reference', at(item('requirements', i), 'key'), `profile "${profileOf(r.key)}" is not defined by this project`); + } + }); + const definedKeys = new Set(requirements.flatMap((r) => (r?.key === undefined ? [] : [r.key]))); + + const suites = (c.array(rec.suites, 'suites') ?? []).map((v, i) => readSuite(c, v, item('suites', i), definedKeys)); + c.unique(suites.map((s, i) => ({ value: s?.id, path: at(item('suites', i), 'id') }))); + + const scenarioFiles = (c.array(rec.scenarioFiles, 'scenarioFiles') ?? []).map((v, i) => c.relativePath(v, item('scenarioFiles', i))); + + const workflows = c.record(rec.workflows, 'workflows', WORKFLOWS_SPEC); + const markers = c.record(rec.markers, 'markers', MARKERS_SPEC); + const releaseNotesMarker = markers && c.name(markers.releaseNotes, 'markers.releaseNotes'); + const qaMarker = markers && c.name(markers.qa, 'markers.qa'); + c.unique([ + { value: releaseNotesMarker, path: 'markers.releaseNotes' }, + { value: qaMarker, path: 'markers.qa' }, + ]); + + return { + schemaVersion: 1, + projectId: c.id(rec.projectId, 'projectId'), + releaseBranch: c.branchName(rec.releaseBranch, 'releaseBranch'), + profiles, + requirements, + suites, + scenarioFiles, + lifecycleModule: c.relativePath(rec.lifecycleModule, 'lifecycleModule'), + workflows: workflows && { + prepare: c.fileName(workflows.prepare, 'workflows.prepare'), + gate: c.fileName(workflows.gate, 'workflows.gate'), + publish: c.fileName(workflows.publish, 'workflows.publish'), + }, + markers: markers && { releaseNotes: releaseNotesMarker, qa: qaMarker }, + } as Project; + }); +} + +function readProfile(c: Collector, value: unknown, path: string): EnvironmentProfile | undefined { + const rec = c.record(value, path, PROFILE_SPEC); + if (rec === undefined) return undefined; + return { + id: c.profileId(rec.id, at(path, 'id')), + os: c.oneOf(rec.os, at(path, 'os'), ['windows', 'linux']), + arch: c.oneOf(rec.arch, at(path, 'arch'), ['x86_64']), + } as EnvironmentProfile; +} + +function readSuite(c: Collector, value: unknown, path: string, definedKeys: ReadonlySet): Suite | undefined { + const rec = c.record(value, path, SUITE_SPEC); + if (rec === undefined) return undefined; + const listPath = at(path, 'requirements'); + const keys = (c.array(rec.requirements, listPath, { min: 1 }) ?? []).map((v, i) => c.requirementKey(v, item(listPath, i))); + c.unique(keys.map((k, i) => ({ value: k, path: item(listPath, i) }))); + keys.forEach((k, i) => { + if (k !== undefined && !definedKeys.has(k)) c.add('unknown-reference', item(listPath, i), `"${k}" is not a requirement of this project`); + }); + return { id: c.id(rec.id, at(path, 'id')), requirements: keys } as Suite; +} diff --git a/packages/qa/src/model/requirement.ts b/packages/qa/src/model/requirement.ts new file mode 100644 index 0000000..252a558 --- /dev/null +++ b/packages/qa/src/model/requirement.ts @@ -0,0 +1,38 @@ +import { at, Collector, item, parseUnversioned, type FieldSpec, type ParseResult } from './validate.ts'; + +/** `/`, e.g. `windows/persistence`. */ +export type RequirementKey = `${string}/${string}`; +export type ExecutionMode = 'automated' | 'manual'; + +export interface Requirement { + key: RequirementKey; + mode: ExecutionMode; + title: string; + /** Capabilities the environment must provide, e.g. `display`, `audio`, `hardware`. */ + capabilities: string[]; +} + +const SPEC: FieldSpec = { required: ['key', 'mode', 'title', 'capabilities'] }; + +/** The profile half of a requirement key. */ +export const profileOf = (key: RequirementKey): string => key.slice(0, key.indexOf('/')); + +/** Reads a requirement nested in another record, reporting problems under `path`. */ +export function readRequirement(c: Collector, value: unknown, path: string): Requirement | undefined { + const rec = c.record(value, path, SPEC); + return rec === undefined ? undefined : readRequirementFields(c, rec, path); +} + +function readRequirementFields(c: Collector, rec: Record, path: string): Requirement { + const capabilities = (c.array(rec.capabilities, at(path, 'capabilities')) ?? []).map((v, i) => c.name(v, item(at(path, 'capabilities'), i))); + return { + key: c.requirementKey(rec.key, at(path, 'key')), + mode: c.oneOf(rec.mode, at(path, 'mode'), ['automated', 'manual']), + title: c.text(rec.title, at(path, 'title')), + capabilities, + } as Requirement; +} + +export function parseRequirement(input: unknown): ParseResult { + return parseUnversioned(input, SPEC, (c, rec) => readRequirementFields(c, rec, '')); +} diff --git a/packages/qa/src/model/result.ts b/packages/qa/src/model/result.ts new file mode 100644 index 0000000..bd2df83 --- /dev/null +++ b/packages/qa/src/model/result.ts @@ -0,0 +1,145 @@ +import type { Candidate } from './candidate.ts'; +import { checkCandidateId, checkRequirementKnown, type ReferenceContext } from './context.ts'; +import { profileOf, type RequirementKey } from './requirement.ts'; +import { at, Collector, item, parseUnversioned, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +export type { ReferenceContext } from './context.ts'; + +export type Outcome = 'passed' | 'failed' | 'blocked' | 'cancelled' | 'interrupted'; +export type Readiness = 'blocked' | 'passed' | 'approved-with-exceptions'; + +export interface Attempt { + id: string; + requirement: RequirementKey; + outcome: Outcome; + /** The attempt this one retries. It may live in another report, so only self-reference is checked here. */ + retryOf?: string; + /** Relative paths of evidence files stored next to the report. */ + evidence: string[]; +} + +/** Facts the runner measured about the environment. Descriptive, never proof of authority. */ +export interface MeasuredEnvironment { + os: string; + osVersion: string; + arch: string; + capabilities: string[]; + toolVersion: string; +} + +export interface Report { + schemaVersion: 1; + id: string; + candidateId: string; + policyDigest: string; + testRevision: string; + profile: string; + /** Claimed by the uploader. Authority comes from {@link UploadProvenance}, never from this field. */ + actor: string; + machineId: string; + environment: MeasuredEnvironment; + attempts: Attempt[]; +} + +/** Recorded by the GitHub layer when it stores an upload; a report cannot attest to its own origin. */ +export interface UploadProvenance { + uploader: string; + uploadedAt: string; + assetId: number; +} + +const OUTCOMES = ['passed', 'failed', 'blocked', 'cancelled', 'interrupted'] as const; +const SPEC: FieldSpec = { + required: ['id', 'candidateId', 'policyDigest', 'testRevision', 'profile', 'actor', 'machineId', 'environment', 'attempts'], +}; +const ENVIRONMENT_SPEC: FieldSpec = { required: ['os', 'osVersion', 'arch', 'capabilities', 'toolVersion'] }; +const ATTEMPT_SPEC: FieldSpec = { required: ['id', 'requirement', 'outcome', 'evidence'], optional: ['retryOf'] }; +const PROVENANCE_SPEC: FieldSpec = { required: ['uploader', 'uploadedAt', 'assetId'] }; + +export function parseReport(input: unknown, context: ReferenceContext = {}): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const id = c.id(rec.id, 'id'); + const candidateId = c.id(rec.candidateId, 'candidateId'); + const policyDigest = c.sha256(rec.policyDigest, 'policyDigest'); + const testRevision = c.gitSha(rec.testRevision, 'testRevision'); + const profile = c.profileId(rec.profile, 'profile'); + const environment = readEnvironment(c, rec.environment, 'environment'); + + const attempts = (c.array(rec.attempts, 'attempts', { min: 1 }) ?? []).map((v, i) => readAttempt(c, v, item('attempts', i))); + c.unique(attempts.map((a, i) => ({ value: a?.id, path: at(item('attempts', i), 'id') }))); + attempts.forEach((a, i) => { + const path = at(item('attempts', i), 'requirement'); + if (a?.requirement !== undefined && profile !== undefined && profileOf(a.requirement) !== profile) { + c.add('mismatch', path, `requirement is for profile "${profileOf(a.requirement)}", the report is for "${profile}"`); + } + checkRequirementKnown(c, context, a?.requirement, path); + }); + + checkCandidateId(c, context, candidateId, 'candidateId'); + checkAgainstCandidate(c, context.candidate, { policyDigest, testRevision, profile }); + + return { + schemaVersion: 1, + id, + candidateId, + policyDigest, + testRevision, + profile, + actor: c.text(rec.actor, 'actor'), + machineId: c.text(rec.machineId, 'machineId'), + environment, + attempts, + } as Report; + }); +} + +function checkAgainstCandidate( + c: Collector, + candidate: Candidate | undefined, + report: { policyDigest: string | undefined; testRevision: string | undefined; profile: string | undefined }, +): void { + if (candidate === undefined) return; + if (report.policyDigest !== undefined && report.policyDigest !== candidate.policyDigest) c.add('mismatch', 'policyDigest', 'made against a different policy than the candidate'); + if (report.testRevision !== undefined && report.testRevision !== candidate.testRevision) c.add('mismatch', 'testRevision', 'made against different tests than the candidate'); + if (report.profile !== undefined && !candidate.artifacts.some((a) => a.profile === report.profile)) { + c.add('unknown-reference', 'profile', `the candidate has no artifact for profile "${report.profile}"`); + } +} + +function readEnvironment(c: Collector, value: unknown, path: string): MeasuredEnvironment | undefined { + const rec = c.record(value, path, ENVIRONMENT_SPEC); + if (rec === undefined) return undefined; + const capabilitiesPath = at(path, 'capabilities'); + return { + os: c.text(rec.os, at(path, 'os')), + osVersion: c.text(rec.osVersion, at(path, 'osVersion')), + arch: c.text(rec.arch, at(path, 'arch')), + capabilities: (c.array(rec.capabilities, capabilitiesPath) ?? []).map((v, i) => c.name(v, item(capabilitiesPath, i))), + toolVersion: c.text(rec.toolVersion, at(path, 'toolVersion')), + } as MeasuredEnvironment; +} + +function readAttempt(c: Collector, value: unknown, path: string): Attempt | undefined { + const rec = c.record(value, path, ATTEMPT_SPEC); + if (rec === undefined) return undefined; + const id = c.id(rec.id, at(path, 'id')); + const retryOf = rec.retryOf === undefined ? undefined : c.id(rec.retryOf, at(path, 'retryOf')); + if (retryOf !== undefined && retryOf === id) c.add('invalid-value', at(path, 'retryOf'), 'an attempt cannot be a retry of itself'); + const evidencePath = at(path, 'evidence'); + const attempt: Record = { + id, + requirement: c.requirementKey(rec.requirement, at(path, 'requirement')), + outcome: c.oneOf(rec.outcome, at(path, 'outcome'), OUTCOMES), + evidence: (c.array(rec.evidence, evidencePath) ?? []).map((v, i) => c.relativePath(v, item(evidencePath, i))), + }; + if (retryOf !== undefined) attempt.retryOf = retryOf; + return attempt as unknown as Attempt; +} + +export function parseUploadProvenance(input: unknown): ParseResult { + return parseUnversioned(input, PROVENANCE_SPEC, (c, rec) => ({ + uploader: c.text(rec.uploader, 'uploader'), + uploadedAt: c.timestamp(rec.uploadedAt, 'uploadedAt'), + assetId: c.int(rec.assetId, 'assetId'), + }) as UploadProvenance); +} diff --git a/packages/qa/src/model/validate.ts b/packages/qa/src/model/validate.ts new file mode 100644 index 0000000..778e5c0 --- /dev/null +++ b/packages/qa/src/model/validate.ts @@ -0,0 +1,279 @@ +/** Machine-readable reasons a record was rejected. The CLI and UI display these, so they are stable names. */ +export type IssueCode = + | 'invalid-type' + | 'missing-field' + | 'unknown-field' + | 'unknown-schema-version' + | 'empty' + | 'too-long' + | 'invalid-characters' + | 'invalid-value' + | 'out-of-range' + | 'malformed-id' + | 'malformed-key' + | 'malformed-hash' + | 'malformed-timestamp' + | 'unsafe-path' + | 'duplicate' + | 'unknown-reference' + | 'mismatch'; + +export interface ValidationIssue { + code: IssueCode; + /** Location of the offending value, e.g. `artifacts[1].sha256`; the empty string is the record itself. */ + path: string; + message: string; +} + +export class ValidationError extends Error { + readonly issues: readonly ValidationIssue[]; + constructor(issues: readonly ValidationIssue[]) { + super(`invalid record: ${issues.map((i) => `${i.path || ''}: ${i.code}`).join(', ') || 'no details'}`); + this.name = 'ValidationError'; + this.issues = issues; + } +} + +export type ParseResult = { ok: true; value: T } | { ok: false; error: ValidationError }; + +export const SCHEMA_VERSION = 1; + +export const at = (parent: string, key: string): string => (parent ? `${parent}.${key}` : key); +export const item = (parent: string, index: number): string => `${parent}[${index}]`; + +const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const PROFILE_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const NAME = /^[a-z][a-z0-9-]{0,63}$/; +const REQUIREMENT_KEY = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/; +const GIT_SHA = /^[0-9a-f]{40}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?Z$/; +const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; +// Characters Windows refuses in a path segment, plus both separators. Includes ":" (drive prefixes, NTFS streams). +const RESERVED_CHARACTERS = /[<>:"/\\|?*]/; + +// Control characters are found by character code so the source contains no raw control bytes. +const TAB = 9; +const LINE_FEED = 10; +const CARRIAGE_RETURN = 13; +function hasControlCharacter(s: string, allowTabsAndLineBreaks = false): boolean { + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code >= 0x20 && code !== 0x7f) continue; + if (allowTabsAndLineBreaks && (code === TAB || code === LINE_FEED || code === CARRIAGE_RETURN)) continue; + return true; + } + return false; +} + +/** One file or directory name as it must be safe on every supported OS. "." and ".." fail the trailing-dot rule. */ +function unsafeSegment(segment: string): boolean { + return ( + segment.length > 255 || + RESERVED_CHARACTERS.test(segment) || + hasControlCharacter(segment) || + /[. ]$/.test(segment) || + WINDOWS_DEVICE.test(segment.split('.')[0] ?? '') + ); +} + +export type FieldSpec = { required: readonly string[]; optional?: readonly string[] }; + +/** Collects every problem in a record instead of stopping at the first, and never throws on bad input. */ +export class Collector { + readonly issues: ValidationIssue[] = []; + + add(code: IssueCode, path: string, message: string): void { + this.issues.push({ code, path, message }); + } + + /** Checks that `value` is a plain object with exactly the allowed fields. */ + record(value: unknown, path: string, spec: FieldSpec): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + this.add('invalid-type', path, 'expected an object'); + return undefined; + } + const rec = value as Record; + const allowed = new Set([...spec.required, ...(spec.optional ?? [])]); + for (const key of Object.keys(rec)) if (!allowed.has(key)) this.add('unknown-field', at(path, key), `unknown field "${key}"`); + for (const key of spec.required) if (rec[key] === undefined) this.add('missing-field', at(path, key), `missing field "${key}"`); + return rec; + } + + /** Free text: non-empty after trimming, bounded, and without control characters. */ + text(value: unknown, path: string, options: { max?: number; multiline?: boolean } = {}): string | undefined { + const s = this.string(value, path); + if (s === undefined) return undefined; + if (s.trim() === '') return this.fail('empty', path, 'must not be empty'); + if ((options.max ?? 200) < s.length) return this.fail('too-long', path, `longer than ${options.max ?? 200} characters`); + if (hasControlCharacter(s, options.multiline === true)) return this.fail('invalid-characters', path, 'contains control characters'); + return s; + } + + id(value: unknown, path: string): string | undefined { + return this.matching(value, path, ID, 'malformed-id', 'expected letters, digits, ".", "_" or "-", starting with a letter or digit'); + } + + profileId(value: unknown, path: string): string | undefined { + return this.matching(value, path, PROFILE_ID, 'malformed-id', 'expected a lower-case profile id'); + } + + /** A short lower-case name such as a capability or marker. */ + name(value: unknown, path: string): string | undefined { + return this.matching(value, path, NAME, 'malformed-id', 'expected a lower-case name of letters, digits and "-"'); + } + + requirementKey(value: unknown, path: string): `${string}/${string}` | undefined { + if (typeof value !== 'string') return this.fail('invalid-type', path, 'expected a string'); + if (!REQUIREMENT_KEY.test(value)) return this.fail('malformed-key', path, 'expected "/"'); + return value as `${string}/${string}`; + } + + gitSha(value: unknown, path: string): string | undefined { + return this.hash(value, path, GIT_SHA, '40 lower-case hex characters'); + } + + sha256(value: unknown, path: string): string | undefined { + return this.hash(value, path, SHA256, '64 lower-case hex characters'); + } + + /** A positive integer that is safe to handle as a JavaScript number. */ + int(value: unknown, path: string): number | undefined { + if (typeof value !== 'number' || Number.isNaN(value)) return this.fail('invalid-type', path, 'expected a number'); + if (!Number.isSafeInteger(value) || value < 1) return this.fail('out-of-range', path, 'expected a positive integer'); + return value; + } + + oneOf(value: unknown, path: string, allowed: readonly T[]): T | undefined { + if (typeof value !== 'string') return this.fail('invalid-type', path, 'expected a string'); + if (!(allowed as readonly string[]).includes(value)) return this.fail('invalid-value', path, `expected one of ${allowed.join(', ')}`); + return value as T; + } + + /** ISO-8601 UTC, e.g. `2026-09-20T12:00:00Z`. A local time or offset is rejected because ordering would depend on it. */ + timestamp(value: unknown, path: string): string | undefined { + const s = this.string(value, path); + if (s === undefined) return undefined; + const m = TIMESTAMP.exec(s); + if (m) { + const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number) as [number, number, number, number, number, number]; + const d = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + const real = d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day; + if (real && hour < 24 && minute < 60 && second < 60) return s; + } + return this.fail('malformed-timestamp', path, 'expected ISO-8601 UTC such as 2026-09-20T12:00:00Z'); + } + + /** A single file name as found inside an archive or release: no directories, no Windows traps. */ + fileName(value: unknown, path: string): string | undefined { + const s = this.string(value, path); + if (s === undefined) return undefined; + if (s.trim() === '') return this.fail('empty', path, 'must not be empty'); + return unsafeSegment(s) ? this.fail('unsafe-path', path, 'not a plain file name') : s; + } + + /** A relative path with `/` separators that cannot leave its directory and is valid on every supported OS. */ + relativePath(value: unknown, path: string): string | undefined { + const s = this.string(value, path); + if (s === undefined) return undefined; + if (s.trim() === '') return this.fail('empty', path, 'must not be empty'); + // A leading "/" or a "//" leaves an empty segment, so this also rejects absolute paths. + const unsafe = s.split('/').some((segment) => segment === '' || unsafeSegment(segment)); + return unsafe ? this.fail('unsafe-path', path, 'must be a relative path that stays inside its directory') : s; + } + + /** A Git branch name in a conservative subset. */ + branchName(value: unknown, path: string): string | undefined { + const s = this.string(value, path); + if (s === undefined) return undefined; + if (s.trim() === '') return this.fail('empty', path, 'must not be empty'); + const ok = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(s) && !s.includes('..') && !s.includes('//') && !/[/.]$/.test(s) && !s.endsWith('.lock'); + return ok ? s : this.fail('invalid-value', path, 'not a valid branch name'); + } + + /** Returns a dense copy, so holes in a sparse array are validated as missing values instead of being skipped. */ + array(value: unknown, path: string, options: { min?: number } = {}): unknown[] | undefined { + if (!Array.isArray(value)) return this.fail('invalid-type', path, 'expected an array'); + if ((options.min ?? 0) > value.length) return this.fail('empty', path, `expected at least ${options.min} item(s)`); + return Array.from(value as unknown[]); + } + + /** Flags the second and later occurrence of a value. Entries whose value failed earlier checks are skipped. */ + unique(entries: ReadonlyArray<{ value: string | undefined; path: string }>): void { + const seen = new Set(); + for (const { value, path } of entries) { + if (value === undefined) continue; + if (seen.has(value)) this.add('duplicate', path, `"${value}" appears more than once`); + seen.add(value); + } + } + + private string(value: unknown, path: string): string | undefined { + return typeof value === 'string' ? value : this.fail('invalid-type', path, 'expected a string'); + } + + private matching(value: unknown, path: string, pattern: RegExp, code: IssueCode, message: string): string | undefined { + const s = this.string(value, path); + if (s === undefined) return undefined; + if (s.trim() === '') return this.fail('empty', path, 'must not be empty'); + return pattern.test(s) ? s : this.fail(code, path, message); + } + + private hash(value: unknown, path: string, pattern: RegExp, expected: string): string | undefined { + if (typeof value !== 'string') return this.fail('invalid-type', path, 'expected a string'); + return pattern.test(value) ? value : this.fail('malformed-hash', path, `expected ${expected}`); + } + + private fail(code: IssueCode, path: string, message: string): undefined { + this.add(code, path, message); + return undefined; + } +} + +/** Describes any value for an error message without ever throwing (bigint and cyclic values break JSON.stringify). */ +function describe(value: unknown): string { + try { + return JSON.stringify(value) ?? typeof value; + } catch { + return `a value of type ${typeof value}`; + } +} + +/** Runs a parse and turns anything that escapes, such as an object whose getters throw, into a validation failure. */ +function guarded(parse: () => ParseResult): ParseResult { + try { + return parse(); + } catch { + return failure([{ code: 'invalid-type', path: '', message: 'the value could not be read' }]); + } +} + +/** + * Parses a schema-versioned record. A record that declares any version other than 1 is rejected on that + * ground alone: its other fields may mean something different, so they are not inspected. + */ +export function parseVersioned(input: unknown, spec: FieldSpec, read: (c: Collector, rec: Record) => T): ParseResult { + return guarded(() => { + if (typeof input === 'object' && input !== null && !Array.isArray(input) && 'schemaVersion' in input) { + const declared = (input as { schemaVersion: unknown }).schemaVersion; + if (declared !== SCHEMA_VERSION) { + return failure([{ code: 'unknown-schema-version', path: 'schemaVersion', message: `unsupported schema version ${describe(declared)}; this tool reads version ${SCHEMA_VERSION}` }]); + } + } + return parseUnversioned(input, { ...spec, required: ['schemaVersion', ...spec.required] }, read); + }); +} + +/** Parses a record embedded in a versioned one, or an internal record that carries no version of its own. */ +export function parseUnversioned(input: unknown, spec: FieldSpec, read: (c: Collector, rec: Record) => T): ParseResult { + return guarded(() => { + const c = new Collector(); + const rec = c.record(input, '', spec); + const value = rec === undefined ? undefined : read(c, rec); + return c.issues.length === 0 ? { ok: true, value: value as T } : failure(c.issues); + }); +} + +function failure(issues: readonly ValidationIssue[]): { ok: false; error: ValidationError } { + return { ok: false, error: new ValidationError(issues) }; +} diff --git a/packages/qa/test/fixtures/records.ts b/packages/qa/test/fixtures/records.ts new file mode 100644 index 0000000..6cbdb10 --- /dev/null +++ b/packages/qa/test/fixtures/records.ts @@ -0,0 +1,110 @@ +// Representative record builders. Every builder returns a valid record built from fixed IDs and +// hashes, and accepts shallow overrides so a test changes only what it is about. +import type { Artifact, Candidate } from '../../src/model/candidate.ts'; +import type { Exception } from '../../src/model/exception.ts'; +import type { Project } from '../../src/model/project.ts'; +import type { Requirement } from '../../src/model/requirement.ts'; +import type { Report } from '../../src/model/result.ts'; + +export const SHA1 = { + source: '1'.repeat(40), + base: '2'.repeat(40), + tree: '3'.repeat(40), + tests: '4'.repeat(40), +} as const; +export const SHA256 = { policy: 'a'.repeat(64), windowsInstaller: 'b'.repeat(64), linuxPackage: 'c'.repeat(64) } as const; + +export function artifact(overrides: Partial = {}): Artifact { + return { + profile: 'windows', + name: 'Release QA Smoke_0.1.0_x64-setup.exe', + sha256: SHA256.windowsInstaller, + assetId: 101, + actionsArtifactId: 201, + ...overrides, + }; +} + +export function candidate(overrides: Partial = {}): Candidate { + return { + schemaVersion: 1, + id: 'cand-0001', + repositoryId: 1, + pullRequest: 7, + sourceSha: SHA1.source, + baseSha: SHA1.base, + sourceTreeSha: SHA1.tree, + testRevision: SHA1.tests, + policyDigest: SHA256.policy, + build: { workflowPath: '.github/workflows/qa-prepare.yml', runId: 5000, attempt: 1 }, + artifacts: [ + artifact(), + artifact({ profile: 'linux', name: 'release-qa-smoke_0.1.0_amd64.deb', sha256: SHA256.linuxPackage, assetId: 102, actionsArtifactId: 202 }), + ], + ...overrides, + }; +} + +export function requirement(overrides: Partial = {}): Requirement { + return { + key: 'windows/persistence', + mode: 'automated', + title: 'The saved setting survives a restart', + capabilities: [], + ...overrides, + }; +} + +export function project(overrides: Partial = {}): Project { + const persistence = requirement(); + const deviceFeel = requirement({ key: 'windows/device-feel', mode: 'manual', title: 'Sliders feel right', capabilities: ['hardware'] }); + const linuxPersistence = requirement({ key: 'linux/persistence' }); + return { + schemaVersion: 1, + projectId: 'tauri-smoke', + releaseBranch: 'main', + profiles: [ + { id: 'windows', os: 'windows', arch: 'x86_64' }, + { id: 'linux', os: 'linux', arch: 'x86_64' }, + ], + requirements: [persistence, deviceFeel, linuxPersistence], + suites: [ + { id: 'smoke', requirements: [persistence.key, linuxPersistence.key] }, + { id: 'release', requirements: [persistence.key, deviceFeel.key, linuxPersistence.key] }, + ], + scenarioFiles: ['scenarios/persistence.spec.ts'], + lifecycleModule: 'lifecycle.ts', + workflows: { prepare: 'qa-prepare.yml', gate: 'qa-gate.yml', publish: 'qa-publish.yml' }, + markers: { releaseNotes: 'release-notes', qa: 'qa' }, + ...overrides, + }; +} + +export function report(overrides: Partial = {}): Report { + return { + schemaVersion: 1, + id: 'report-0001', + candidateId: 'cand-0001', + policyDigest: SHA256.policy, + testRevision: SHA1.tests, + profile: 'windows', + actor: 'tester', + machineId: 'lab-win-01', + environment: { os: 'windows', osVersion: '10.0.26200', arch: 'x86_64', capabilities: ['display', 'audio'], toolVersion: '0.0.0' }, + attempts: [{ id: 'attempt-0001', requirement: 'windows/persistence', outcome: 'passed', evidence: ['evidence/persistence.png'] }], + ...overrides, + }; +} + +export function exception(overrides: Partial = {}): Exception { + return { + schemaVersion: 1, + id: 'exception-0001', + candidateId: 'cand-0001', + requirements: ['windows/device-feel'], + reason: 'No slider hardware available in the lab', + actor: 'maintainer', + createdAt: '2026-09-20T12:00:00Z', + ...overrides, + }; +} diff --git a/packages/qa/test/model/contracts.test.ts b/packages/qa/test/model/contracts.test.ts new file mode 100644 index 0000000..cb78f1b --- /dev/null +++ b/packages/qa/test/model/contracts.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, test } from 'vitest'; +import { parseCandidate } from '../../src/model/candidate.ts'; +import { parseException } from '../../src/model/exception.ts'; +import { parseProject } from '../../src/model/project.ts'; +import { parseRequirement } from '../../src/model/requirement.ts'; +import { parseReport, parseUploadProvenance } from '../../src/model/result.ts'; +import type { ParseResult } from '../../src/model/validate.ts'; +import { artifact, candidate, exception, project, report, requirement, SHA1 } from '../fixtures/records.ts'; + +/** `[path, code]` pairs of a failed parse; empty for a successful one. */ +function issues(result: ParseResult): Array<[string, string]> { + return result.ok ? [] : result.error.issues.map((i) => [i.path, i.code]); +} +function expectValid(result: ParseResult): void { + expect(result.ok, `should have been accepted, got ${JSON.stringify(issues(result))}`).toBe(true); +} +function expectIssue(result: ParseResult, path: string, code: string): void { + expect(result.ok, 'the record should have been rejected').toBe(false); + expect(issues(result)).toContainEqual([path, code]); +} +/** Overwrites a nested value on a deep copy, so a test can break exactly one field. */ +function broken(record: T, path: string, value: unknown): unknown { + const copy = structuredClone(record) as Record; + const keys = path.split('.'); + let node: Record = copy; + for (const key of keys.slice(0, -1)) node = (Array.isArray(node) ? node[Number(key)] : node[key]) as Record; + const last = keys.at(-1) as string; + if (value === undefined) delete node[last]; + else node[last] = value; + return copy; +} + +const parsers: Array<[string, (input: unknown) => ParseResult, () => unknown]> = [ + ['candidate', (i) => parseCandidate(i), () => candidate()], + ['project', (i) => parseProject(i), () => project()], + ['report', (i) => parseReport(i), () => report()], + ['exception', (i) => parseException(i), () => exception()], +]; + +describe.each(parsers)('%s: behaviour common to every record', (_name, parse, build) => { + test('accepts a valid record and returns it unchanged', () => { + const result = parse(build()); + expectValid(result); + expect(result.ok && result.value).toEqual(build()); + }); + + test.each([null, undefined, 'a string', 42, true, []])('rejects the non-object %j without throwing', (input) => { + expectIssue(parse(input), '', 'invalid-type'); + }); + + test.each([2, 0, '1', null])('rejects unknown schema version %j', (version) => { + expectIssue(parse(broken(build(), 'schemaVersion', version)), 'schemaVersion', 'unknown-schema-version'); + }); + + test('rejects a record with no schema version', () => { + expectIssue(parse(broken(build(), 'schemaVersion', undefined)), 'schemaVersion', 'missing-field'); + }); + + test('reports only the version problem for a future-schema record, whatever else it contains', () => { + const result = parse({ schemaVersion: 2, somethingNew: { nested: true } }); + expect(issues(result)).toEqual([['schemaVersion', 'unknown-schema-version']]); + }); + + test('rejects fields it does not know', () => { + expectIssue(parse({ ...(build() as object), surprise: 1 }), 'surprise', 'unknown-field'); + }); + + // The contract is "never throws", so values that JSON.stringify or property access cannot handle must not escape. + const circular: Record = {}; + circular.self = circular; + const hostile = new Proxy({}, { has: () => { throw new Error('boom'); }, get: () => { throw new Error('boom'); }, ownKeys: () => { throw new Error('boom'); } }); + test.each([ + ['a bigint', { schemaVersion: 1n }], + ['a circular object', { schemaVersion: circular }], + ])('names the unsupported version precisely when the schema version is %s', (_label, input) => { + expect(() => parse(input)).not.toThrow(); + expectIssue(parse(input), 'schemaVersion', 'unknown-schema-version'); + }); + + test('does not throw for an object that throws when it is read', () => { + expect(() => parse(hostile)).not.toThrow(); + expectIssue(parse(hostile), '', 'invalid-type'); + }); +}); + +describe('candidate', () => { + test.each([ + ['sha256 in upper case', 'artifacts.0.sha256', 'B'.repeat(64), 'artifacts[0].sha256'], + ['sha256 one character short', 'artifacts.0.sha256', 'b'.repeat(63), 'artifacts[0].sha256'], + ['sha256 with an algorithm prefix', 'artifacts.0.sha256', `sha256:${'b'.repeat(64)}`, 'artifacts[0].sha256'], + ['sha256 that is not hex', 'artifacts.0.sha256', 'g'.repeat(64), 'artifacts[0].sha256'], + ['source commit that is abbreviated', 'sourceSha', '1234567', 'sourceSha'], + ['base commit in upper case', 'baseSha', 'A'.repeat(40), 'baseSha'], + ['tree that is a sha256', 'sourceTreeSha', 'a'.repeat(64), 'sourceTreeSha'], + ['test revision that is empty', 'testRevision', '', 'testRevision'], + ['policy digest that is a git sha', 'policyDigest', SHA1.tests, 'policyDigest'], + ])('rejects a malformed hash: %s', (_label, path, value, issuePath) => { + expectIssue(parseCandidate(broken(candidate(), path, value)), issuePath, 'malformed-hash'); + }); + + test('rejects two artifacts with the same GitHub asset id', () => { + const result = parseCandidate(candidate({ artifacts: [artifact(), artifact({ profile: 'linux', name: 'other.deb', actionsArtifactId: 999 })] })); + expectIssue(result, 'artifacts[1].assetId', 'duplicate'); + }); + + test('rejects two artifacts with the same profile and file name', () => { + const result = parseCandidate(candidate({ artifacts: [artifact(), artifact({ assetId: 555, actionsArtifactId: 999 })] })); + expectIssue(result, 'artifacts[1].name', 'duplicate'); + }); + + test('accepts the same file name under two different profiles', () => { + const result = parseCandidate(candidate({ artifacts: [artifact(), artifact({ profile: 'linux', assetId: 555, actionsArtifactId: 999 })] })); + expectValid(result); + }); + + test.each([ + ['a parent directory', '../evil.exe'], + ['a nested parent directory', 'a/../../evil.exe'], + ['a forward slash', 'dir/setup.exe'], + ['a backslash', 'dir\\setup.exe'], + ['just dot dot', '..'], + ['just a dot', '.'], + ['a Windows drive prefix', 'C:evil.exe'], + ['an NTFS alternate data stream', 'setup.exe:stream'], + ['a NUL character', 'setup\u0000.exe'], + ['a Windows reserved device name', 'CON'], + ['a reserved device name with an extension', 'nul.txt'], + ['a trailing dot', 'setup.exe.'], + ['a trailing space', 'setup.exe '], + ['a name longer than 255 characters', `${'a'.repeat(256)}.exe`], + ])('rejects an artifact file name containing %s', (_label, name) => { + expectIssue(parseCandidate(candidate({ artifacts: [artifact({ name })] })), 'artifacts[0].name', 'unsafe-path'); + }); + + test.each(['<', '>', '"', '|', '?', '*'])('rejects an artifact file name containing the Windows-reserved character %j', (ch) => { + expectIssue(parseCandidate(candidate({ artifacts: [artifact({ name: `setup${ch}.exe` })] })), 'artifacts[0].name', 'unsafe-path'); + }); + + test('accepts an installer name with spaces, as produced by real packagers', () => { + expectValid(parseCandidate(candidate({ artifacts: [artifact({ name: 'Release QA Smoke_0.1.0_x64-setup.exe' })] }))); + }); + + test.each([['../workflow.yml'], ['/etc/workflow.yml'], ['.github\\workflows\\x.yml'], ['C:/x.yml'], ['a//b.yml']])( + 'rejects the unsafe build workflow path %j', + (workflowPath) => { + expectIssue(parseCandidate(candidate({ build: { workflowPath, runId: 1, attempt: 1 } })), 'build.workflowPath', 'unsafe-path'); + }, + ); + + test('rejects an empty build workflow path', () => { + expectIssue(parseCandidate(candidate({ build: { workflowPath: '', runId: 1, attempt: 1 } })), 'build.workflowPath', 'empty'); + }); + + test.each(['', ' ', '\t'])('rejects the empty candidate id %j', (id) => { + expectIssue(parseCandidate(candidate({ id })), 'id', 'empty'); + }); + + test('rejects an id that could be used to escape a directory', () => { + expectIssue(parseCandidate(candidate({ id: '../cand' })), 'id', 'malformed-id'); + }); + + test('rejects a candidate with no artifacts', () => { + expectIssue(parseCandidate(candidate({ artifacts: [] })), 'artifacts', 'empty'); + }); + + test.each([ + ['zero', 0, 'out-of-range'], + ['negative', -3, 'out-of-range'], + ['fractional', 1.5, 'out-of-range'], + ['a string', '7', 'invalid-type'], + ['not a number', Number.NaN, 'invalid-type'], + ])('rejects a pull request number that is %s', (_label, value, code) => { + expectIssue(parseCandidate(broken(candidate(), 'pullRequest', value)), 'pullRequest', code); + }); + + test('reports every problem in one pass, not just the first', () => { + const result = parseCandidate(candidate({ id: '', sourceSha: 'nope', pullRequest: 0 })); + expect(issues(result)).toEqual(expect.arrayContaining([['id', 'empty'], ['sourceSha', 'malformed-hash'], ['pullRequest', 'out-of-range']])); + }); +}); + +describe('requirement', () => { + test.each([['windows'], ['a/b/c'], ['/x'], ['x/'], ['Windows/Persistence'], ['win dows/x'], ['']])('rejects the malformed key %j', (key) => { + expectIssue(parseRequirement(requirement({ key: key as never })), 'key', 'malformed-key'); + }); + + test('rejects an unknown execution mode', () => { + expectIssue(parseRequirement(broken(requirement(), 'mode', 'semi-automatic')), 'mode', 'invalid-value'); + }); + + test('validates every slot of a sparse capabilities array', () => { + expectIssue(parseRequirement(requirement({ capabilities: new Array(2) })), 'capabilities[0]', 'invalid-type'); + }); + + test('rejects an empty title', () => { + expectIssue(parseRequirement(requirement({ title: ' ' })), 'title', 'empty'); + }); + + test('accepts a valid requirement', () => { + expectValid(parseRequirement(requirement())); + }); +}); + +describe('project', () => { + test('rejects two profiles with the same id', () => { + const p = project(); + expectIssue(parseProject({ ...p, profiles: [...p.profiles, p.profiles[0]] }), 'profiles[2].id', 'duplicate'); + }); + + test('rejects two requirements with the same key', () => { + const p = project(); + expectIssue(parseProject({ ...p, requirements: [...p.requirements, requirement()] }), 'requirements[3].key', 'duplicate'); + }); + + test('rejects two suites with the same id', () => { + const p = project(); + expectIssue(parseProject({ ...p, suites: [...p.suites, p.suites[0]] }), 'suites[2].id', 'duplicate'); + }); + + test('rejects a suite that lists a requirement the project does not define', () => { + const p = project(); + expectIssue(parseProject({ ...p, suites: [{ id: 'smoke', requirements: ['windows/missing'] }] }), 'suites[0].requirements[0]', 'unknown-reference'); + }); + + test('rejects a requirement for a profile the project does not define', () => { + const p = project(); + expectIssue(parseProject({ ...p, requirements: [requirement({ key: 'macos/persistence' })], suites: [] }), 'requirements[0].key', 'unknown-reference'); + }); + + test('rejects a suite that lists the same requirement twice', () => { + const p = project(); + expectIssue(parseProject({ ...p, suites: [{ id: 'smoke', requirements: ['windows/persistence', 'windows/persistence'] }] }), 'suites[0].requirements[1]', 'duplicate'); + }); + + test.each([['../scenario.spec.ts'], ['/abs/scenario.spec.ts'], ['scenarios\\a.spec.ts'], ['scenarios/../../x.ts']])( + 'rejects the unsafe scenario file %j', + (file) => { + expectIssue(parseProject({ ...project(), scenarioFiles: [file] }), 'scenarioFiles[0]', 'unsafe-path'); + }, + ); + + test('rejects an unsafe lifecycle module path', () => { + expectIssue(parseProject({ ...project(), lifecycleModule: '../lifecycle.ts' }), 'lifecycleModule', 'unsafe-path'); + }); + + test('rejects two markers with the same name', () => { + expectIssue(parseProject({ ...project(), markers: { releaseNotes: 'qa', qa: 'qa' } }), 'markers.qa', 'duplicate'); + }); + + test('rejects a workflow name that is a path', () => { + expectIssue(parseProject({ ...project(), workflows: { ...project().workflows, gate: '../qa-gate.yml' } }), 'workflows.gate', 'unsafe-path'); + }); + + test('rejects an unsupported operating system', () => { + expectIssue(parseProject(broken(project(), 'profiles.0.os', 'macos')), 'profiles[0].os', 'invalid-value'); + }); + + test.each([['feature branch'], ['a..b'], ['a//b'], ['-x'], ['x/']])('rejects the release branch %j', (releaseBranch) => { + expectIssue(parseProject({ ...project(), releaseBranch }), 'releaseBranch', 'invalid-value'); + }); + + test('rejects an empty release branch', () => { + expectIssue(parseProject({ ...project(), releaseBranch: '' }), 'releaseBranch', 'empty'); + }); +}); + +describe('report', () => { + test('rejects two attempts with the same id', () => { + const attempt = report().attempts[0]!; + expectIssue(parseReport(report({ attempts: [attempt, { ...attempt }] })), 'attempts[1].id', 'duplicate'); + }); + + test('rejects an attempt that is a retry of itself', () => { + const attempt = report().attempts[0]!; + expectIssue(parseReport(report({ attempts: [{ ...attempt, retryOf: attempt.id }] })), 'attempts[0].retryOf', 'invalid-value'); + }); + + test('accepts a retry of an attempt that lives in another report', () => { + const attempt = report().attempts[0]!; + expectValid(parseReport(report({ attempts: [{ ...attempt, retryOf: 'attempt-from-elsewhere' }] }))); + }); + + test('validates every slot of a sparse attempts array', () => { + expectIssue(parseReport(report({ attempts: new Array(1) as never })), 'attempts[0]', 'invalid-type'); + }); + + test('rejects a line break in a single-line text field, on any platform', () => { + expectIssue(parseReport(report({ actor: 'x\ry' })), 'actor', 'invalid-characters'); + expectIssue(parseReport(report({ machineId: 'x\ny' })), 'machineId', 'invalid-characters'); + }); + + test('rejects a report with no attempts', () => { + expectIssue(parseReport(report({ attempts: [] })), 'attempts', 'empty'); + }); + + test('rejects an attempt whose requirement belongs to a different profile than the report', () => { + const attempt = { ...report().attempts[0]!, requirement: 'linux/persistence' as const }; + expectIssue(parseReport(report({ attempts: [attempt] })), 'attempts[0].requirement', 'mismatch'); + }); + + test('rejects an outcome it does not know', () => { + expectIssue(parseReport(broken(report(), 'attempts.0.outcome', 'kinda-passed')), 'attempts[0].outcome', 'invalid-value'); + }); + + test.each([ + ['../secret.png'], + ['/etc/passwd'], + ['evidence\\a.png'], + ['a/../../b.png'], + ['evidence/trace:secret'], // NTFS alternate data stream + ['evidence/CON/x.png'], // Windows device name as a directory + ['evidence/nul.txt'], + ['evidence/x./y.png'], // trailing dot in a directory + ['evidence/x /y.png'], // trailing space in a directory + ['evidence/a?b.png'], + ['evidence/a*b.png'], + ['evidence/a.png'], + ['evidence/a|b.png'], + ['evidence/a"b.png'], + ])('rejects the unsafe evidence path %j', (path) => { + const attempt = { ...report().attempts[0]!, evidence: [path] }; + expectIssue(parseReport(report({ attempts: [attempt] })), 'attempts[0].evidence[0]', 'unsafe-path'); + }); + + test('rejects a report with no measured environment', () => { + expectIssue(parseReport(broken(report(), 'environment', undefined)), 'environment', 'missing-field'); + }); + + test.each([['actor'], ['machineId']])('rejects an empty %s', (field) => { + expectIssue(parseReport(broken(report(), field, '')), field, 'empty'); + }); + + test('keeps the claimed actor as plain data', () => { + const result = parseReport(report({ actor: 'someone-else' })); + expect(result.ok && result.value.actor).toBe('someone-else'); + }); + + describe('against a candidate', () => { + test('accepts a report that belongs to the candidate', () => { + expectValid(parseReport(report(), { candidate: candidate() })); + }); + + test('rejects a report for a different candidate', () => { + expectIssue(parseReport(report({ candidateId: 'cand-9999' }), { candidate: candidate() }), 'candidateId', 'unknown-reference'); + }); + + test('rejects a report for a profile the candidate has no artifact for', () => { + const linuxOnly = candidate({ artifacts: [artifact({ profile: 'linux', name: 'x.deb' })] }); + expectIssue(parseReport(report(), { candidate: linuxOnly }), 'profile', 'unknown-reference'); + }); + + test('rejects a report made against a different policy', () => { + expectIssue(parseReport(report({ policyDigest: 'd'.repeat(64) }), { candidate: candidate() }), 'policyDigest', 'mismatch'); + }); + + test('rejects a report made against different tests', () => { + expectIssue(parseReport(report({ testRevision: '9'.repeat(40) }), { candidate: candidate() }), 'testRevision', 'mismatch'); + }); + + test('rejects an attempt for a requirement outside the required set', () => { + expectIssue(parseReport(report(), { requirements: ['windows/device-feel'] }), 'attempts[0].requirement', 'unknown-reference'); + }); + }); +}); + +describe('exception', () => { + test('rejects an exception that names no requirements', () => { + expectIssue(parseException(exception({ requirements: [] })), 'requirements', 'empty'); + }); + + test('rejects an exception that names a requirement twice', () => { + expectIssue(parseException(exception({ requirements: ['windows/device-feel', 'windows/device-feel'] })), 'requirements[1]', 'duplicate'); + }); + + test.each(['', ' '])('rejects the empty reason %j', (reason) => { + expectIssue(parseException(exception({ reason })), 'reason', 'empty'); + }); + + test.each([['yesterday'], ['2026-09-20'], ['2026-09-20T12:00:00'], ['2026-09-20T12:00:00+02:00'], ['2026-13-40T12:00:00Z']])( + 'rejects the malformed timestamp %j', + (createdAt) => { + expectIssue(parseException(exception({ createdAt })), 'createdAt', 'malformed-timestamp'); + }, + ); + + test('accepts a multi-line reason with Windows or Unix line endings', () => { + expectValid(parseException(exception({ reason: 'first line\r\nsecond line\nthird line' }))); + }); + + test('accepts a timestamp with fractional seconds', () => { + expectValid(parseException(exception({ createdAt: '2026-09-20T12:00:00.123Z' }))); + }); + + test('rejects an exception for a different candidate than the one supplied', () => { + expectIssue(parseException(exception({ candidateId: 'cand-9999' }), { candidate: candidate() }), 'candidateId', 'unknown-reference'); + }); +}); + +describe('upload provenance', () => { + const provenance = { uploader: 'octocat', uploadedAt: '2026-09-20T12:00:00Z', assetId: 42 }; + + test('accepts valid provenance', () => { + expectValid(parseUploadProvenance(provenance)); + }); + + test('rejects a malformed upload time', () => { + expectIssue(parseUploadProvenance({ ...provenance, uploadedAt: 'later' }), 'uploadedAt', 'malformed-timestamp'); + }); + + test('rejects an empty uploader', () => { + expectIssue(parseUploadProvenance({ ...provenance, uploader: '' }), 'uploader', 'empty'); + }); +});