@marcusrbrown/infra 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
package/src/conventions.test.ts
DELETED
|
@@ -1,1180 +0,0 @@
|
|
|
1
|
-
import {relative, resolve} from 'node:path'
|
|
2
|
-
import {describe, expect, it} from 'bun:test'
|
|
3
|
-
import {parse as parseYaml} from 'yaml'
|
|
4
|
-
import {MCP_ALLOWLIST} from './commands/mcp'
|
|
5
|
-
|
|
6
|
-
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
|
7
|
-
const INTERNAL_ORGS = new Set(['marcusrbrown'])
|
|
8
|
-
const ALLOWED_SHELL_SCRIPTS = new Set(['apps/keeweb/deploy.sh'])
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// MCP drift-guard: sensitive tool set (two-layer security model)
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
//
|
|
14
|
-
// These commands are source-gated: they are NOT in MCP_ALLOWLIST and are
|
|
15
|
-
// therefore never registered as MCP tools. They remain CLI-only.
|
|
16
|
-
//
|
|
17
|
-
// WHY each is source-gated (primary layer — MCP_ALLOWLIST exclusion):
|
|
18
|
-
// cliproxy keys add — mutating: creates live bearer tokens on the proxy
|
|
19
|
-
// cliproxy keys remove — mutating: revokes live bearer tokens
|
|
20
|
-
// cliproxy config set — mutating: overwrites CLIProxyAPI runtime config
|
|
21
|
-
// gateway backup — secret-bearing: writes CA private key material to a tarball
|
|
22
|
-
// cliproxy keys list — secret-disclosing: prints live bearer tokens in plaintext
|
|
23
|
-
// cliproxy config get — secret-disclosing: dumps management config incl. management key
|
|
24
|
-
// vpn deploy — mutating: deploys WireGuard config to live VPN box
|
|
25
|
-
// vpn logs — sensitive: streams journalctl logs that may reveal peer IPs/traffic
|
|
26
|
-
// vpn client add — mutating: generates keypair + appends peer + triggers redeploy
|
|
27
|
-
// vpn client list — sensitive: lists peer public keys and tunnel IPs
|
|
28
|
-
// vpn client remove — mutating: removes peer + triggers redeploy
|
|
29
|
-
//
|
|
30
|
-
// Defense-in-depth (secondary layer — opencode.jsonc `permission: deny`):
|
|
31
|
-
// Even if MCP_ALLOWLIST were mistakenly re-expanded, opencode's native tool
|
|
32
|
-
// permission check provides a backstop that denies these tool calls centrally
|
|
33
|
-
// before execution. Both layers must stay in sync; the test below enforces it.
|
|
34
|
-
//
|
|
35
|
-
// A conventions test below asserts every entry (a) is NOT in MCP_ALLOWLIST
|
|
36
|
-
// (source-gated out) and (b) is still denied in opencode.jsonc under the
|
|
37
|
-
// prefixed tool id (defense-in-depth backstop).
|
|
38
|
-
const SENSITIVE_MCP_COMMANDS: readonly string[] = [
|
|
39
|
-
'cliproxy keys add',
|
|
40
|
-
'cliproxy keys remove',
|
|
41
|
-
'cliproxy config set',
|
|
42
|
-
'gateway backup',
|
|
43
|
-
'cliproxy keys list',
|
|
44
|
-
'cliproxy config get',
|
|
45
|
-
// VPN: mutating / sensitive / log-streaming — CLI-only
|
|
46
|
-
'vpn deploy',
|
|
47
|
-
'vpn logs',
|
|
48
|
-
'vpn client add',
|
|
49
|
-
'vpn client list',
|
|
50
|
-
'vpn client remove',
|
|
51
|
-
]
|
|
52
|
-
|
|
53
|
-
// Accepted version-comment forms on SHA-pinned `uses:` lines:
|
|
54
|
-
// # v6.0.2 (semver tag)
|
|
55
|
-
// # renovate-changesets@0.2.31 (scoped release tag)
|
|
56
|
-
const VERSION_COMMENT_RE = /^#\s+(?:v\d+(?:\.\d+){0,2}|[\w@/-]+@\d+(?:\.\d+){0,2})\s*$/
|
|
57
|
-
|
|
58
|
-
// Match `[- ]uses: owner/repo@<sha>[ trailing]` — step-level (indented, possibly dash-prefixed) and job-level.
|
|
59
|
-
// Uses [ \t] instead of \s to avoid regex backtracking ambiguity between overlapping quantifiers.
|
|
60
|
-
const USES_SHA_LINE_RE = /^[ \t]+(?:-[ \t]+)?uses:[ \t]+(\S+)@([a-f0-9]{7,})(?:[ \t]+(\S.*))?$/
|
|
61
|
-
|
|
62
|
-
interface Violation {
|
|
63
|
-
file: string
|
|
64
|
-
detail: string
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default, so every
|
|
68
|
-
// glob that traverses `.github/` must pass `{ dot: true }`. Without it, the
|
|
69
|
-
// workflow rules silently pass on an empty file set. The tripwire test at the
|
|
70
|
-
// top of the suite asserts the file count is >= 1 to catch this regression.
|
|
71
|
-
function listWorkflowFiles(extension: '.yaml' | '.yml'): string[] {
|
|
72
|
-
const glob = new Bun.Glob(`.github/workflows/*${extension}`)
|
|
73
|
-
return [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function listPackageJsonFiles(): string[] {
|
|
77
|
-
// Bun.Glob auto-excludes node_modules/** by default; no package.json lives under dot-dirs.
|
|
78
|
-
const glob = new Bun.Glob('**/package.json')
|
|
79
|
-
return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(f => !f.includes('/node_modules/'))
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function listShellScriptFiles(): string[] {
|
|
83
|
-
const glob = new Bun.Glob('**/*.sh')
|
|
84
|
-
return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(
|
|
85
|
-
f => !f.includes('/node_modules/') && !f.includes('/.cache/') && !f.includes('/dist/'),
|
|
86
|
-
)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Detect cross-org `secrets: inherit` on reusable-workflow job calls.
|
|
91
|
-
*
|
|
92
|
-
* Rules:
|
|
93
|
-
* - Jobs without a `uses:` string are step-based and out of scope.
|
|
94
|
-
* - `uses:` values starting with `./` or `../` are local reusable workflows; `secrets: inherit` is legitimate.
|
|
95
|
-
* - `uses:` values whose owner (first path segment) is in INTERNAL_ORGS are same-org; `secrets: inherit` is legitimate.
|
|
96
|
-
* - Everything else is cross-org and must not use `secrets: inherit`.
|
|
97
|
-
*/
|
|
98
|
-
export function findCrossOrgSecretsInherit(parsed: unknown): {jobId: string; uses: string}[] {
|
|
99
|
-
if (typeof parsed !== 'object' || parsed === null) return []
|
|
100
|
-
const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
|
|
101
|
-
if (typeof jobs !== 'object' || jobs === null) return []
|
|
102
|
-
|
|
103
|
-
const violations: {jobId: string; uses: string}[] = []
|
|
104
|
-
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
105
|
-
if (typeof jobRaw !== 'object' || jobRaw === null) continue
|
|
106
|
-
const job = jobRaw as {uses?: unknown; secrets?: unknown}
|
|
107
|
-
if (typeof job.uses !== 'string') continue
|
|
108
|
-
if (job.uses.startsWith('./') || job.uses.startsWith('../')) continue
|
|
109
|
-
const owner = job.uses.split('/')[0] ?? ''
|
|
110
|
-
if (INTERNAL_ORGS.has(owner)) continue
|
|
111
|
-
if (job.secrets === 'inherit') {
|
|
112
|
-
violations.push({jobId, uses: job.uses})
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return violations
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Detect dorny/paths-filter steps that use negation patterns without declaring
|
|
120
|
-
* `predicate-quantifier: every`. The default quantifier (`some`) applies OR-logic
|
|
121
|
-
* across patterns, which silently makes negations truthy whenever any other file
|
|
122
|
-
* matches — the opposite of the intended behaviour.
|
|
123
|
-
*
|
|
124
|
-
* Rule: any step using dorny/paths-filter that contains a filter pattern starting
|
|
125
|
-
* with `!` MUST also set `predicate-quantifier: every` in the same step's `with:` block.
|
|
126
|
-
*/
|
|
127
|
-
export interface PathsFilterQuantifierViolation {
|
|
128
|
-
file?: string
|
|
129
|
-
jobId: string
|
|
130
|
-
stepIndex: number
|
|
131
|
-
reason: string
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function findPathsFilterQuantifierViolations(workflowText: string): PathsFilterQuantifierViolation[] {
|
|
135
|
-
const parsed = parseYaml(workflowText, {merge: true}) as unknown
|
|
136
|
-
if (typeof parsed !== 'object' || parsed === null) return []
|
|
137
|
-
const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
|
|
138
|
-
if (typeof jobs !== 'object' || jobs === null) return []
|
|
139
|
-
|
|
140
|
-
const violations: PathsFilterQuantifierViolation[] = []
|
|
141
|
-
|
|
142
|
-
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
143
|
-
if (typeof jobRaw !== 'object' || jobRaw === null) continue
|
|
144
|
-
const job = jobRaw as {steps?: unknown[]}
|
|
145
|
-
if (!Array.isArray(job.steps)) continue
|
|
146
|
-
|
|
147
|
-
for (const [index, stepRaw] of job.steps.entries()) {
|
|
148
|
-
if (typeof stepRaw !== 'object' || stepRaw === null) continue
|
|
149
|
-
const step = stepRaw as {uses?: unknown; with?: Record<string, unknown>}
|
|
150
|
-
if (typeof step.uses !== 'string') continue
|
|
151
|
-
if (!step.uses.startsWith('dorny/paths-filter')) continue
|
|
152
|
-
|
|
153
|
-
const withBlock = step.with ?? {}
|
|
154
|
-
const filtersRaw = withBlock.filters
|
|
155
|
-
if (typeof filtersRaw !== 'string') continue
|
|
156
|
-
|
|
157
|
-
// Parse the inner YAML of the filters block to inspect pattern lists
|
|
158
|
-
const filters = parseYaml(filtersRaw, {merge: true}) as unknown
|
|
159
|
-
if (typeof filters !== 'object' || filters === null) continue
|
|
160
|
-
|
|
161
|
-
let hasNegation = false
|
|
162
|
-
for (const patternsRaw of Object.values(filters as Record<string, unknown>)) {
|
|
163
|
-
const patterns = Array.isArray(patternsRaw) ? patternsRaw : [patternsRaw]
|
|
164
|
-
for (const p of patterns) {
|
|
165
|
-
if (typeof p === 'string' && p.startsWith('!')) {
|
|
166
|
-
hasNegation = true
|
|
167
|
-
break
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (hasNegation) break
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (!hasNegation) continue
|
|
174
|
-
|
|
175
|
-
const quantifier = withBlock['predicate-quantifier']
|
|
176
|
-
if (quantifier !== 'every') {
|
|
177
|
-
violations.push({
|
|
178
|
-
jobId,
|
|
179
|
-
stepIndex: index,
|
|
180
|
-
reason:
|
|
181
|
-
quantifier === undefined
|
|
182
|
-
? `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`
|
|
183
|
-
: `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but predicate-quantifier is '${String(quantifier)}' (must be 'every')`,
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return violations
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
describe('repo conventions', () => {
|
|
193
|
-
it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
|
|
194
|
-
const workflows = listWorkflowFiles('.yaml')
|
|
195
|
-
expect(workflows.length).toBeGreaterThan(0)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it('no `bundledDependencies` in any package.json', async () => {
|
|
199
|
-
const files = listPackageJsonFiles()
|
|
200
|
-
const offenders: string[] = []
|
|
201
|
-
for (const file of files) {
|
|
202
|
-
const json = (await Bun.file(file).json()) as Record<string, unknown>
|
|
203
|
-
if ('bundledDependencies' in json) {
|
|
204
|
-
offenders.push(relative(REPO_ROOT, file))
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
expect(offenders).toEqual([])
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
it("apps/keeweb/config/config.json has settings.dropboxSecret === ''", async () => {
|
|
211
|
-
const configPath = resolve(REPO_ROOT, 'apps/keeweb/config/config.json')
|
|
212
|
-
const config = (await Bun.file(configPath).json()) as {
|
|
213
|
-
settings?: {dropboxSecret?: unknown}
|
|
214
|
-
}
|
|
215
|
-
expect(config.settings?.dropboxSecret).toBe('')
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
it('no `secrets: inherit` on any job whose `uses:` points to a cross-org workflow', async () => {
|
|
219
|
-
const files = listWorkflowFiles('.yaml')
|
|
220
|
-
const violations: Violation[] = []
|
|
221
|
-
for (const file of files) {
|
|
222
|
-
const text = await Bun.file(file).text()
|
|
223
|
-
const parsed = parseYaml(text, {merge: true})
|
|
224
|
-
for (const v of findCrossOrgSecretsInherit(parsed)) {
|
|
225
|
-
violations.push({
|
|
226
|
-
file: relative(REPO_ROOT, file),
|
|
227
|
-
detail: `job '${v.jobId}' uses '${v.uses}' with secrets: inherit`,
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
expect(violations).toEqual([])
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
it('no `ssh-keyscan` under .github/workflows/**', async () => {
|
|
235
|
-
const files = listWorkflowFiles('.yaml')
|
|
236
|
-
const violations: Violation[] = []
|
|
237
|
-
for (const file of files) {
|
|
238
|
-
const text = await Bun.file(file).text()
|
|
239
|
-
const lines = text.split(/\r?\n/)
|
|
240
|
-
for (const [index, line] of lines.entries()) {
|
|
241
|
-
if (/\bssh-keyscan\b/.test(line)) {
|
|
242
|
-
violations.push({
|
|
243
|
-
file: relative(REPO_ROOT, file),
|
|
244
|
-
detail: `line ${index + 1}: ${line.trim()}`,
|
|
245
|
-
})
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
expect(violations).toEqual([])
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
it('every SHA-pinned `uses:` line has a trailing `# vX.Y.Z` or `# scope@X.Y.Z` comment', async () => {
|
|
253
|
-
const files = listWorkflowFiles('.yaml')
|
|
254
|
-
const violations: Violation[] = []
|
|
255
|
-
for (const file of files) {
|
|
256
|
-
const text = await Bun.file(file).text()
|
|
257
|
-
const lines = text.split(/\r?\n/)
|
|
258
|
-
for (const [index, line] of lines.entries()) {
|
|
259
|
-
const match = line.match(USES_SHA_LINE_RE)
|
|
260
|
-
if (!match) continue
|
|
261
|
-
const [, ref = '', sha = '', tail] = match
|
|
262
|
-
const shortSha = sha.slice(0, 7)
|
|
263
|
-
if (tail === undefined) {
|
|
264
|
-
violations.push({
|
|
265
|
-
file: relative(REPO_ROOT, file),
|
|
266
|
-
detail: `line ${index + 1}: missing version comment on '${ref}@${shortSha}…'`,
|
|
267
|
-
})
|
|
268
|
-
} else if (!VERSION_COMMENT_RE.test(tail.trim())) {
|
|
269
|
-
violations.push({
|
|
270
|
-
file: relative(REPO_ROOT, file),
|
|
271
|
-
detail: `line ${index + 1}: malformed version comment '${tail.trim()}' on '${ref}@${shortSha}…'`,
|
|
272
|
-
})
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
expect(violations).toEqual([])
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
it('no `.yml` files under .github/workflows/ (use `.yaml`)', () => {
|
|
280
|
-
const files = listWorkflowFiles('.yml')
|
|
281
|
-
expect(files.map(f => relative(REPO_ROOT, f))).toEqual([])
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
it('no `.sh` files outside `apps/keeweb/deploy.sh`', () => {
|
|
285
|
-
const files = listShellScriptFiles()
|
|
286
|
-
const offenders = files.map(f => relative(REPO_ROOT, f)).filter(f => !ALLOWED_SHELL_SCRIPTS.has(f))
|
|
287
|
-
expect(offenders).toEqual([])
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
it('opencode.jsonc mcp.infra.command uses local repo source (not bunx published package)', async () => {
|
|
291
|
-
const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
|
|
292
|
-
const stripped = jsoncText
|
|
293
|
-
.split('\n')
|
|
294
|
-
.map(line => (/^\s*\/\//.test(line) ? '' : line))
|
|
295
|
-
.join('\n')
|
|
296
|
-
.replaceAll(/,(\s*[}\]])/g, '$1')
|
|
297
|
-
const opencode = JSON.parse(stripped) as {mcp?: {infra?: {command?: unknown}}}
|
|
298
|
-
|
|
299
|
-
const infraCommand = opencode.mcp?.infra?.command
|
|
300
|
-
expect(Array.isArray(infraCommand), 'mcp.infra.command must be an array').toBe(true)
|
|
301
|
-
|
|
302
|
-
const cmd = infraCommand as string[]
|
|
303
|
-
expect(
|
|
304
|
-
cmd,
|
|
305
|
-
'mcp.infra.command must be ["bun", "run", "packages/cli/src/cli.ts", "mcp"] — use local source, not bunx',
|
|
306
|
-
).toEqual(['bun', 'run', 'packages/cli/src/cli.ts', 'mcp'])
|
|
307
|
-
|
|
308
|
-
// Explicit regression guard: bunx @marcusrbrown/infra resolves stale published
|
|
309
|
-
// package cache and exits before the MCP handshake, causing connection closed.
|
|
310
|
-
expect(cmd.join(' ')).not.toContain('bunx')
|
|
311
|
-
expect(cmd.join(' ')).not.toContain('@marcusrbrown/infra')
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
it('gates every sensitive infra MCP tool in opencode.jsonc', async () => {
|
|
315
|
-
// Parse opencode.jsonc tolerantly: it uses JSONC syntax (// line comments,
|
|
316
|
-
// trailing commas). Strategy:
|
|
317
|
-
// 1. Strip full-line comments — lines whose first non-whitespace is `//`.
|
|
318
|
-
// This avoids breaking the `https://` in the $schema string value.
|
|
319
|
-
// 2. Strip trailing commas before `}` or `]`.
|
|
320
|
-
// 3. JSON.parse the result.
|
|
321
|
-
const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
|
|
322
|
-
const stripped = jsoncText
|
|
323
|
-
.split('\n')
|
|
324
|
-
.map(line => (/^\s*\/\//.test(line) ? '' : line))
|
|
325
|
-
.join('\n')
|
|
326
|
-
.replaceAll(/,(\s*[}\]])/g, '$1')
|
|
327
|
-
const opencode = JSON.parse(stripped) as {permission?: Record<string, unknown>}
|
|
328
|
-
|
|
329
|
-
// Parse sanity: permission block must be present (catches a broken parse).
|
|
330
|
-
// opencode permission-checks MCP tool calls via the `permission` map (tool ids
|
|
331
|
-
// `<server>_<tool>`); we assert the canonical `permission: "deny"` form here.
|
|
332
|
-
const permission = opencode.permission ?? {}
|
|
333
|
-
expect(Object.keys(permission).length).toBeGreaterThan(0)
|
|
334
|
-
|
|
335
|
-
const violations: string[] = []
|
|
336
|
-
for (const cmd of SENSITIVE_MCP_COMMANDS) {
|
|
337
|
-
// (a) Primary gate: the command must NOT be in MCP_ALLOWLIST (source-gated out).
|
|
338
|
-
// If someone re-adds it to the allowlist, this catches the regression immediately.
|
|
339
|
-
if (MCP_ALLOWLIST.has(cmd)) {
|
|
340
|
-
violations.push(
|
|
341
|
-
`'${cmd}' is in SENSITIVE_MCP_COMMANDS AND in MCP_ALLOWLIST — source-gate regression: remove it from MCP_ALLOWLIST`,
|
|
342
|
-
)
|
|
343
|
-
}
|
|
344
|
-
// (b) Defense-in-depth backstop: the tool must still be denied in opencode.jsonc.
|
|
345
|
-
// This catches the case where MCP_ALLOWLIST is mistakenly re-expanded.
|
|
346
|
-
// The infra MCP server converts command names to tool names with underscores,
|
|
347
|
-
// and OpenCode prefixes with the server name, giving "infra_<underscored>".
|
|
348
|
-
const toolId = `infra_${cmd.replaceAll(' ', '_')}`
|
|
349
|
-
if (permission[toolId] !== 'deny') {
|
|
350
|
-
violations.push(
|
|
351
|
-
`${toolId}: not denied in opencode.jsonc permission (found: ${JSON.stringify(permission[toolId])}) — defense-in-depth backstop missing`,
|
|
352
|
-
)
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
expect(
|
|
356
|
-
violations,
|
|
357
|
-
`Sensitive infra MCP tools must be source-gated (not in MCP_ALLOWLIST) and denied in opencode.jsonc:\n${violations.join('\n')}`,
|
|
358
|
-
).toEqual([])
|
|
359
|
-
})
|
|
360
|
-
})
|
|
361
|
-
|
|
362
|
-
// ---------------------------------------------------------------------------
|
|
363
|
-
// (enforced) marker drift detection
|
|
364
|
-
// ---------------------------------------------------------------------------
|
|
365
|
-
//
|
|
366
|
-
// Every bullet in AGENTS.md tagged `(enforced)` must map to a known
|
|
367
|
-
// enforcement mechanism (test or ESLint rule). Adding `(enforced)` without
|
|
368
|
-
// backing enforcement, or deleting the enforcement while keeping the marker,
|
|
369
|
-
// both cause a test failure here.
|
|
370
|
-
//
|
|
371
|
-
// Manifest keys are unique substrings of the AGENTS.md bullet text.
|
|
372
|
-
// Values describe where the enforcement lives — they are documentation only.
|
|
373
|
-
const ENFORCED_MANIFEST: Record<string, string> = {
|
|
374
|
-
'Only bash script': 'conventions.test.ts: no .sh files outside apps/keeweb/deploy.sh',
|
|
375
|
-
'GitHub Actions': 'conventions.test.ts: .yaml extension + SHA-pin version comment',
|
|
376
|
-
'Cross-org reusable workflows': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
|
|
377
|
-
'as any': 'eslint.config.ts: @typescript-eslint/no-explicit-any + ban-ts-comment at error',
|
|
378
|
-
'No secret values in tracked files': 'conventions.test.ts: settings.dropboxSecret === empty string',
|
|
379
|
-
'ssh-keyscan': 'conventions.test.ts: no ssh-keyscan under .github/workflows/**',
|
|
380
|
-
'Never `secrets: inherit`': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
|
|
381
|
-
bundledDependencies: 'conventions.test.ts: no bundledDependencies in any package.json',
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
describe('(enforced) marker drift', () => {
|
|
385
|
-
it('every (enforced) bullet in AGENTS.md is accounted for in the enforcement manifest', async () => {
|
|
386
|
-
const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
|
|
387
|
-
const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
|
|
388
|
-
|
|
389
|
-
// Tripwire — if the grep logic breaks, the whole suite silently passes with 0 checks
|
|
390
|
-
expect(enforcedLines.length).toBeGreaterThan(0)
|
|
391
|
-
|
|
392
|
-
const unmatched = enforcedLines.filter(
|
|
393
|
-
(line: string) => !Object.keys(ENFORCED_MANIFEST).some((key: string) => line.includes(key)),
|
|
394
|
-
)
|
|
395
|
-
expect(unmatched, 'New (enforced) bullet has no manifest entry — add enforcement before tagging').toEqual([])
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
it('every manifest entry corresponds to an actual (enforced) bullet in AGENTS.md', async () => {
|
|
399
|
-
const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
|
|
400
|
-
const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
|
|
401
|
-
|
|
402
|
-
const stale = Object.keys(ENFORCED_MANIFEST).filter(
|
|
403
|
-
(key: string) => !enforcedLines.some((l: string) => l.includes(key)),
|
|
404
|
-
)
|
|
405
|
-
expect(stale, 'Manifest entry has no matching (enforced) bullet in AGENTS.md — remove or update').toEqual([])
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
it('@typescript-eslint/no-explicit-any is configured at error severity in eslint.config.ts', async () => {
|
|
409
|
-
const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
|
|
410
|
-
// Matches: '@typescript-eslint/no-explicit-any': 'error'
|
|
411
|
-
expect(eslintConfig).toMatch(/'@typescript-eslint\/no-explicit-any'\s*:\s*'error'/)
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
it('@typescript-eslint/ban-ts-comment is configured in eslint.config.ts', async () => {
|
|
415
|
-
const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
|
|
416
|
-
expect(eslintConfig).toContain('@typescript-eslint/ban-ts-comment')
|
|
417
|
-
})
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
// ---------------------------------------------------------------------------
|
|
421
|
-
// Per-app invariants
|
|
422
|
-
// ---------------------------------------------------------------------------
|
|
423
|
-
//
|
|
424
|
-
// Guard that critical safety mechanisms in each app are not accidentally
|
|
425
|
-
// removed. These are the runtime equivalents of (enforced) markers: code that
|
|
426
|
-
// must remain present for the documented behaviour to hold.
|
|
427
|
-
|
|
428
|
-
describe('per-app invariants', () => {
|
|
429
|
-
it('cliproxy deploy.ts guards config.yaml upload with a remoteFileExists() check', async () => {
|
|
430
|
-
const deployTs = await Bun.file(resolve(REPO_ROOT, 'apps/cliproxy/src/deploy.ts')).text()
|
|
431
|
-
// The guard: remoteFileExists(host, `${REMOTE_DIR}/config/config.yaml`, env)
|
|
432
|
-
// This prevents overwriting runtime API keys on the server.
|
|
433
|
-
expect(deployTs).toContain('remoteFileExists')
|
|
434
|
-
expect(deployTs).toContain('config.yaml')
|
|
435
|
-
})
|
|
436
|
-
|
|
437
|
-
it('keeweb build.ts defines an EXPECTED_SHA256 constant for archive integrity', async () => {
|
|
438
|
-
const buildTs = await Bun.file(resolve(REPO_ROOT, 'apps/keeweb/src/build.ts')).text()
|
|
439
|
-
// EXPECTED_SHA256 is the KeeWeb release zip checksum; its presence proves
|
|
440
|
-
// SHA verification is wired in and was not accidentally stripped.
|
|
441
|
-
expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
it('deploy-gateway.yaml does NOT install doctl (firewall is provisioning-only, not deploy-time)', async () => {
|
|
445
|
-
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
446
|
-
// The DO Cloud Firewall is created in provisioning (provision-droplet.ts), not in the deploy hot path.
|
|
447
|
-
// The deploy job must NOT install doctl — it no longer needs it.
|
|
448
|
-
expect(text).not.toMatch(/uses:\s+digitalocean\/action-doctl@[a-f0-9]{40}/)
|
|
449
|
-
expect(text).not.toContain('digitalocean/action-doctl')
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
it('deploy-gateway.yaml does NOT forward DIGITALOCEAN_ACCESS_TOKEN to the deploy step', async () => {
|
|
453
|
-
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
454
|
-
// DIGITALOCEAN_ACCESS_TOKEN is only needed for provisioning, not for the deploy hot path.
|
|
455
|
-
// Verify it is not forwarded to the deploy step env block.
|
|
456
|
-
const deployStepIndex = text.indexOf('name: Deploy gateway')
|
|
457
|
-
expect(deployStepIndex).toBeGreaterThan(-1)
|
|
458
|
-
const afterDeployStep = text.slice(deployStepIndex)
|
|
459
|
-
expect(afterDeployStep).not.toContain('DIGITALOCEAN_ACCESS_TOKEN')
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
it('deploy-gateway.yaml still forwards GATEWAY_VPC_IP and DASHBOARD_VPC_IP to the deploy step', async () => {
|
|
463
|
-
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
464
|
-
// GATEWAY_VPC_IP and DASHBOARD_VPC_IP are still needed by deploy.ts for the compose VPC-IP
|
|
465
|
-
// publish and the DOCKER-USER iptables rule.
|
|
466
|
-
const deployStepIndex = text.indexOf('name: Deploy gateway')
|
|
467
|
-
expect(deployStepIndex).toBeGreaterThan(-1)
|
|
468
|
-
const afterDeployStep = text.slice(deployStepIndex)
|
|
469
|
-
expect(afterDeployStep).toContain('GATEWAY_VPC_IP')
|
|
470
|
-
expect(afterDeployStep).toContain('DASHBOARD_VPC_IP')
|
|
471
|
-
})
|
|
472
|
-
})
|
|
473
|
-
|
|
474
|
-
describe('findCrossOrgSecretsInherit', () => {
|
|
475
|
-
it('flags a cross-org reusable workflow that uses `secrets: inherit`', () => {
|
|
476
|
-
const parsed = parseYaml(`
|
|
477
|
-
jobs:
|
|
478
|
-
build:
|
|
479
|
-
uses: bfra-me/.github/.github/workflows/example.yaml@abc1234
|
|
480
|
-
secrets: inherit
|
|
481
|
-
`)
|
|
482
|
-
const violations = findCrossOrgSecretsInherit(parsed)
|
|
483
|
-
expect(violations).toHaveLength(1)
|
|
484
|
-
expect(violations[0]?.jobId).toBe('build')
|
|
485
|
-
expect(violations[0]?.uses).toContain('bfra-me/')
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
it('allows a same-org reusable workflow to use `secrets: inherit`', () => {
|
|
489
|
-
const parsed = parseYaml(`
|
|
490
|
-
jobs:
|
|
491
|
-
release:
|
|
492
|
-
uses: marcusrbrown/infra/.github/workflows/release.yaml@sha
|
|
493
|
-
secrets: inherit
|
|
494
|
-
`)
|
|
495
|
-
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
it('allows a local (./) reusable workflow to use `secrets: inherit`', () => {
|
|
499
|
-
const parsed = parseYaml(`
|
|
500
|
-
jobs:
|
|
501
|
-
build:
|
|
502
|
-
uses: ./.github/workflows/local.yaml
|
|
503
|
-
secrets: inherit
|
|
504
|
-
`)
|
|
505
|
-
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
506
|
-
})
|
|
507
|
-
|
|
508
|
-
it('ignores `secrets: inherit` strings appearing inside prose block scalars', () => {
|
|
509
|
-
// Mirrors fro-bot.yaml: SCHEDULE_PROMPT contains the literal string
|
|
510
|
-
// `secrets: inherit` inside a prompt heredoc, but no job-level key exists.
|
|
511
|
-
const parsed = parseYaml(`
|
|
512
|
-
env:
|
|
513
|
-
SCHEDULE_PROMPT: |
|
|
514
|
-
Remember: never use secrets: inherit with cross-org workflows.
|
|
515
|
-
jobs:
|
|
516
|
-
run:
|
|
517
|
-
runs-on: ubuntu-latest
|
|
518
|
-
steps:
|
|
519
|
-
- run: echo hi
|
|
520
|
-
`)
|
|
521
|
-
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
it('does not flag jobs without a `uses:` key (step-based jobs)', () => {
|
|
525
|
-
const parsed = parseYaml(`
|
|
526
|
-
jobs:
|
|
527
|
-
lint:
|
|
528
|
-
runs-on: ubuntu-latest
|
|
529
|
-
steps:
|
|
530
|
-
- run: echo hi
|
|
531
|
-
`)
|
|
532
|
-
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
533
|
-
})
|
|
534
|
-
})
|
|
535
|
-
|
|
536
|
-
describe('findPathsFilterQuantifierViolations', () => {
|
|
537
|
-
it('paths-filter with negations and predicate-quantifier: every → 0 violations', () => {
|
|
538
|
-
const yaml = `
|
|
539
|
-
jobs:
|
|
540
|
-
detect:
|
|
541
|
-
runs-on: ubuntu-latest
|
|
542
|
-
steps:
|
|
543
|
-
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
544
|
-
with:
|
|
545
|
-
predicate-quantifier: every
|
|
546
|
-
filters: |
|
|
547
|
-
app:
|
|
548
|
-
- 'apps/myapp/**'
|
|
549
|
-
- '!apps/myapp/**/*.md'
|
|
550
|
-
`
|
|
551
|
-
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
it('paths-filter with negations and missing predicate-quantifier → 1 violation', () => {
|
|
555
|
-
const yaml = `
|
|
556
|
-
jobs:
|
|
557
|
-
detect:
|
|
558
|
-
runs-on: ubuntu-latest
|
|
559
|
-
steps:
|
|
560
|
-
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
561
|
-
with:
|
|
562
|
-
filters: |
|
|
563
|
-
app:
|
|
564
|
-
- 'apps/myapp/**'
|
|
565
|
-
- '!apps/myapp/**/*.md'
|
|
566
|
-
`
|
|
567
|
-
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
568
|
-
expect(violations).toEqual([
|
|
569
|
-
{
|
|
570
|
-
jobId: 'detect',
|
|
571
|
-
stepIndex: 0,
|
|
572
|
-
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
|
|
573
|
-
},
|
|
574
|
-
])
|
|
575
|
-
})
|
|
576
|
-
|
|
577
|
-
it('paths-filter with negations and predicate-quantifier: some → 1 violation', () => {
|
|
578
|
-
const yaml = `
|
|
579
|
-
jobs:
|
|
580
|
-
detect:
|
|
581
|
-
runs-on: ubuntu-latest
|
|
582
|
-
steps:
|
|
583
|
-
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
584
|
-
with:
|
|
585
|
-
predicate-quantifier: some
|
|
586
|
-
filters: |
|
|
587
|
-
app:
|
|
588
|
-
- 'apps/myapp/**'
|
|
589
|
-
- '!apps/myapp/**/*.md'
|
|
590
|
-
`
|
|
591
|
-
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
592
|
-
expect(violations).toEqual([
|
|
593
|
-
{
|
|
594
|
-
jobId: 'detect',
|
|
595
|
-
stepIndex: 0,
|
|
596
|
-
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but predicate-quantifier is 'some' (must be 'every')`,
|
|
597
|
-
},
|
|
598
|
-
])
|
|
599
|
-
})
|
|
600
|
-
|
|
601
|
-
it('paths-filter without negations → 0 violations regardless of quantifier', () => {
|
|
602
|
-
const yaml = `
|
|
603
|
-
jobs:
|
|
604
|
-
detect:
|
|
605
|
-
runs-on: ubuntu-latest
|
|
606
|
-
steps:
|
|
607
|
-
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
608
|
-
with:
|
|
609
|
-
filters: |
|
|
610
|
-
app:
|
|
611
|
-
- 'apps/myapp/**'
|
|
612
|
-
- 'apps/myapp/**/*.ts'
|
|
613
|
-
`
|
|
614
|
-
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
615
|
-
})
|
|
616
|
-
|
|
617
|
-
it('bare-string negation filter without predicate-quantifier → 1 violation', () => {
|
|
618
|
-
const yaml = `
|
|
619
|
-
jobs:
|
|
620
|
-
detect:
|
|
621
|
-
runs-on: ubuntu-latest
|
|
622
|
-
steps:
|
|
623
|
-
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
624
|
-
with:
|
|
625
|
-
filters: |
|
|
626
|
-
cliproxy: '!apps/cliproxy/**/*.md'
|
|
627
|
-
`
|
|
628
|
-
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
629
|
-
expect(violations).toEqual([
|
|
630
|
-
{
|
|
631
|
-
jobId: 'detect',
|
|
632
|
-
stepIndex: 0,
|
|
633
|
-
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
|
|
634
|
-
},
|
|
635
|
-
])
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
it('bare-string negation filter with predicate-quantifier: every → 0 violations', () => {
|
|
639
|
-
const yaml = `
|
|
640
|
-
jobs:
|
|
641
|
-
detect:
|
|
642
|
-
runs-on: ubuntu-latest
|
|
643
|
-
steps:
|
|
644
|
-
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
645
|
-
with:
|
|
646
|
-
predicate-quantifier: every
|
|
647
|
-
filters: |
|
|
648
|
-
cliproxy: '!apps/cliproxy/**/*.md'
|
|
649
|
-
`
|
|
650
|
-
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
651
|
-
})
|
|
652
|
-
})
|
|
653
|
-
|
|
654
|
-
describe('dorny/paths-filter quantifier guard', () => {
|
|
655
|
-
it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
|
|
656
|
-
// `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default unless `dot: true` is set.
|
|
657
|
-
const glob = new Bun.Glob('.github/workflows/**')
|
|
658
|
-
const files = [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
|
|
659
|
-
expect(files.length).toBeGreaterThan(0)
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
it('all workflow files using dorny/paths-filter with negations declare predicate-quantifier: every', async () => {
|
|
663
|
-
const files = listWorkflowFiles('.yaml')
|
|
664
|
-
expect(files.length).toBeGreaterThan(0)
|
|
665
|
-
|
|
666
|
-
const violations: PathsFilterQuantifierViolation[] = []
|
|
667
|
-
for (const file of files) {
|
|
668
|
-
const text = await Bun.file(file).text()
|
|
669
|
-
for (const v of findPathsFilterQuantifierViolations(text)) {
|
|
670
|
-
violations.push({...v, file: relative(REPO_ROOT, file)})
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
expect(violations).toEqual([])
|
|
674
|
-
})
|
|
675
|
-
})
|
|
676
|
-
|
|
677
|
-
// ─── operator auth/config secrets and tuning vars ────────────────────────────
|
|
678
|
-
//
|
|
679
|
-
// These assert the four operator auth/config secrets and three optional tuning vars are
|
|
680
|
-
// wired through deploy-gateway.yaml (workflow_call.secrets + inputs + deploy step env),
|
|
681
|
-
// deploy.yaml (fan-out secrets + with inputs), and the CLI passthrough.
|
|
682
|
-
|
|
683
|
-
const OPERATOR_AUTH_SECRETS = [
|
|
684
|
-
'GATEWAY_OPERATOR_GITHUB_CLIENT_ID',
|
|
685
|
-
'GATEWAY_OPERATOR_GITHUB_CLIENT_SECRET',
|
|
686
|
-
'GATEWAY_OPERATOR_CSRF_SECRET',
|
|
687
|
-
'GATEWAY_OPERATOR_ALLOWLIST',
|
|
688
|
-
] as const
|
|
689
|
-
|
|
690
|
-
const OPERATOR_TUNING_SECRETS = [
|
|
691
|
-
'GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS',
|
|
692
|
-
'GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS',
|
|
693
|
-
'GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS',
|
|
694
|
-
] as const
|
|
695
|
-
|
|
696
|
-
describe('deploy-gateway.yaml: operator auth/config secrets in workflow_call.secrets', () => {
|
|
697
|
-
const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
|
|
698
|
-
|
|
699
|
-
for (const secret of OPERATOR_AUTH_SECRETS) {
|
|
700
|
-
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
701
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
702
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
703
|
-
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
704
|
-
expect(secrets).toHaveProperty(secret)
|
|
705
|
-
expect(secrets[secret]?.required).toBe(false)
|
|
706
|
-
})
|
|
707
|
-
}
|
|
708
|
-
})
|
|
709
|
-
|
|
710
|
-
describe('deploy-gateway.yaml: operator tuning vars in workflow_call.secrets', () => {
|
|
711
|
-
const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
|
|
712
|
-
|
|
713
|
-
for (const secret of OPERATOR_TUNING_SECRETS) {
|
|
714
|
-
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
715
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
716
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
717
|
-
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
718
|
-
expect(secrets).toHaveProperty(secret)
|
|
719
|
-
expect(secrets[secret]?.required).toBe(false)
|
|
720
|
-
})
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
it('workflow_call.inputs does NOT declare any GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
|
|
724
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
725
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {inputs?: Record<string, unknown>}}}
|
|
726
|
-
const inputs = parsed?.on?.workflow_call?.inputs ?? {}
|
|
727
|
-
for (const secret of OPERATOR_TUNING_SECRETS) {
|
|
728
|
-
expect(inputs).not.toHaveProperty(secret)
|
|
729
|
-
}
|
|
730
|
-
})
|
|
731
|
-
})
|
|
732
|
-
|
|
733
|
-
describe('deploy-gateway.yaml: deploy step env forwards operator auth/config vars', () => {
|
|
734
|
-
const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
|
|
735
|
-
|
|
736
|
-
for (const secret of OPERATOR_AUTH_SECRETS) {
|
|
737
|
-
it(`Deploy gateway step env forwards ${secret}`, async () => {
|
|
738
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
739
|
-
const parsed = parseYaml(text) as {
|
|
740
|
-
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
741
|
-
}
|
|
742
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
743
|
-
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
744
|
-
expect(deployStep).toBeDefined()
|
|
745
|
-
expect(deployStep?.env).toHaveProperty(secret)
|
|
746
|
-
expect(deployStep?.env?.[secret]).toContain(secret)
|
|
747
|
-
})
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
for (const secret of OPERATOR_TUNING_SECRETS) {
|
|
751
|
-
it(`Deploy gateway step env forwards ${secret} from secrets context (not inputs)`, async () => {
|
|
752
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
753
|
-
const parsed = parseYaml(text) as {
|
|
754
|
-
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
755
|
-
}
|
|
756
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
757
|
-
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
758
|
-
expect(deployStep).toBeDefined()
|
|
759
|
-
expect(deployStep?.env).toHaveProperty(secret)
|
|
760
|
-
// Must source from secrets context, not inputs context
|
|
761
|
-
expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
|
|
762
|
-
})
|
|
763
|
-
}
|
|
764
|
-
})
|
|
765
|
-
|
|
766
|
-
describe('deploy.yaml: fan-out passes operator auth/config secrets to deploy-gateway job', () => {
|
|
767
|
-
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
768
|
-
|
|
769
|
-
for (const secret of OPERATOR_AUTH_SECRETS) {
|
|
770
|
-
it(`deploy-gateway job secrets block passes ${secret}`, async () => {
|
|
771
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
772
|
-
const parsed = parseYaml(text) as {
|
|
773
|
-
jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
|
|
774
|
-
}
|
|
775
|
-
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
776
|
-
expect(secrets).toHaveProperty(secret)
|
|
777
|
-
expect(secrets[secret]).toContain(secret)
|
|
778
|
-
})
|
|
779
|
-
}
|
|
780
|
-
})
|
|
781
|
-
|
|
782
|
-
describe('deploy.yaml: fan-out passes operator tuning vars via secrets: to deploy-gateway job', () => {
|
|
783
|
-
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
784
|
-
|
|
785
|
-
for (const secret of OPERATOR_TUNING_SECRETS) {
|
|
786
|
-
it(`deploy-gateway job secrets block passes ${secret}`, async () => {
|
|
787
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
788
|
-
const parsed = parseYaml(text) as {
|
|
789
|
-
jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
|
|
790
|
-
}
|
|
791
|
-
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
792
|
-
expect(secrets).toHaveProperty(secret)
|
|
793
|
-
expect(secrets[secret]).toContain(secret)
|
|
794
|
-
})
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
it('deploy-gateway job does NOT have a with: block for GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
|
|
798
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
799
|
-
const parsed = parseYaml(text) as {
|
|
800
|
-
jobs?: {'deploy-gateway'?: {with?: Record<string, unknown>}}
|
|
801
|
-
}
|
|
802
|
-
const withBlock = parsed?.jobs?.['deploy-gateway']?.with ?? {}
|
|
803
|
-
for (const secret of OPERATOR_TUNING_SECRETS) {
|
|
804
|
-
expect(withBlock).not.toHaveProperty(secret)
|
|
805
|
-
}
|
|
806
|
-
})
|
|
807
|
-
})
|
|
808
|
-
|
|
809
|
-
describe('CLI getGatewayDeployEnv: operator auth/config passthrough', () => {
|
|
810
|
-
it('getGatewayDeployEnv includes all four operator auth/config secret vars', async () => {
|
|
811
|
-
const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
|
|
812
|
-
// Provide required env vars
|
|
813
|
-
const origEnv = {...process.env}
|
|
814
|
-
process.env.PATH = '/usr/bin'
|
|
815
|
-
process.env.HOME = '/home/test'
|
|
816
|
-
process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
|
|
817
|
-
try {
|
|
818
|
-
const env = getGatewayDeployEnv()
|
|
819
|
-
for (const secret of OPERATOR_AUTH_SECRETS) {
|
|
820
|
-
expect(env).toHaveProperty(secret)
|
|
821
|
-
}
|
|
822
|
-
} finally {
|
|
823
|
-
Object.assign(process.env, origEnv)
|
|
824
|
-
}
|
|
825
|
-
})
|
|
826
|
-
|
|
827
|
-
it('getGatewayDeployEnv includes all three operator tuning vars', async () => {
|
|
828
|
-
const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
|
|
829
|
-
const origEnv = {...process.env}
|
|
830
|
-
process.env.PATH = '/usr/bin'
|
|
831
|
-
process.env.HOME = '/home/test'
|
|
832
|
-
process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
|
|
833
|
-
try {
|
|
834
|
-
const env = getGatewayDeployEnv()
|
|
835
|
-
for (const secret of OPERATOR_TUNING_SECRETS) {
|
|
836
|
-
expect(env).toHaveProperty(secret)
|
|
837
|
-
}
|
|
838
|
-
} finally {
|
|
839
|
-
Object.assign(process.env, origEnv)
|
|
840
|
-
}
|
|
841
|
-
})
|
|
842
|
-
|
|
843
|
-
it('getGatewayDeployEnv includes GATEWAY_IMAGE_DIGEST', async () => {
|
|
844
|
-
const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
|
|
845
|
-
const origEnv = {...process.env}
|
|
846
|
-
process.env.PATH = '/usr/bin'
|
|
847
|
-
process.env.HOME = '/home/test'
|
|
848
|
-
process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
|
|
849
|
-
process.env.GATEWAY_IMAGE_DIGEST = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
|
850
|
-
try {
|
|
851
|
-
const env = getGatewayDeployEnv()
|
|
852
|
-
expect(env).toHaveProperty('GATEWAY_IMAGE_DIGEST')
|
|
853
|
-
expect(env.GATEWAY_IMAGE_DIGEST).toBe('sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
|
|
854
|
-
} finally {
|
|
855
|
-
Object.assign(process.env, origEnv)
|
|
856
|
-
}
|
|
857
|
-
})
|
|
858
|
-
|
|
859
|
-
it('getGatewayDeployEnv includes WORKSPACE_IMAGE_DIGEST', async () => {
|
|
860
|
-
const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
|
|
861
|
-
const origEnv = {...process.env}
|
|
862
|
-
process.env.PATH = '/usr/bin'
|
|
863
|
-
process.env.HOME = '/home/test'
|
|
864
|
-
process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
|
|
865
|
-
process.env.WORKSPACE_IMAGE_DIGEST = 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
|
866
|
-
try {
|
|
867
|
-
const env = getGatewayDeployEnv()
|
|
868
|
-
expect(env).toHaveProperty('WORKSPACE_IMAGE_DIGEST')
|
|
869
|
-
expect(env.WORKSPACE_IMAGE_DIGEST).toBe('sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')
|
|
870
|
-
} finally {
|
|
871
|
-
Object.assign(process.env, origEnv)
|
|
872
|
-
}
|
|
873
|
-
})
|
|
874
|
-
|
|
875
|
-
it('getGatewayDeployEnv includes GATEWAY_VPC_IP and DASHBOARD_VPC_IP (but NOT DIGITALOCEAN_ACCESS_TOKEN)', async () => {
|
|
876
|
-
const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
|
|
877
|
-
const origEnv = {...process.env}
|
|
878
|
-
process.env.PATH = '/usr/bin'
|
|
879
|
-
process.env.HOME = '/home/test'
|
|
880
|
-
process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
|
|
881
|
-
try {
|
|
882
|
-
const env = getGatewayDeployEnv()
|
|
883
|
-
// VPC IPs are still needed by deploy.ts for compose publish + DOCKER-USER rule
|
|
884
|
-
expect(env).toHaveProperty('GATEWAY_VPC_IP')
|
|
885
|
-
expect(env).toHaveProperty('DASHBOARD_VPC_IP')
|
|
886
|
-
// DIGITALOCEAN_ACCESS_TOKEN is provisioning-only — must NOT be in the deploy env
|
|
887
|
-
expect(env).not.toHaveProperty('DIGITALOCEAN_ACCESS_TOKEN')
|
|
888
|
-
} finally {
|
|
889
|
-
Object.assign(process.env, origEnv)
|
|
890
|
-
}
|
|
891
|
-
})
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
// ─── deploy.yaml: aggregate router passes operator secrets to deploy-gateway ──
|
|
895
|
-
|
|
896
|
-
describe('deploy.yaml: aggregate router forwards operator secrets to deploy-gateway job', () => {
|
|
897
|
-
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
898
|
-
|
|
899
|
-
it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_HOST', async () => {
|
|
900
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
901
|
-
const parsed = parseYaml(text) as {
|
|
902
|
-
jobs?: {
|
|
903
|
-
'deploy-gateway'?: {
|
|
904
|
-
secrets?: Record<string, string>
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
909
|
-
expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
|
|
910
|
-
expect(secrets.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
|
|
911
|
-
})
|
|
912
|
-
|
|
913
|
-
it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_PORT', async () => {
|
|
914
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
915
|
-
const parsed = parseYaml(text) as {
|
|
916
|
-
jobs?: {
|
|
917
|
-
'deploy-gateway'?: {
|
|
918
|
-
secrets?: Record<string, string>
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
923
|
-
expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
|
|
924
|
-
expect(secrets.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
|
|
925
|
-
})
|
|
926
|
-
|
|
927
|
-
it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
|
|
928
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
929
|
-
const parsed = parseYaml(text) as {
|
|
930
|
-
jobs?: {
|
|
931
|
-
'deploy-gateway'?: {
|
|
932
|
-
secrets?: Record<string, string>
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
937
|
-
expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
938
|
-
expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
939
|
-
})
|
|
940
|
-
})
|
|
941
|
-
|
|
942
|
-
// ─── deploy-gateway.yaml: optional operator secret declarations ───────────────
|
|
943
|
-
|
|
944
|
-
describe('deploy-gateway.yaml: optional operator secret declarations (issue 1)', () => {
|
|
945
|
-
const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
|
|
946
|
-
|
|
947
|
-
it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_HOST as optional', async () => {
|
|
948
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
949
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
950
|
-
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
951
|
-
expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
|
|
952
|
-
expect(secrets.GATEWAY_OPERATOR_BIND_HOST?.required).toBe(false)
|
|
953
|
-
})
|
|
954
|
-
|
|
955
|
-
it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_PORT as optional', async () => {
|
|
956
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
957
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
958
|
-
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
959
|
-
expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
|
|
960
|
-
expect(secrets.GATEWAY_OPERATOR_BIND_PORT?.required).toBe(false)
|
|
961
|
-
})
|
|
962
|
-
|
|
963
|
-
it('workflow_call.secrets declares GATEWAY_OPERATOR_PUBLIC_ORIGIN as optional', async () => {
|
|
964
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
965
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
966
|
-
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
967
|
-
expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
968
|
-
expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN?.required).toBe(false)
|
|
969
|
-
})
|
|
970
|
-
|
|
971
|
-
it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_HOST', async () => {
|
|
972
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
973
|
-
const parsed = parseYaml(text) as {
|
|
974
|
-
jobs?: {
|
|
975
|
-
'deploy-gateway'?: {
|
|
976
|
-
steps?: {name?: string; env?: Record<string, string>}[]
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
981
|
-
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
982
|
-
expect(deployStep).toBeDefined()
|
|
983
|
-
expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
|
|
984
|
-
expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
|
|
985
|
-
})
|
|
986
|
-
|
|
987
|
-
it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_PORT', async () => {
|
|
988
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
989
|
-
const parsed = parseYaml(text) as {
|
|
990
|
-
jobs?: {
|
|
991
|
-
'deploy-gateway'?: {
|
|
992
|
-
steps?: {name?: string; env?: Record<string, string>}[]
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
997
|
-
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
998
|
-
expect(deployStep).toBeDefined()
|
|
999
|
-
expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
|
|
1000
|
-
expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
|
|
1001
|
-
})
|
|
1002
|
-
|
|
1003
|
-
it('Deploy gateway step env forwards GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
|
|
1004
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1005
|
-
const parsed = parseYaml(text) as {
|
|
1006
|
-
jobs?: {
|
|
1007
|
-
'deploy-gateway'?: {
|
|
1008
|
-
steps?: {name?: string; env?: Record<string, string>}[]
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1013
|
-
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
1014
|
-
expect(deployStep).toBeDefined()
|
|
1015
|
-
expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
1016
|
-
expect(deployStep?.env?.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
1017
|
-
})
|
|
1018
|
-
|
|
1019
|
-
it('operator vars are NOT in the required-secret validation step env', async () => {
|
|
1020
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1021
|
-
const parsed = parseYaml(text) as {
|
|
1022
|
-
jobs?: {
|
|
1023
|
-
'deploy-gateway'?: {
|
|
1024
|
-
steps?: {name?: string; env?: Record<string, string>; run?: string}[]
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1029
|
-
const validateStep = steps.find(s => s.name === 'Validate required secrets')
|
|
1030
|
-
expect(validateStep).toBeDefined()
|
|
1031
|
-
// Operator vars must NOT be in the validation step env (deploy script enforces all-or-none)
|
|
1032
|
-
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
|
|
1033
|
-
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
|
|
1034
|
-
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
1035
|
-
})
|
|
1036
|
-
})
|
|
1037
|
-
|
|
1038
|
-
// ─── VPC bridge secrets: CI-vs-local parity ──────────────────────────────────
|
|
1039
|
-
//
|
|
1040
|
-
// GATEWAY_VPC_IP and DASHBOARD_VPC_IP are optional (all-or-none via getOperatorVpcState)
|
|
1041
|
-
// but must be forwarded through CI so the operator VPC bridge is not silently disabled
|
|
1042
|
-
// when the secrets are set. DIGITALOCEAN_ACCESS_TOKEN is provisioning-only and must NOT
|
|
1043
|
-
// be forwarded to the deploy step.
|
|
1044
|
-
// These tests assert the end-to-end wiring without adding them to the required
|
|
1045
|
-
// validation preflight (they are opt-in, like the operator listener vars).
|
|
1046
|
-
|
|
1047
|
-
const VPC_GATEWAY_SECRETS = ['GATEWAY_VPC_IP', 'DASHBOARD_VPC_IP'] as const
|
|
1048
|
-
const VPC_DASHBOARD_SECRETS = ['GATEWAY_VPC_IP'] as const
|
|
1049
|
-
|
|
1050
|
-
describe('deploy-gateway.yaml: VPC bridge secrets wired end-to-end', () => {
|
|
1051
|
-
const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
|
|
1052
|
-
|
|
1053
|
-
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1054
|
-
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
1055
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1056
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
1057
|
-
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
1058
|
-
expect(secrets).toHaveProperty(secret)
|
|
1059
|
-
expect(secrets[secret]?.required).toBe(false)
|
|
1060
|
-
})
|
|
1061
|
-
|
|
1062
|
-
it(`Deploy gateway step env forwards ${secret} from secrets context`, async () => {
|
|
1063
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1064
|
-
const parsed = parseYaml(text) as {
|
|
1065
|
-
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
1066
|
-
}
|
|
1067
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1068
|
-
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
1069
|
-
expect(deployStep).toBeDefined()
|
|
1070
|
-
expect(deployStep?.env).toHaveProperty(secret)
|
|
1071
|
-
expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
|
|
1072
|
-
})
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
it('VPC bridge secrets are NOT in the required-secret validation step env', async () => {
|
|
1076
|
-
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1077
|
-
const parsed = parseYaml(text) as {
|
|
1078
|
-
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
|
|
1079
|
-
}
|
|
1080
|
-
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1081
|
-
const validateStep = steps.find(s => s.name === 'Validate required secrets')
|
|
1082
|
-
expect(validateStep).toBeDefined()
|
|
1083
|
-
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1084
|
-
expect(validateStep?.env ?? {}).not.toHaveProperty(secret)
|
|
1085
|
-
}
|
|
1086
|
-
})
|
|
1087
|
-
})
|
|
1088
|
-
|
|
1089
|
-
describe('deploy-dashboard.yaml: VPC bridge secret wired end-to-end', () => {
|
|
1090
|
-
const DEPLOY_DASHBOARD_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-dashboard.yaml')
|
|
1091
|
-
|
|
1092
|
-
for (const secret of VPC_DASHBOARD_SECRETS) {
|
|
1093
|
-
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
1094
|
-
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1095
|
-
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
1096
|
-
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
1097
|
-
expect(secrets).toHaveProperty(secret)
|
|
1098
|
-
expect(secrets[secret]?.required).toBe(false)
|
|
1099
|
-
})
|
|
1100
|
-
|
|
1101
|
-
it(`Deploy step env forwards ${secret} from secrets context`, async () => {
|
|
1102
|
-
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1103
|
-
const parsed = parseYaml(text) as {
|
|
1104
|
-
jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
1105
|
-
}
|
|
1106
|
-
const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
|
|
1107
|
-
const deployStep = steps.find(s => s.name === 'Deploy')
|
|
1108
|
-
expect(deployStep).toBeDefined()
|
|
1109
|
-
expect(deployStep?.env).toHaveProperty(secret)
|
|
1110
|
-
expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
|
|
1111
|
-
})
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
it('GATEWAY_VPC_IP is NOT in the required-secret validation step env', async () => {
|
|
1115
|
-
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1116
|
-
const parsed = parseYaml(text) as {
|
|
1117
|
-
jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
|
|
1118
|
-
}
|
|
1119
|
-
const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
|
|
1120
|
-
const validateStep = steps.find(s => s.name === 'Validate required secrets')
|
|
1121
|
-
expect(validateStep).toBeDefined()
|
|
1122
|
-
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_VPC_IP')
|
|
1123
|
-
})
|
|
1124
|
-
})
|
|
1125
|
-
|
|
1126
|
-
describe('deploy.yaml: aggregate router forwards VPC bridge secrets', () => {
|
|
1127
|
-
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
1128
|
-
|
|
1129
|
-
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1130
|
-
it(`deploy-gateway job secrets block passes ${secret}`, async () => {
|
|
1131
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1132
|
-
const parsed = parseYaml(text) as {
|
|
1133
|
-
jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
|
|
1134
|
-
}
|
|
1135
|
-
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
1136
|
-
expect(secrets).toHaveProperty(secret)
|
|
1137
|
-
expect(secrets[secret]).toContain(secret)
|
|
1138
|
-
})
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
for (const secret of VPC_DASHBOARD_SECRETS) {
|
|
1142
|
-
it(`deploy-dashboard job secrets block passes ${secret}`, async () => {
|
|
1143
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1144
|
-
const parsed = parseYaml(text) as {
|
|
1145
|
-
jobs?: {'deploy-dashboard'?: {secrets?: Record<string, string>}}
|
|
1146
|
-
}
|
|
1147
|
-
const secrets = parsed?.jobs?.['deploy-dashboard']?.secrets ?? {}
|
|
1148
|
-
expect(secrets).toHaveProperty(secret)
|
|
1149
|
-
expect(secrets[secret]).toContain(secret)
|
|
1150
|
-
})
|
|
1151
|
-
}
|
|
1152
|
-
})
|
|
1153
|
-
|
|
1154
|
-
// ─── deploy.yaml: deploy-dashboard is decoupled from deploy-gateway ──────────
|
|
1155
|
-
//
|
|
1156
|
-
// The dashboard is a standalone deployment. A gateway failure or slowness must
|
|
1157
|
-
// not block dashboard deploys. deploy-dashboard must NOT declare needs: deploy-gateway,
|
|
1158
|
-
// and its if: must NOT reference needs.deploy-gateway — locking in the decoupling
|
|
1159
|
-
// so it cannot silently regress.
|
|
1160
|
-
|
|
1161
|
-
describe('deploy.yaml: deploy-dashboard is decoupled from deploy-gateway', () => {
|
|
1162
|
-
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
1163
|
-
|
|
1164
|
-
it('deploy-dashboard needs: does NOT include deploy-gateway (standalone job)', async () => {
|
|
1165
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1166
|
-
const parsed = parseYaml(text) as {
|
|
1167
|
-
jobs?: {'deploy-dashboard'?: {needs?: string | string[]}}
|
|
1168
|
-
}
|
|
1169
|
-
const needs = parsed?.jobs?.['deploy-dashboard']?.needs ?? []
|
|
1170
|
-
const needsArr = Array.isArray(needs) ? needs : [needs]
|
|
1171
|
-
expect(needsArr).not.toContain('deploy-gateway')
|
|
1172
|
-
})
|
|
1173
|
-
|
|
1174
|
-
it('deploy-dashboard if: does NOT reference needs.deploy-gateway (no gateway coupling)', async () => {
|
|
1175
|
-
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1176
|
-
// The if: expression must not reference needs.deploy-gateway in any form —
|
|
1177
|
-
// gateway state must not gate the dashboard deploy.
|
|
1178
|
-
expect(text).not.toMatch(/needs\.deploy-gateway/)
|
|
1179
|
-
})
|
|
1180
|
-
})
|