mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
test(release): make the signing-gate structural tests assert executed code, not text
The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so
the word satisfied the assertion wherever it appeared. Downgrading the resolver
throw to `Write-Host "...would normally throw..."` — the exact silent fail-open
this PR exists to kill — left all 11 tests green.
Every span is now classified once (code / string / comment) by the same walk that
pairs braces, and assertions run against the string-and-comment-blanked view.
Blanking preserves length, so indices still line up across views.
Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked
the first catch in the step, which stopped being the gate's own once the
persistence helpers grew theirs — moving the policy throw inside the try was
passing again.
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -14,54 +14,166 @@ function workflowSource(name) {
|
||||
return readFileSync(join(workflowsDir, name), 'utf8')
|
||||
}
|
||||
|
||||
// Why a scanner and not a regex: PowerShell here-docs in these gates contain
|
||||
// braces inside strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so
|
||||
// naive brace counting mis-pairs and every scope assertion below silently
|
||||
// degrades into "some text appears somewhere in the file".
|
||||
function findBlockEnd(source, openIndex) {
|
||||
let depth = 0
|
||||
let i = openIndex
|
||||
// Why a scanner and not a regex: PowerShell gates here contain braces inside
|
||||
// strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so naive brace
|
||||
// counting mis-pairs and every scope assertion below silently degrades into
|
||||
// "some text appears somewhere in the file". The same walk also lets assertions
|
||||
// distinguish a keyword the shell executes from the same word sitting in a
|
||||
// message string — downgrading `throw` to `Write-Host "...would throw..."`
|
||||
// otherwise passes a `/\bthrow\b/` check while restoring the silent fail-open.
|
||||
|
||||
/** `source` split into `{start, end, kind}` spans, kind being 'code' | 'string' | 'comment'. */
|
||||
function scanSpans(source) {
|
||||
// Here-strings use different terminator rules; refusing them beats mis-pairing silently.
|
||||
expect(source, 'here-strings are not understood by this scanner').not.toMatch(/@['"]/)
|
||||
const spans = []
|
||||
let i = 0
|
||||
while (i < source.length) {
|
||||
const char = source[i]
|
||||
if (char === '#') {
|
||||
const newline = source.indexOf('\n', i)
|
||||
i = newline === -1 ? source.length : newline
|
||||
const end = newline === -1 ? source.length : newline
|
||||
spans.push({ start: i, end, kind: 'comment' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
const end = source.indexOf("'", i + 1)
|
||||
i = end === -1 ? source.length : end + 1
|
||||
let end = i + 1
|
||||
while (end < source.length) {
|
||||
if (source[end] !== "'") {
|
||||
end += 1
|
||||
} else if (source[end + 1] === "'") {
|
||||
end += 2 // doubled '' escapes a quote rather than closing the string
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
end = Math.min(end + 1, source.length)
|
||||
spans.push({ start: i, end, kind: 'string' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
i += 1
|
||||
while (i < source.length && source[i] !== '"') {
|
||||
i += source[i] === '`' ? 2 : 1
|
||||
let end = i + 1
|
||||
while (end < source.length && source[end] !== '"') {
|
||||
end += source[end] === '`' ? 2 : 1
|
||||
}
|
||||
i += 1
|
||||
end = Math.min(end + 1, source.length)
|
||||
spans.push({ start: i, end, kind: 'string' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if (char === '{') {
|
||||
depth += 1
|
||||
} else if (char === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return i
|
||||
}
|
||||
let end = i
|
||||
while (end < source.length && !'#\'"'.includes(source[end])) {
|
||||
end += 1
|
||||
}
|
||||
i += 1
|
||||
spans.push({ start: i, end, kind: 'code' })
|
||||
i = end
|
||||
}
|
||||
throw new Error(`unbalanced block starting at ${openIndex}`)
|
||||
return spans
|
||||
}
|
||||
|
||||
/** The `{ ... }` block that opens after `marker`, as `{ start, end, body }`. */
|
||||
/** `source` with the named span kinds blanked to spaces — same length, so indices still line up. */
|
||||
function blank(source, spans, kinds) {
|
||||
const chars = [...source]
|
||||
for (const span of spans) {
|
||||
if (!kinds.includes(span.kind)) {
|
||||
continue
|
||||
}
|
||||
for (let i = span.start; i < span.end; i += 1) {
|
||||
if (chars[i] !== '\n') {
|
||||
chars[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
/** `source` with comments blanked — for assertions whose subject is a literal the gate prints. */
|
||||
function withoutComments(source) {
|
||||
return blank(source, scanSpans(source), ['comment'])
|
||||
}
|
||||
|
||||
/** `source` with strings and comments blanked — for assertions about executed statements. */
|
||||
function codeOf(source) {
|
||||
return blank(source, scanSpans(source), ['string', 'comment'])
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{ ... }` block opening after `marker`. `code` has strings and comments blanked, so a
|
||||
* keyword assertion against it can only be satisfied by a keyword the shell would execute;
|
||||
* `text` keeps strings but drops comments, for assertions about literals the gate emits.
|
||||
*/
|
||||
function blockAfter(source, marker, from = 0) {
|
||||
const markerIndex = source.indexOf(marker, from)
|
||||
const spans = scanSpans(source)
|
||||
const code = blank(source, spans, ['string', 'comment'])
|
||||
const markerIndex = code.indexOf(marker, from)
|
||||
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
|
||||
const start = source.indexOf('{', markerIndex)
|
||||
const start = code.indexOf('{', markerIndex)
|
||||
expect(start, `no block opens after: ${marker}`).toBeGreaterThan(-1)
|
||||
const end = findBlockEnd(source, start)
|
||||
return { start, end, body: source.slice(start, end + 1) }
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let i = start; i < source.length && end === -1; i += 1) {
|
||||
if (code[i] === '{') {
|
||||
depth += 1
|
||||
} else if (code[i] === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(end, `unbalanced block after: ${marker}`).toBeGreaterThan(-1)
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
code: code.slice(start, end + 1),
|
||||
text: blank(source, spans, ['comment']).slice(start, end + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The innermost `{ ... }` block enclosing `marker`, plus the keyword introducing it.
|
||||
* Needed where the anchor is the block's *contents*: `blockAfter(step, '} catch {')` picks
|
||||
* whichever catch comes first in the file, which stopped being the gate's own once the
|
||||
* persistence helpers grew their own try/catch.
|
||||
*/
|
||||
function blockEnclosing(source, marker) {
|
||||
const spans = scanSpans(source)
|
||||
const code = blank(source, spans, ['string', 'comment'])
|
||||
// Located in the comment-stripped text (the marker may include a string literal),
|
||||
// then paired in `code`; both blankings preserve length, so indices line up.
|
||||
const markerIndex = blank(source, spans, ['comment']).indexOf(marker)
|
||||
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let i = markerIndex; i < code.length && end === -1; i += 1) {
|
||||
if (code[i] === '{') {
|
||||
depth += 1
|
||||
} else if (code[i] === '}') {
|
||||
if (depth === 0) {
|
||||
end = i
|
||||
} else {
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
depth = 0
|
||||
let start = -1
|
||||
for (let i = markerIndex; i >= 0 && start === -1; i -= 1) {
|
||||
if (code[i] === '}') {
|
||||
depth += 1
|
||||
} else if (code[i] === '{') {
|
||||
if (depth === 0) {
|
||||
start = i
|
||||
} else {
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(start, `no block encloses: ${marker}`).toBeGreaterThan(-1)
|
||||
expect(end, `no block encloses: ${marker}`).toBeGreaterThan(-1)
|
||||
return { start, end, keyword: code.slice(0, start).trimEnd().split(/\s+/).pop() }
|
||||
}
|
||||
|
||||
describe('Windows signing gates resolve 7za through the toolset resolver (#6487)', () => {
|
||||
@@ -81,7 +193,7 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
|
||||
it(`${name} aborts the gate when 7za cannot be resolved`, () => {
|
||||
const source = workflowSource(name)
|
||||
const guard = blockAfter(source, '$LASTEXITCODE -ne 0 -or -not (Test-Path $7za)')
|
||||
expect(guard.body).toMatch(/\bthrow\b/)
|
||||
expect(guard.code).toMatch(/\bthrow\b/)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,14 +213,14 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
|
||||
// string 'Write-GateVerdict' appears somewhere passes even if the body is
|
||||
// gutted to a Write-Host, which is exactly the silent degradation this gate
|
||||
// exists to prevent.
|
||||
function gateVerdictFunctionBody() {
|
||||
return blockAfter(innerBinaryStep(), 'function Write-GateVerdict').body
|
||||
function gateVerdictBlock() {
|
||||
return blockAfter(innerBinaryStep(), 'function Write-GateVerdict')
|
||||
}
|
||||
|
||||
it('persists the verdict to the evidence file the artifact upload collects', () => {
|
||||
const body = gateVerdictFunctionBody()
|
||||
expect(body).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/)
|
||||
expect(body).toContain('Add-GateSummary')
|
||||
const block = gateVerdictBlock()
|
||||
expect(block.text).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/)
|
||||
expect(block.code).toContain('Add-GateSummary')
|
||||
})
|
||||
|
||||
it('never lets verdict persistence itself fail a warn-only release', () => {
|
||||
@@ -116,15 +228,16 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
|
||||
// must not turn evidence-writing into the thing that fails the release.
|
||||
const step = innerBinaryStep()
|
||||
for (const helper of ['function Add-GateEvidence', 'function Add-GateSummary']) {
|
||||
const body = blockAfter(step, helper).body
|
||||
expect(body, helper).toContain('-ErrorAction Stop')
|
||||
expect(body, helper).toMatch(/\bcatch\b/)
|
||||
const code = blockAfter(step, helper).code
|
||||
expect(code, helper).toContain('-ErrorAction Stop')
|
||||
expect(code, helper).toMatch(/\bcatch\b/)
|
||||
}
|
||||
expect(gateVerdictFunctionBody()).toMatch(/\bcatch\b/)
|
||||
expect(gateVerdictBlock().code).toMatch(/\bcatch\b/)
|
||||
})
|
||||
|
||||
it('records a verdict on every terminal branch of the gate', () => {
|
||||
const step = innerBinaryStep()
|
||||
// Comments stripped: a `# VERDICT: PASSED` note must not stand in for the write.
|
||||
const step = withoutComments(innerBinaryStep())
|
||||
for (const verdict of ['NOT VERIFIED', 'ERRORED', 'VERDICT: FAILED', 'VERDICT: PASSED']) {
|
||||
expect(step).toContain(verdict)
|
||||
}
|
||||
@@ -134,10 +247,13 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
|
||||
// Why: throwing inside `try` re-enters the catch, whose Set-Content
|
||||
// replaces the per-file report with "ERRORED — <exception>".
|
||||
const step = innerBinaryStep()
|
||||
const policyThrow = step.indexOf('if ($policyFailure) { throw $policyFailure }')
|
||||
const policyThrow = codeOf(step).indexOf('if ($policyFailure) { throw $policyFailure }')
|
||||
expect(policyThrow).toBeGreaterThan(-1)
|
||||
const catchBlock = blockAfter(step, '} catch {')
|
||||
expect(policyThrow).toBeGreaterThan(catchBlock.end)
|
||||
// Anchored on the gate's own handler, not the first `catch` in the step: the
|
||||
// persistence helpers have their own, and they sit earlier in the file.
|
||||
const gateCatch = blockEnclosing(step, 'Write-GateVerdict "ERRORED')
|
||||
expect(gateCatch.keyword).toBe('catch')
|
||||
expect(policyThrow).toBeGreaterThan(gateCatch.end)
|
||||
})
|
||||
|
||||
// Why: the assignment is what survives a write failure. With it after the
|
||||
@@ -145,7 +261,7 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
|
||||
// $policyFailure still null — required mode reports ERRORED and overwrites the
|
||||
// per-file report, reintroducing exactly the loss the hoist prevents.
|
||||
it('records the required-mode failure before attempting any evidence write', () => {
|
||||
const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').body
|
||||
const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').code
|
||||
const assignment = branch.indexOf('$policyFailure = $message')
|
||||
expect(assignment).toBeGreaterThan(-1)
|
||||
for (const write of ['Add-GateEvidence', 'Add-GateSummary']) {
|
||||
|
||||
Reference in New Issue
Block a user