forked from mxx1111/sparepack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathe2e.test.mjs
More file actions
349 lines (291 loc) · 13.4 KB
/
Copy pathe2e.test.mjs
File metadata and controls
349 lines (291 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
// End-to-end: build a fake private repo that is full of things which must not escape,
// run the real CLI against it, and assert on what landed on disk.
//
// The repo below is deliberately nasty — credentials in function bodies, a customer
// database, internal hostnames, a company codename, a Python file someone tried to
// list under `interfaces`. If sparepack is going to fail, it should fail here.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile, readdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
const run = promisify(execFile)
const HERE = dirname(fileURLToPath(import.meta.url))
const CLI = join(HERE, '..', 'bin', 'sparepack.mjs')
// Every one of these must be absent from the output. Referenced by name in assertions
// so a failure says which secret got out.
const SECRETS = {
apiKey: 'sk-ant-api03-RealKeyMaterialThatMustNeverBeShipped00',
dbPassword: 'postgres://svc:Tr0ub4dor3@db01.acme-corp.internal:5432/orders',
customerName: '张伟',
customerPhone: '13800138000',
customerEmail: 'zhang.wei@acme-corp.cn',
internalHost: 'billing-api.acme-corp.internal',
algorithm: 'applyLoyaltyTierDiscount',
codename: 'PROJECT-VULCAN',
}
async function makeRepo() {
const root = await mkdtemp(join(tmpdir(), 'sparepack-e2e-'))
const write = async (rel, body) => {
await mkdir(dirname(join(root, rel)), { recursive: true })
await writeFile(join(root, rel), body)
}
await write(
'src/billing/types.ts',
`export interface Order { id: string; totalCents: number }
export type Tier = 'basic' | 'gold'
`,
)
await write(
'src/billing/gateway.ts',
`import { Order, Tier } from './types'
const API_KEY = "${SECRETS.apiKey}"
const DB = "${SECRETS.dbPassword}"
/** Charge an order and return the receipt id. */
export async function charge(order: Order, tier: Tier): Promise<string> {
const discounted = ${SECRETS.algorithm}(order, tier)
const res = await fetch("https://${SECRETS.internalHost}/v2/charge", {
headers: { authorization: API_KEY },
body: JSON.stringify({ cents: discounted, db: DB }),
})
return (await res.json()).receiptId
}
function ${SECRETS.algorithm}(order: Order, tier: Tier): number {
return tier === 'gold' ? order.totalCents * 0.82 : order.totalCents
}
export class Ledger {
private endpoint = "https://${SECRETS.internalHost}/ledger"
record(order: Order): void {
void fetch(this.endpoint, { body: order.id })
}
}
`,
)
await write(
'tests/billing/charge.spec.ts',
`import { charge } from '../../src/billing/gateway'
import { Order } from '../../src/billing/types'
test('gold tier gets 18% off', async () => {
const order: Order = { id: 'o1', totalCents: 10_000 }
expect(await charge(order, 'gold')).toBeDefined()
})
`,
)
await write(
'data/customers.json',
JSON.stringify(
[
{ id: 1, name: SECRETS.customerName, phone: SECRETS.customerPhone, email: SECRETS.customerEmail },
{ id: 2, name: '李娜', phone: '13900139000', email: 'li.na@acme-corp.cn' },
],
null,
2,
),
)
await write('src/billing/legacy.py', 'def charge(order):\n return secret_internal_logic(order)\n')
return root
}
const CONFIG = `task: "Add proportional refunds to the billing gateway"
include:
- src/billing/types.ts
interfaces:
- src/billing/gateway.ts
tests:
- tests/billing/*.spec.ts
fixtures:
data/customers.json: shape:2
redact:
- pattern: "${SECRETS.codename}|acme-corp"
replace: "example-org"
out: pack
`
async function cli(root, args, { input } = {}) {
try {
const { stdout, stderr } = await run(process.execPath, [CLI, ...args], {
cwd: root,
input,
encoding: 'utf8',
})
return { code: 0, stdout, stderr }
} catch (err) {
return { code: err.code ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}
async function readPack(root) {
const dir = join(root, 'pack')
const files = {}
const walk = async (rel) => {
for (const entry of await readdir(join(dir, rel), { withFileTypes: true })) {
const next = rel ? join(rel, entry.name) : entry.name
if (entry.isDirectory()) await walk(next)
else files[next] = await readFile(join(dir, next), 'utf8')
}
}
await walk('')
return { dir, files, all: Object.values(files).join('\n') }
}
test('a pack built from a repo full of secrets contains none of them', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'sparepack.yaml'), CONFIG)
const packed = await cli(root, ['pack', '--yes', '--no-color'])
assert.equal(packed.code, 0, `pack failed:\n${packed.stdout}\n${packed.stderr}`)
const { dir, files, all } = await readPack(root)
for (const [name, secret] of Object.entries(SECRETS)) {
assert.ok(!all.includes(secret), `pack leaked ${name}: "${secret}"`)
}
// Contract survived.
assert.ok(files['src/billing/types.ts'].includes('interface Order'), 'types must be published verbatim')
assert.match(files['src/billing/gateway.ts'], /export async function charge\(order: Order, tier: Tier\)/)
assert.match(files['src/billing/gateway.ts'], /Charge an order and return the receipt id/)
assert.match(files['src/billing/gateway.ts'], /sparepack stub: not implemented/)
// The test file is the spec and is published as-is.
assert.ok(files['tests/billing/charge.spec.ts'].includes('gold tier gets 18% off'))
// Fixture keeps shape, loses people.
const customers = JSON.parse(files['data/customers.json'])
assert.equal(customers.length, 2)
assert.deepEqual(Object.keys(customers[0]), ['id', 'name', 'phone', 'email'])
// Redaction reached everything, including the test file.
assert.ok(!all.includes('acme-corp'), 'redact rule should have removed the company name everywhere')
// Orientation for the worker.
assert.ok(files['README.md'].includes('Add proportional refunds'))
assert.ok(files['MANIFEST.json'])
// And the independent verifier agrees.
const verified = await cli(root, ['verify', dir])
assert.equal(verified.code, 0, `verify failed:\n${verified.stdout}\n${verified.stderr}`)
assert.match(verified.stdout, /No problems found/)
})
test('the published MANIFEST.json does not become the leak', async (t) => {
// Regression: the manifest used to carry the author's warnings and redact patterns.
// Both describe exactly what the pack is meant to withhold — the warnings name dropped
// internal functions, and the redact patterns are a list of the forbidden words.
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'sparepack.yaml'), CONFIG)
const packed = await cli(root, ['pack', '--yes', '--no-color'])
assert.equal(packed.code, 0)
const manifest = JSON.parse(await readFile(join(root, 'pack', 'MANIFEST.json'), 'utf8'))
const serialised = JSON.stringify(manifest)
assert.ok(!serialised.includes(SECRETS.algorithm), 'manifest named a dropped internal function')
assert.ok(!serialised.includes(SECRETS.codename), 'manifest published a redact pattern')
assert.ok(!serialised.includes('acme-corp'), 'manifest published a redact pattern')
assert.equal(manifest.warnings, undefined, 'author-facing warnings must not ship')
assert.equal(manifest.findings, undefined, 'scan findings are an internal audit record')
for (const f of manifest.files) {
assert.equal(f.notes, undefined, `${f.path}: per-file notes must not ship`)
assert.equal(f.redactions, undefined, `${f.path}: redaction patterns must not ship`)
}
// The author still gets the full picture on their terminal.
assert.match(packed.stdout, new RegExp(SECRETS.algorithm), 'the author must be told what was dropped')
// And what remains is enough for verify to detect tampering.
assert.ok(manifest.files.every((f) => f.path && f.kind && typeof f.bytes === 'number'))
})
test('pack refuses to write when a listed file still contains a credential', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
// gateway.ts under `include` means verbatim publication — the key goes straight through.
await writeFile(
join(root, 'sparepack.yaml'),
`task: "x"\ninclude:\n - src/billing/gateway.ts\ntests:\n - tests/billing/*.spec.ts\nout: pack\n`,
)
const result = await cli(root, ['pack', '--yes', '--no-color'])
assert.equal(result.code, 2, 'a pack containing an API key must not be written')
assert.match(result.stderr, /Refusing to write/)
assert.match(result.stderr, /credentials or personal data/)
await assert.rejects(readdir(join(root, 'pack')), /ENOENT/, 'nothing should have been written')
})
test('a rejected confirmation writes nothing', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'sparepack.yaml'), CONFIG)
// Not a TTY, and no --yes: the CLI must refuse rather than assume consent.
const result = await cli(root, ['pack', '--no-color'], { input: 'publish\n' })
assert.notEqual(result.code, 0)
assert.match(result.stderr, /confirmation required/)
await assert.rejects(readdir(join(root, 'pack')), /ENOENT/, 'nothing should have been written')
})
test('a pattern that matches nothing is an error, not a quiet no-op', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'sparepack.yaml'), `task: "x"\ninclude:\n - src/billing/typos.ts\nout: pack\n`)
const result = await cli(root, ['pack', '--yes'])
assert.equal(result.code, 2)
assert.match(result.stderr, /matched no files/)
})
test('listing a Python file under interfaces is refused, not silently copied', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'sparepack.yaml'), `task: "x"\ninterfaces:\n - src/billing/legacy.py\nout: pack\n`)
const result = await cli(root, ['pack', '--yes'])
assert.equal(result.code, 2)
assert.match(result.stderr, /only understands/)
await assert.rejects(readdir(join(root, 'pack')), /ENOENT/)
})
test('verify catches a file edited after packing', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'sparepack.yaml'), CONFIG)
await cli(root, ['pack', '--yes'])
await writeFile(join(root, 'pack', 'src', 'billing', 'types.ts'), 'export interface Order { id: string }\n')
const result = await cli(root, ['verify', join(root, 'pack')])
assert.equal(result.code, 2)
assert.match(result.stderr, /changed after packing/)
})
test('verify catches an implementation smuggled into a stripped file', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'sparepack.yaml'), CONFIG)
await cli(root, ['pack', '--yes'])
const target = join(root, 'pack', 'src', 'billing', 'gateway.ts')
const body = await readFile(target, 'utf8')
await writeFile(target, body.replace(/throw new Error\('sparepack stub: not implemented'\)/, 'return "real"'))
const result = await cli(root, ['verify', join(root, 'pack')])
assert.equal(result.code, 2)
assert.match(result.stderr, /still has a real body/)
})
test('verify rejects a directory that is not a pack', async (t) => {
const root = await makeRepo()
t.after(() => rm(root, { recursive: true, force: true }))
const result = await cli(root, ['verify', root])
assert.equal(result.code, 2)
assert.match(result.stderr, /does not look like a sparepack/)
})
test('--help succeeds, no command does not', async (t) => {
// prepublishOnly runs `sparepack --help` as a smoke test, so its exit code has to mean
// what it says: asking for help is a successful use of the tool, being given nothing is not.
const root = await mkdtemp(join(tmpdir(), 'sparepack-help-'))
t.after(() => rm(root, { recursive: true, force: true }))
const help = await cli(root, ['--help'])
assert.equal(help.code, 0)
assert.match(help.stdout, /sparepack — turn a slice of a private repo/)
const bare = await cli(root, [])
assert.equal(bare.code, 1, 'no command is a usage error')
const bogus = await cli(root, ['frobnicate'])
assert.equal(bogus.code, 1)
assert.match(bogus.stderr, /unknown command/)
})
test('init writes a template and refuses to clobber an existing config', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'sparepack-init-'))
t.after(() => rm(root, { recursive: true, force: true }))
const first = await cli(root, ['init'])
assert.equal(first.code, 0)
const template = await readFile(join(root, 'sparepack.yaml'), 'utf8')
assert.match(template, /allowlist/)
assert.match(template, /task:/)
const second = await cli(root, ['init'])
assert.equal(second.code, 1)
assert.match(second.stderr, /already exists/)
})
test('the generated template is itself a valid config', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'sparepack-tmpl-'))
t.after(() => rm(root, { recursive: true, force: true }))
await cli(root, ['init'])
const { parseConfig } = await import('../src/config.mjs')
const config = parseConfig(await readFile(join(root, 'sparepack.yaml'), 'utf8'))
assert.equal(config.task.length > 0, true)
assert.equal(config.interfaces.length, 1)
assert.equal(config.redact.length, 1)
})