@agile-team/wl-skills-gate 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/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/bin/wl-quality.js +2 -1
- package/docs/01-quick-start.md +1 -1
- package/docs/07-jenkins.md +15 -2
- package/docs/README.md +1 -1
- package/lib/jenkins-cluster.js +3 -2
- package/lib/jenkins-installation.js +7 -4
- package/lib/jenkins-integration.js +1 -0
- package/lib/jenkins-inventory.js +162 -0
- package/lib/jenkins-node-bootstrap.js +1 -1
- package/lib/jenkins-ops-checks.js +158 -0
- package/lib/jenkins-ops-notification.js +125 -0
- package/lib/jenkins-ops.js +129 -0
- package/npm-shrinkwrap.json +7 -7
- package/package.json +1 -1
- package/scripts/lib-tests/jenkins-full-chain.test.mjs +4 -1
- package/scripts/lib-tests/jenkins-inventory.test.mjs +96 -0
- package/scripts/lib-tests/jenkins-native.test.mjs +3 -0
- package/scripts/lib-tests/jenkins-ops-checks.test.mjs +171 -0
- package/scripts/lib-tests/jenkins-ops-native.test.mjs +248 -0
- package/scripts/lib-tests/jenkins-ops-notification.test.mjs +88 -0
- package/scripts/lib-tests/jenkins-ops.test.mjs +238 -0
- package/templates/ci/check-project.config.json +1 -1
- package/templates/ci/jenkins-bootstrap.py +1 -1
- package/templates/ci/jenkins-loader.groovy +2 -0
- package/templates/ci/jenkins-native.groovy +141 -1
- package/templates/onboarding/backend/quality-tools.package.json +1 -1
- package/templates/quality/rules.lock.json +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readFileSync, existsSync, rmSync } from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { fixture, bind, put, git } from '../test-support/jenkins-fixture.mjs';
|
|
7
|
+
import { planJenkinsInstallation, applyJenkinsInstallation } from '../../lib/jenkins-installation.js';
|
|
8
|
+
import { loadJenkinsCluster } from '../../lib/jenkins-cluster.js';
|
|
9
|
+
import { digest } from '../../lib/jenkins-integration.js';
|
|
10
|
+
import { stableStringify } from '../../lib/util.js';
|
|
11
|
+
import { nativePipelineEntry } from '../../lib/jenkins-entry-contract.js';
|
|
12
|
+
import { renderFleetJob } from '../../lib/jenkins-job-config.js';
|
|
13
|
+
import { claimOpsCheck, acknowledgeOpsCheck } from '../../lib/jenkins-ops-checks.js';
|
|
14
|
+
|
|
15
|
+
const env = { WL_JENKINS_READ_USER: 'fixture-reader', WL_JENKINS_READ_TOKEN: 'private-fixture-read-token' };
|
|
16
|
+
const read = file => JSON.parse(readFileSync(file));
|
|
17
|
+
const hash = value => digest(stableStringify(value));
|
|
18
|
+
async function setup(t) {
|
|
19
|
+
const f = fixture(t);
|
|
20
|
+
const options = { prefix: path.join(f.base, 'install'), configDir: path.join(f.base, 'config'), registryFiles: [f.registryFile], jenkinsOrigin: 'https://jenkins.example.invalid/', centralAgentLabel: 'central', reviewSignerAgentLabel: 'signer' };
|
|
21
|
+
const plan = planJenkinsInstallation(options), installed = applyJenkinsInstallation({ ...options, confirm: true, planHash: plan.planHash });
|
|
22
|
+
await bind(f);
|
|
23
|
+
const registry = read(f.registryFile); registry.nativeEntry = installed.nativeEntry; put(f.base, 'bindings.json', registry);
|
|
24
|
+
for (const name of ['automation-config', 'governance-config', 'control-config', 'review-publish-config']) put(options.configDir, name + '.json', {
|
|
25
|
+
schemaVersion: 1, projects: { 'wl-produce': {} }, sources: [{ id: 'fixture' }], dataDir: f.base, publicKeyFile: 'approved-fixture.pem', identity: { provider: 'fixture' }, authorizationIssuer: 'fixture', reviewPrivateKeyEnv: 'FIXTURE_PRIVATE_FILE',
|
|
26
|
+
});
|
|
27
|
+
put(options.configDir, 'jenkins-fleet.json', { schemaVersion: 1, kind: 'wl-gate-jenkins-fleet', jenkinsOrigin: options.jenkinsOrigin, dataDir: path.join(f.base, 'fleet'), registryFiles: [f.registryFile], automationConfigFile: path.join(options.configDir, 'automation-config.json'), selection: { projects: ['wl-produce'], branches: ['test'] }, approvers: ['fixture'] });
|
|
28
|
+
const binding = registry.bindings[0], cluster = loadJenkinsCluster(installed.clusterFile);
|
|
29
|
+
let xml = renderFleetJob({ script: nativePipelineEntry(installed.nativeEntry), binding, mode: 'check', secret: 'fixture-shared-gitlab-secret' });
|
|
30
|
+
const project = { key: 'a'.repeat(24), project: binding.project, branch: binding.branch, scope: binding.scope, job: binding.jobs.check, jobs: [binding.jobs.check], status: 'ready', gaps: [], pinsVerified: true, entryVerified: true, checkJob: binding.jobs.check, bindingId: binding.id, registryFile: f.registryFile, bindingHash: hash(binding), sha: git(f.root, 'rev-parse', 'HEAD'), configHash: digest(xml) };
|
|
31
|
+
const report = { schemaVersion: 1, kind: 'jenkins-ops-report', runId: randomUUID(), checkedAt: new Date().toISOString(), clusterHash: cluster.configHash, jenkinsOrigin: options.jenkinsOrigin, status: 'ready', globalGaps: [], projects: [project], qualityPassed: false, buildAuthorized: false };
|
|
32
|
+
const reports = path.join(options.configDir, 'ops-state/runs');
|
|
33
|
+
const save = value => put(reports, value.runId + '/report.json', value);
|
|
34
|
+
save(report);
|
|
35
|
+
const runs = new Map(), summaries = new Map(), calls = [];
|
|
36
|
+
let failReads = false;
|
|
37
|
+
const response = data => new Response(JSON.stringify(data));
|
|
38
|
+
const fetchImpl = async (url, request = {}) => {
|
|
39
|
+
const u = new URL(url); calls.push(u.pathname + u.search);
|
|
40
|
+
assert.equal(u.origin, 'https://jenkins.example.invalid'); assert.equal(request.method, 'GET');
|
|
41
|
+
assert.equal(request.headers.Authorization, 'Basic ' + Buffer.from('fixture-reader:private-fixture-read-token').toString('base64'));
|
|
42
|
+
assert.equal(request.redirect, 'error');
|
|
43
|
+
if (failReads) throw Error('network-private-token');
|
|
44
|
+
if (u.pathname.endsWith('/config.xml')) return new Response(xml);
|
|
45
|
+
const build = /\/([0-9]+)\/api\/json$/.exec(u.pathname);
|
|
46
|
+
if (build) return runs.has(build[1]) ? response(runs.get(build[1])) : new Response('', { status: 404 });
|
|
47
|
+
if (u.pathname.endsWith('/api/json')) return response({ builds: [...runs.values()].sort((a, b) => b.number - a.number).slice(0, 25) });
|
|
48
|
+
const artifact = /\/([0-9]+)\/artifact\/quality-reports\/quality-summary\.json$/.exec(u.pathname);
|
|
49
|
+
if (artifact) return summaries.has(artifact[1]) ? response(summaries.get(artifact[1])) : new Response('', { status: 404 });
|
|
50
|
+
throw Error('unexpected Jenkins route');
|
|
51
|
+
};
|
|
52
|
+
const args = { clusterFile: installed.clusterFile, runId: report.runId, key: project.key, env, fetchImpl };
|
|
53
|
+
const stateFile = path.join(options.configDir, 'ops-state/checks.json');
|
|
54
|
+
const state = () => read(stateFile), current = () => Object.values(state().claims)[0];
|
|
55
|
+
const run = (number, claim, extra = {}) => ({ number, building: false, result: 'SUCCESS', actions: [{ parameters: [{ name: 'WL_GATE_DISPATCH_ID', value: claim.dispatchId }, { name: 'WL_REVIEW_SHA', value: claim.sha }] }], artifacts: [], ...extra });
|
|
56
|
+
return { ...f, args, report, project, options, binding, reports, save, stateFile, state, current, runs, summaries, calls, run, xml: () => xml, setXml: value => { xml = value; }, setFailReads: value => { failReads = value; } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('首次claim持久保存唯一dispatch和候选SHA;ack实际编号幂等,运行中和成功跨轮不重复', async t => {
|
|
60
|
+
const f = await setup(t), claimed = await claimOpsCheck(f.args);
|
|
61
|
+
assert.equal(claimed.status, 'claimed'); assert.equal(claimed.sha, f.project.sha); assert.match(claimed.dispatchId, /^[a-f0-9-]{36}$/);
|
|
62
|
+
assert.equal(claimed.attempt, 1); assert.equal(f.current().status, 'claimed'); assert.equal(f.current().dispatchId, claimed.dispatchId);
|
|
63
|
+
assert.equal((await claimOpsCheck(f.args)).status, 'unknown');
|
|
64
|
+
await assert.rejects(acknowledgeOpsCheck({ ...f.args, buildNumber: 0 }), /实际Jenkins运行编号/);
|
|
65
|
+
await assert.rejects(acknowledgeOpsCheck({ ...f.args, runId: randomUUID(), buildNumber: 31 }), /不存在唯一/);
|
|
66
|
+
assert.equal((await acknowledgeOpsCheck({ ...f.args, buildNumber: 31 })).status, 'started');
|
|
67
|
+
assert.equal((await acknowledgeOpsCheck({ ...f.args, buildNumber: 31 })).status, 'started');
|
|
68
|
+
await assert.rejects(acknowledgeOpsCheck({ ...f.args, buildNumber: 32 }), /不能覆盖/);
|
|
69
|
+
f.runs.set('31', f.run(31, claimed, { building: true, result: null }));
|
|
70
|
+
assert.equal((await claimOpsCheck(f.args)).status, 'already-started');
|
|
71
|
+
f.runs.set('31', f.run(31, claimed));
|
|
72
|
+
const next = { ...f.report, runId: randomUUID() }; f.save(next);
|
|
73
|
+
assert.equal((await claimOpsCheck({ ...f.args, runId: next.runId })).status, 'already-started');
|
|
74
|
+
assert.equal(f.current().attempt, 1);
|
|
75
|
+
});
|
|
76
|
+
test('启动返回前中断,凭近期25次运行的dispatchId及SHA找到唯一实际运行后补ack', async t => {
|
|
77
|
+
const f = await setup(t), claimed = await claimOpsCheck(f.args);
|
|
78
|
+
f.runs.set('7', f.run(7, claimed));
|
|
79
|
+
const pending = { ...f.report, runId: randomUUID(), status: 'attention', projects: [{ ...f.project, status: 'attention', gaps: [{ id: 'CHECK-DISPATCH-UNKNOWN', message: '待核对实际启动' }] }] }; f.save(pending);
|
|
80
|
+
const reconciled = await claimOpsCheck({ ...f.args, runId: pending.runId });
|
|
81
|
+
assert.equal(reconciled.status, 'already-started'); assert.equal(reconciled.buildNumber, '7');
|
|
82
|
+
assert.equal(f.current().status, 'started'); assert.equal(f.current().attempt, 1);
|
|
83
|
+
assert(f.calls.some(c => c.includes('{0,25}') || c.includes('%7B0,25%7D')));
|
|
84
|
+
});
|
|
85
|
+
test('未知启动不盲发;一分钟观察后只有显式确认且对账无匹配才可有界重试', async t => {
|
|
86
|
+
const f = await setup(t), instant = Date.now(), claimed = await claimOpsCheck({ ...f.args, now: instant });
|
|
87
|
+
assert.equal((await claimOpsCheck({ ...f.args, now: instant + 60001 })).status, 'unknown');
|
|
88
|
+
assert.equal((await claimOpsCheck({ ...f.args, now: instant + 1000, retryUnknown: true })).status, 'unknown');
|
|
89
|
+
const retry = await claimOpsCheck({ ...f.args, now: instant + 60001, retryUnknown: true });
|
|
90
|
+
assert.equal(retry.status, 'claimed'); assert.equal(retry.attempt, 2); assert.notEqual(retry.dispatchId, claimed.dispatchId);
|
|
91
|
+
assert.equal(f.current().history[0].dispatchId, claimed.dispatchId);
|
|
92
|
+
await assert.rejects(acknowledgeOpsCheck({ ...f.args, buildNumber: 31, dispatchId: claimed.dispatchId }), /迟到或其他dispatchId/);
|
|
93
|
+
assert.equal(f.current().status, 'claimed');
|
|
94
|
+
assert.equal((await acknowledgeOpsCheck({ ...f.args, buildNumber: 32, dispatchId: retry.dispatchId })).status, 'started');
|
|
95
|
+
});
|
|
96
|
+
test('重复追踪、错误SHA、不可读运行都不能被当成无运行重新启动', async t => {
|
|
97
|
+
const f = await setup(t), instant = Date.now(), claimed = await claimOpsCheck({ ...f.args, now: instant });
|
|
98
|
+
f.runs.set('7', f.run(7, claimed)); f.runs.set('8', f.run(8, claimed));
|
|
99
|
+
assert.equal((await claimOpsCheck({ ...f.args, now: instant + 60001, retryUnknown: true })).status, 'unknown');
|
|
100
|
+
f.runs.clear(); await acknowledgeOpsCheck({ ...f.args, buildNumber: 9 });
|
|
101
|
+
f.runs.set('9', f.run(9, { ...claimed, sha: 'b'.repeat(40) }));
|
|
102
|
+
assert.equal((await claimOpsCheck({ ...f.args, retryUnknown: true })).status, 'unknown');
|
|
103
|
+
f.runs.clear(); assert.equal((await claimOpsCheck({ ...f.args, retryUnknown: true })).status, 'unknown');
|
|
104
|
+
assert.equal(f.current().attempt, 1);
|
|
105
|
+
});
|
|
106
|
+
test('只读重验实际Job摘要、受控入口、参数和目标Push触发;漂移先于写启动台账被拒绝', async t => {
|
|
107
|
+
const f = await setup(t), original = f.xml();
|
|
108
|
+
f.setXml(original.replace('<includeBranchesSpec>test</includeBranchesSpec>', '<includeBranchesSpec>sit</includeBranchesSpec>'));
|
|
109
|
+
await assert.rejects(claimOpsCheck(f.args), /配置已漂移/); assert(!existsSync(f.stateFile));
|
|
110
|
+
// Even a matching updated digest cannot turn an unprotected script into a
|
|
111
|
+
// CHECK. This isolates the semantic check from the raw config pin check.
|
|
112
|
+
f.setXml(original.replace('<sandbox>true</sandbox>', '<sandbox>false</sandbox>'));
|
|
113
|
+
f.project.configHash = digest(f.xml()); f.save(f.report);
|
|
114
|
+
await assert.rejects(claimOpsCheck(f.args), /受控入口/); assert(!existsSync(f.stateFile));
|
|
115
|
+
f.setXml(original.replace('<name>WL_REVIEW_SHA</name>', '<name>OTHER_SHA</name>')); f.project.configHash = digest(f.xml()); f.save(f.report);
|
|
116
|
+
await assert.rejects(claimOpsCheck(f.args), /候选SHA或启动追踪参数/); assert(!existsSync(f.stateFile));
|
|
117
|
+
});
|
|
118
|
+
test('过期、未来、其他集群、BUILD或缺pins的报告不可借旧ready触发', async t => {
|
|
119
|
+
const f = await setup(t), original = structuredClone(f.report);
|
|
120
|
+
for (const mutate of [
|
|
121
|
+
r => { r.checkedAt = new Date(Date.now() - 31 * 60000).toISOString(); },
|
|
122
|
+
r => { r.checkedAt = new Date(Date.now() + 60000).toISOString(); },
|
|
123
|
+
r => { r.clusterHash = 'b'.repeat(64); },
|
|
124
|
+
r => { r.globalGaps = [{ id: 'CENTRAL-CONFIG' }]; },
|
|
125
|
+
r => { r.projects[0].pinsVerified = false; },
|
|
126
|
+
r => { r.projects[0].checkJob = 'produce/BUILD'; },
|
|
127
|
+
r => { r.projects[0].gaps = [{ id: 'PROJECT-GATE' }]; r.projects[0].status = 'attention'; },
|
|
128
|
+
]) {
|
|
129
|
+
const value = structuredClone(original); mutate(value); f.save(value);
|
|
130
|
+
await assert.rejects(claimOpsCheck(f.args), /巡检报告|仅本轮/); assert(!existsSync(f.stateFile));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
test('已核验实际终止的环境失败可重试;同一SHA最多三次,台账完整保存每次dispatch', async t => {
|
|
134
|
+
const f = await setup(t); let claim = await claimOpsCheck(f.args);
|
|
135
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
136
|
+
assert.equal(claim.attempt, attempt);
|
|
137
|
+
await acknowledgeOpsCheck({ ...f.args, buildNumber: 10 + attempt });
|
|
138
|
+
f.runs.set(String(10 + attempt), f.run(10 + attempt, claim, { building: false, result: attempt === 2 ? 'ABORTED' : 'FAILURE', artifacts: [] }));
|
|
139
|
+
const next = await claimOpsCheck(f.args);
|
|
140
|
+
if (attempt < 3) { assert.equal(next.status, 'claimed'); assert.notEqual(next.dispatchId, claim.dispatchId); claim = next; }
|
|
141
|
+
else assert.equal(next.status, 'retry-limit');
|
|
142
|
+
}
|
|
143
|
+
assert.equal(f.current().history.length, 2); assert.equal(f.current().attempt, 3);
|
|
144
|
+
});
|
|
145
|
+
test('真实质量发现失败不自动重刷;报告下载失败不能冒充无质量原件', async t => {
|
|
146
|
+
const f = await setup(t), claim = await claimOpsCheck(f.args);
|
|
147
|
+
await acknowledgeOpsCheck({ ...f.args, buildNumber: 17 });
|
|
148
|
+
f.runs.set('17', f.run(17, claim, { result: 'FAILURE', artifacts: [{ relativePath: 'quality-reports/quality-summary.json' }] }));
|
|
149
|
+
const noOriginal = await claimOpsCheck(f.args); assert.equal(noOriginal.status, 'failed-needs-confirmation');
|
|
150
|
+
f.summaries.set('17', { run: { project: f.project.project, commitSha: claim.sha, stage: 'ci' }, findings: [{ severity: 'error', ruleId: 'FIXTURE/RISK' }], checks: [{ status: 'passed' }] });
|
|
151
|
+
assert.equal((await claimOpsCheck(f.args)).status, 'quality-failed');
|
|
152
|
+
assert.equal((await claimOpsCheck({ ...f.args, retryKnownFailure: true })).status, 'quality-failed');
|
|
153
|
+
assert.equal(f.current().attempt, 1);
|
|
154
|
+
});
|
|
155
|
+
test('不完整质量结果只读留待确认,明确非代码执行失败才可自动复检', async t => {
|
|
156
|
+
const f = await setup(t), claim = await claimOpsCheck(f.args);
|
|
157
|
+
await acknowledgeOpsCheck({ ...f.args, buildNumber: 19 });
|
|
158
|
+
f.runs.set('19', f.run(19, claim, { result: 'FAILURE', artifacts: [{ relativePath: 'quality-reports/quality-summary.json' }] }));
|
|
159
|
+
f.summaries.set('19', { run: { project: f.project.project, commitSha: claim.sha, stage: 'ci' }, findings: [], checks: [{ status: 'invalid' }] });
|
|
160
|
+
assert.equal((await claimOpsCheck(f.args)).status, 'failed-needs-confirmation');
|
|
161
|
+
f.summaries.set('19', { run: { project: f.project.project, commitSha: claim.sha, stage: 'ci' }, findings: [], checks: [{ status: 'passed' }] });
|
|
162
|
+
assert.equal((await claimOpsCheck(f.args)).status, 'claimed');
|
|
163
|
+
assert.equal(f.current().attempt, 2);
|
|
164
|
+
});
|
|
165
|
+
test('台账或工程配置损坏不能忽略后重新启动', async t => {
|
|
166
|
+
const f = await setup(t); await claimOpsCheck(f.args);
|
|
167
|
+
put(path.dirname(f.stateFile), 'checks.json', { schemaVersion: 1, claims: null });
|
|
168
|
+
await assert.rejects(claimOpsCheck(f.args), /台账损坏/);
|
|
169
|
+
rmSync(path.join(f.options.configDir, 'control-config.json'));
|
|
170
|
+
await assert.rejects(claimOpsCheck(f.args), /工程配置变化/);
|
|
171
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync} from 'node:fs';
|
|
4
|
+
import {spawnSync} from 'node:child_process';
|
|
5
|
+
import {tmpdir} from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
// These are boundaries of the package-owned Jenkins adapter, not a claim that a
|
|
9
|
+
// text inspection validates Jenkins CPS or the site's credential permissions.
|
|
10
|
+
const native = () => readFileSync(new URL('../../templates/ci/jenkins-native.groovy', import.meta.url), 'utf8');
|
|
11
|
+
|
|
12
|
+
function endOfBlock(text, open) {
|
|
13
|
+
let depth = 1, quote = null;
|
|
14
|
+
for (let i = open + 1; i < text.length; i++) {
|
|
15
|
+
if (quote) {
|
|
16
|
+
if (text[i] === '\\') { i++; continue; }
|
|
17
|
+
if (text.startsWith(quote, i)) { i += quote.length - 1; quote = null; }
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (text.startsWith('//', i)) { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end; continue; }
|
|
21
|
+
if (text.startsWith('/*', i)) { const end = text.indexOf('*/', i + 2); assert.notEqual(end, -1, 'unterminated Groovy comment'); i = end + 1; continue; }
|
|
22
|
+
for (const delimiter of ["'''", '"""', "'", '"']) {
|
|
23
|
+
if (text.startsWith(delimiter, i)) { quote = delimiter; i += delimiter.length - 1; break; }
|
|
24
|
+
}
|
|
25
|
+
if (quote) continue;
|
|
26
|
+
if (text[i] === '{') depth++;
|
|
27
|
+
if (text[i] === '}' && --depth === 0) return i;
|
|
28
|
+
}
|
|
29
|
+
throw Error('unterminated Groovy block');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function opsMethod(source, name = 'wlOpsRun') {
|
|
33
|
+
const header = new RegExp('\\bdef\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{').exec(source);
|
|
34
|
+
assert(header, 'doctor must have the package-owned ' + name + ' adapter');
|
|
35
|
+
const open = header.index + header[0].lastIndexOf('{');
|
|
36
|
+
return source.slice(open + 1, endOfBlock(source, open));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function credentialBlocks(source) {
|
|
40
|
+
const scopes = [];
|
|
41
|
+
for (const match of source.matchAll(/\bwithCredentials\s*\(/g)) {
|
|
42
|
+
const open = source.indexOf('{', match.index);
|
|
43
|
+
assert(open >= 0, 'credential binding must have an explicit closure');
|
|
44
|
+
const end = endOfBlock(source, open);
|
|
45
|
+
scopes.push({declaration: source.slice(match.index, open), body: source.slice(open + 1, end)});
|
|
46
|
+
}
|
|
47
|
+
return scopes;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('运维监督无需项目登记即可先盘点,检查与通知为三个独立动作', () => {
|
|
51
|
+
const helper = opsMethod(native());
|
|
52
|
+
const inventory = helper.indexOf('jenkins ops inventory');
|
|
53
|
+
const inspect = helper.indexOf('jenkins ops inspect');
|
|
54
|
+
const finish = helper.indexOf('jenkins ops finish');
|
|
55
|
+
assert(inventory >= 0 && inspect > inventory && finish > inspect, 'inventory → inspect → finish must be explicit');
|
|
56
|
+
assert.match(helper, /--cluster/);
|
|
57
|
+
assert.match(helper, /--target/);
|
|
58
|
+
assert.match(helper, /--inventory/);
|
|
59
|
+
assert.match(helper, /--results/);
|
|
60
|
+
assert.doesNotMatch(helper, /jenkins fleet (?:inventory|plan|apply)|jenkins (?:bind|plan)|review-publish|jenkins (?:admit|build)/,
|
|
61
|
+
'ops supervision must not require the project registry or silently authorize a build/review');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('源码检出和项目接入检查不持有Jenkins接口、飞书或签发凭据', () => {
|
|
65
|
+
const helper = opsMethod(native());
|
|
66
|
+
const scopes = credentialBlocks(helper);
|
|
67
|
+
assert(scopes.length >= 2, 'read credentials and notice credentials must be separately scoped');
|
|
68
|
+
for (const scope of scopes) {
|
|
69
|
+
assert.doesNotMatch(scope.body, /\bcheckout\s*\(|jenkins ops inspect/,
|
|
70
|
+
'SCM checkout/inspection must happen outside API and bot credential closures');
|
|
71
|
+
assert.doesNotMatch(scope.declaration, /wl-jenkins-config-manager|wl-jenkins-build-dispatch|wl-review-signing-key|wl-quality-sso-client-secret/,
|
|
72
|
+
'read-only supervision cannot obtain write, dispatch, signing or SSO credentials');
|
|
73
|
+
}
|
|
74
|
+
const reader = scopes.find(scope => scope.body.includes('jenkins ops inventory'));
|
|
75
|
+
const notifier = scopes.find(scope => scope.body.includes('jenkins ops finish'));
|
|
76
|
+
assert(reader, 'inventory must use a scoped Jenkins read credential');
|
|
77
|
+
assert(notifier, 'finish must use a scoped CI bot credential');
|
|
78
|
+
assert.match(reader.declaration, /wl-jenkins-ci-readonly/);
|
|
79
|
+
assert.doesNotMatch(reader.declaration, /feishu/);
|
|
80
|
+
assert.match(notifier.declaration, /wl-feishu-ci-webhook/);
|
|
81
|
+
assert.match(notifier.declaration, /wl-feishu-ci-signing-secret/);
|
|
82
|
+
assert.doesNotMatch(notifier.declaration, /wl-feishu-project|wl-jenkins-ci-readonly/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('项目检查失败仍进入汇总,运维报告归档且不启动旧部署任务', () => {
|
|
86
|
+
const helper = opsMethod(native());
|
|
87
|
+
assert.match(helper, /\btry\s*\{/);
|
|
88
|
+
assert.match(helper, /\bfinally\s*\{/,
|
|
89
|
+
'inspection failures need a finally path for the aggregate report/notice');
|
|
90
|
+
assert.match(helper, /archiveArtifacts/);
|
|
91
|
+
assert.match(helper, /returnStatus\s*:\s*true/,
|
|
92
|
+
'a missing project prerequisite must be recorded before ending the monitoring run');
|
|
93
|
+
assert.doesNotMatch(helper, /\bbuild\s*\(|\/buildWithParameters|\/build[?'"`\s]|createItem|config\.xml/,
|
|
94
|
+
'supervision does not rewrite or execute discovered custom deployment jobs');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('默认doctor执行监督,机器人测试仅走显式连通测试', () => {
|
|
98
|
+
const source = native();
|
|
99
|
+
assert.match(source, /cfg\.role\s*==\s*['"]node['"]/);
|
|
100
|
+
assert.match(source, /params\.TEST_NOTIFICATION/);
|
|
101
|
+
const call = source.lastIndexOf('wlOpsRun(cfg)');
|
|
102
|
+
assert(call > source.indexOf('def call('), 'node routing must invoke the ops adapter by default');
|
|
103
|
+
assert.match(source, /wlGateNotificationTest\(\)/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('承接仅启动持久claim给出的CHECK,启动后记录实际编号,未持有通知或签发凭据', () => {
|
|
107
|
+
const helper = opsMethod(native(), 'wlOpsContinue');
|
|
108
|
+
const claim = helper.indexOf('jenkins ops claim-check'), build = helper.indexOf('build('), ack = helper.indexOf('jenkins ops ack-check');
|
|
109
|
+
assert(claim >= 0 && build > claim && ack > build, 'durable claim → actual CHECK start → actual number acknowledgment');
|
|
110
|
+
assert.match(helper, /claim\.status\s*==\s*['"]claimed['"]/);
|
|
111
|
+
assert.match(helper, /build\(job:\s*claim\.job/);
|
|
112
|
+
assert.match(helper, /waitForStart:\s*true/);
|
|
113
|
+
assert.match(helper, /--build-number/);
|
|
114
|
+
assert.match(helper, /--dispatch-id/);
|
|
115
|
+
assert.match(helper, /started\.number/);
|
|
116
|
+
assert.match(helper, /name:\s*['"]WL_REVIEW_SHA['"],\s*value:\s*claim\.sha/);
|
|
117
|
+
assert.match(helper, /name:\s*['"]WL_GATE_DISPATCH_ID['"],\s*value:\s*claim\.dispatchId/);
|
|
118
|
+
for (const scope of credentialBlocks(helper)) {
|
|
119
|
+
assert.doesNotMatch(scope.declaration, /feishu|signing-key|build-dispatch|config-manager|sso-client-secret/);
|
|
120
|
+
assert.doesNotMatch(scope.body, /\bbuild\s*\(/, 'Jenkins interface credentials must end before the child CHECK starts');
|
|
121
|
+
}
|
|
122
|
+
assert.doesNotMatch(helper, /review-publish|jenkins (?:admit|build)|\/buildWithParameters/);
|
|
123
|
+
assert.match(helper, /archiveArtifacts/);
|
|
124
|
+
assert.match(opsMethod(native()), /wlOpsContinue\(cfg,\s*summary\)/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('人工终止不继续检出,通知凭据失败仍保留无发送汇总与归档', () => {
|
|
128
|
+
const helper = opsMethod(native());
|
|
129
|
+
assert.match(helper, /catch\s*\(org\.jenkinsci\.plugins\.workflow\.steps\.FlowInterruptedException interrupted\)\s*\{\s*throw interrupted\s*\}/);
|
|
130
|
+
assert.match(helper, /finally\s*\{\s*deleteDir\(\)\s*\}/);
|
|
131
|
+
const finishes = helper.split('\n').filter(command => /\bjenkins ops finish\b/.test(command));
|
|
132
|
+
assert(finishes.some(command => /--send\b/.test(command)), 'configured bot must send the summary');
|
|
133
|
+
assert(finishes.some(command => !/--send\b/.test(command)), 'missing credential path must still generate a non-sending summary');
|
|
134
|
+
assert.match(helper, /finally\s*\{\s*archiveArtifacts/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('原生Groovy执行监督与承接:凭据隔离、固定SHA、失败不漏档且继续其它项目', {skip: !process.env.WL_GROOVY_JAR}, t => {
|
|
138
|
+
const jar = process.env.WL_GROOVY_JAR;
|
|
139
|
+
assert(existsSync(jar), 'WL_GROOVY_JAR must point to an existing Groovy runtime');
|
|
140
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'wl-ops-groovy-'));
|
|
141
|
+
t.after(() => rmSync(directory, {recursive: true, force: true}));
|
|
142
|
+
const harness = path.join(directory, 'ops.groovy');
|
|
143
|
+
writeFileSync(harness, String.raw`
|
|
144
|
+
def loader = new GroovyClassLoader()
|
|
145
|
+
def interruptedClass = loader.parseClass('package org.jenkinsci.plugins.workflow.steps; class FlowInterruptedException extends Exception { FlowInterruptedException(String message) { super(message) } }')
|
|
146
|
+
def parser = loader.parseClass('package groovy.json; class JsonSlurperClassic { static Map values=[:]; def parseText(String text) { assert values.containsKey(text):text; return values[text] } }')
|
|
147
|
+
def source = new File(args[0]).text
|
|
148
|
+
for (def mode in ['normal', 'no-bot', 'interrupted', 'claim-failure', 'claim-missing', 'claim-unknown', 'already-started', 'ack-failure']) {
|
|
149
|
+
def events=[], files=[:], env=[:], currentBuild=[:], working=[], credentials=0, builds=0, cleanups=0, startedKeys=[], acknowledgedKeys=[]
|
|
150
|
+
def multi=mode in ['claim-failure', 'claim-missing', 'claim-unknown', 'ack-failure']
|
|
151
|
+
def inventory=[runId:'fixture-run', jobsDiscovered:2, targets:[[key:'a'*24, checkoutAllowed:true, branch:'test', repository:'https://gitlab.example.invalid/group/produce.git', credentialId:'scm'],[key:'b'*24, checkoutAllowed:false, branch:null]]]
|
|
152
|
+
def summary=[runId:'fixture-run', globalGaps:(mode in ['no-bot','interrupted'] ? [[id:'pending']] : []), projects:[[key:'a'*24, status:'ready', checkJob:'produce/CHECK']]]
|
|
153
|
+
if(multi){inventory.targets[1]=[key:'b'*24,checkoutAllowed:true,branch:'test',repository:'https://gitlab.example.invalid/group/produce.git',credentialId:'scm'];summary.projects<<[key:'b'*24,status:'ready',checkJob:'produce/NEXT-CHECK']}
|
|
154
|
+
def claim=[status:'claimed', job:'produce/CHECK', sha:'c'*40, dispatchId:'ops-fixture-approved-check']
|
|
155
|
+
def nextClaim=[status:'claimed', job:'produce/NEXT-CHECK', sha:'d'*40, dispatchId:'ops-fixture-next-check']
|
|
156
|
+
parser.values=[inventory:inventory, summary:summary, claim:claim, nextClaim:nextClaim, unknown:[status:'unknown',reason:'fixture start unknown'], already:[status:'already-started',job:'produce/CHECK']]
|
|
157
|
+
Binding binding=new Binding([env:env, params:[:], currentBuild:currentBuild])
|
|
158
|
+
def shell=new GroovyShell(loader,binding)
|
|
159
|
+
binding.setVariable('pwd',{->'/workspace'})
|
|
160
|
+
binding.setVariable('dir',{location,body->working<<location;try{return body()}finally{working.remove(working.size()-1)}})
|
|
161
|
+
binding.setVariable('deleteDir',{->cleanups++;events<<'cleanup'})
|
|
162
|
+
binding.setVariable('writeFile',{options->files[options.file]=options.text})
|
|
163
|
+
binding.setVariable('readFile',{location->def key=location.toString();assert files.containsKey(key):key;return files[key]})
|
|
164
|
+
binding.setVariable('fileExists',{location->files.containsKey(location.toString())})
|
|
165
|
+
binding.setVariable('archiveArtifacts',{options->events<<'archive'})
|
|
166
|
+
binding.setVariable('stage',{name,body->events<<name;body()})
|
|
167
|
+
binding.setVariable('timeout',{options,body->body()})
|
|
168
|
+
binding.setVariable('echo',{message->events<<'echo'})
|
|
169
|
+
binding.setVariable('withEnv',{values,body->def old=new LinkedHashMap(env);values.each{value->def pair=value.split('=',2);env[pair[0]]=pair[1]};try{return body()}finally{env.clear();env.putAll(old)}})
|
|
170
|
+
['usernamePassword','string','file'].each{name->binding.setVariable(name,{options->options})}
|
|
171
|
+
binding.setVariable('withCredentials',{values,body->
|
|
172
|
+
if(mode=='no-bot'&&values.any{it.credentialsId.startsWith('wl-feishu')})throw new RuntimeException('fixture missing bot credential')
|
|
173
|
+
def old=new LinkedHashMap(env);credentials++
|
|
174
|
+
values.each{value->if(value.variable)env[value.variable]='fixture';if(value.usernameVariable){env[value.usernameVariable]='fixture';env[value.passwordVariable]='fixture'}}
|
|
175
|
+
try{return body()}finally{credentials--;env.clear();env.putAll(old)}
|
|
176
|
+
})
|
|
177
|
+
binding.setVariable('checkout',{options->
|
|
178
|
+
assert credentials==0:'SCM checkout held API/bot credentials'
|
|
179
|
+
assert options.scm.branches[0].name=='origin/test'
|
|
180
|
+
assert options.scm.userRemoteConfigs[0].credentialsId=='scm'
|
|
181
|
+
events<<'checkout'
|
|
182
|
+
if(mode=='interrupted')throw interruptedClass.newInstance('fixture manual cancellation')
|
|
183
|
+
return [GIT_COMMIT:'c'*40]
|
|
184
|
+
})
|
|
185
|
+
binding.setVariable('build',{options->
|
|
186
|
+
assert credentials==0:'CHECK start held interface/bot credentials'
|
|
187
|
+
def expected=env.WL_OPS_KEY=='a'*24?claim:nextClaim
|
|
188
|
+
assert options.job==expected.job
|
|
189
|
+
assert options.waitForStart==true&&options.wait==false&&options.propagate==false
|
|
190
|
+
assert options.parameters.find{it.name=='WL_REVIEW_SHA'}.value==expected.sha
|
|
191
|
+
assert options.parameters.find{it.name=='WL_GATE_DISPATCH_ID'}.value==expected.dispatchId
|
|
192
|
+
builds++;startedKeys<<env.WL_OPS_KEY;events<<'CHECK-start';return [number:40+builds]
|
|
193
|
+
})
|
|
194
|
+
binding.setVariable('sh',{options->
|
|
195
|
+
String command=options instanceof Map?options.script:options
|
|
196
|
+
if(command.contains('jenkins ops inventory')){assert env.WL_JENKINS_READ_TOKEN;assert !env.WL_FEISHU_CI_SECRET;files['wl-ops-public/inventory.json']='inventory';events<<'inventory'}
|
|
197
|
+
else if(command.contains('jenkins ops inspect')){assert credentials==0;events<<'inspect'}
|
|
198
|
+
else if(command.contains('jenkins ops finish')){if(command.contains('--send'))assert env.WL_FEISHU_CI_SECRET;else assert credentials==0;files['wl-ops-public/summary.json']='summary';events<<'finish';return mode in ['no-bot','interrupted']?2:0}
|
|
199
|
+
else if(command.contains('jenkins ops claim-check')){
|
|
200
|
+
assert env.WL_JENKINS_READ_TOKEN;assert !env.WL_FEISHU_CI_SECRET;events<<'claim'
|
|
201
|
+
if(env.WL_OPS_KEY=='a'*24&&mode=='claim-failure')return 2
|
|
202
|
+
if(env.WL_OPS_KEY=='a'*24&&mode=='claim-missing')return 0
|
|
203
|
+
files[env.WL_OPS_CLAIM]=env.WL_OPS_KEY=='b'*24?'nextClaim':mode=='claim-unknown'?'unknown':mode=='already-started'?'already':'claim'
|
|
204
|
+
}
|
|
205
|
+
else if(command.contains('jenkins ops ack-check')){
|
|
206
|
+
def expected=env.WL_OPS_KEY=='a'*24?claim:nextClaim
|
|
207
|
+
assert env.WL_OPS_BUILD==(40+builds).toString()
|
|
208
|
+
assert env.WL_OPS_DISPATCH==expected.dispatchId
|
|
209
|
+
assert command.contains('--dispatch-id')
|
|
210
|
+
if(mode=='ack-failure'&&env.WL_OPS_KEY=='a'*24)throw new RuntimeException('fixture ACK write failed')
|
|
211
|
+
acknowledgedKeys<<env.WL_OPS_KEY;events<<'ack'
|
|
212
|
+
}
|
|
213
|
+
else assert false:command
|
|
214
|
+
return 0
|
|
215
|
+
})
|
|
216
|
+
def script=shell.parse(source,'native_ops.groovy');script.run()
|
|
217
|
+
def failure=null
|
|
218
|
+
try{script.wlOpsRun([gateRoot:'/fixed/gate',clusterFile:'/controlled/cluster.json'])}catch(Exception expected){failure=expected}
|
|
219
|
+
assert events.contains('inventory')&&events.contains('finish')&&events.contains('archive')
|
|
220
|
+
assert credentials==0
|
|
221
|
+
if(mode=='normal'){
|
|
222
|
+
assert failure==null:failure
|
|
223
|
+
assert builds==1&&events.indexOf('claim')<events.indexOf('CHECK-start')&&events.indexOf('CHECK-start')<events.indexOf('ack')
|
|
224
|
+
assert events.count('inspect')==2
|
|
225
|
+
}else if(mode=='no-bot'){
|
|
226
|
+
assert failure==null:failure
|
|
227
|
+
assert builds==0&¤tBuild.result=='UNSTABLE'
|
|
228
|
+
}else if(mode=='interrupted'){
|
|
229
|
+
assert interruptedClass.isInstance(failure)
|
|
230
|
+
assert builds==0&&events.count('checkout')==1&&events.count('inspect')==0
|
|
231
|
+
assert cleanups>=2
|
|
232
|
+
}else if(mode=='already-started'){
|
|
233
|
+
assert failure==null:failure
|
|
234
|
+
assert builds==0&¤tBuild.result==null
|
|
235
|
+
}else{
|
|
236
|
+
assert failure==null:failure
|
|
237
|
+
assert currentBuild.result=='UNSTABLE'
|
|
238
|
+
assert events.count('archive')>=2
|
|
239
|
+
if(mode=='ack-failure'){assert startedKeys==['a'*24,'b'*24];assert acknowledgedKeys==['b'*24]}
|
|
240
|
+
else{assert startedKeys==['b'*24];assert acknowledgedKeys==['b'*24]}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
println 'Operations native Groovy mock passed: normal/no-bot/interrupted/claim-failure/claim-missing/claim-unknown/already-started/ack-failure.'
|
|
244
|
+
`);
|
|
245
|
+
const result = spawnSync(process.env.JAVA || 'java', ['-cp', jar, 'groovy.ui.GroovyMain', harness, new URL('../../templates/ci/jenkins-native.groovy', import.meta.url).pathname], {encoding: 'utf8', timeout: 30000});
|
|
246
|
+
assert.equal(result.status, 0, result.stderr + '\n' + result.stdout);
|
|
247
|
+
assert.match(result.stdout, /Operations native Groovy mock passed/);
|
|
248
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createHmac } from 'node:crypto';
|
|
4
|
+
import { opsNotification, sendOpsNotification } from '../../lib/jenkins-ops-notification.js';
|
|
5
|
+
|
|
6
|
+
const report = () => ({ kind: 'jenkins-ops-report', checkedAt: '2026-10-22T01:02:03Z', jenkinsOrigin: 'https://jenkins.example.invalid/', runUrl: 'https://jenkins.example.invalid/job/doctor/7/', status: 'attention', globalGaps: [], projects: [{ key: 'p:test', project: 'wl-produce', branch: 'test', jobs: ['p-test'], status: 'attention', gaps: [{ id: 'gate-package-missing', message: '项目未安装Gate', action: '技术经理执行项目接入,再自动复检' }], technicalManager: 'manager', executor: 'author' }], remoteModified: false, qualityPassed: false, buildAuthorized: false });
|
|
7
|
+
const person = (name, id, verified = true) => ({ name, feishu: { idType: 'user_id', id, verified } });
|
|
8
|
+
const config = () => ({ people: { manager: person('经理', 'verified-manager'), author: person('执行人', 'verified-author'), observer: person('李月', 'verified-observer') }, owners: { 'wl-produce': { technicalManager: 'manager', managerSubmissionNotify: 'observer' } } });
|
|
9
|
+
const json = value => JSON.stringify(value);
|
|
10
|
+
const env = { WL_FEISHU_CI_WEBHOOK: 'https://open.feishu.cn/open-apis/bot/v2/hook/ci-fixture', WL_FEISHU_CI_SECRET: 'fixture-secret' };
|
|
11
|
+
|
|
12
|
+
test('运维缺项通知无需项目配置:时间、缺项、动作与真实@;不冒充质量通过', () => {
|
|
13
|
+
const out = opsNotification(report(), config()), content = json(out.payload);
|
|
14
|
+
assert.deepEqual(out.mentions.sort(), ['verified-author', 'verified-manager']);
|
|
15
|
+
assert.equal(out.mentionMappingComplete, true);
|
|
16
|
+
assert.match(content, /2026年10月22日 09:02:03/);
|
|
17
|
+
assert.match(content, /项目未安装Gate/); assert.match(content, /自动复检/); assert.match(content, /未接入不等于扫描通过/);
|
|
18
|
+
assert.match(content, /<at id=\\"verified-manager/); assert.match(content, /查看本次接入检查/);
|
|
19
|
+
});
|
|
20
|
+
test('姓名工号不推断飞书身份;无真实ID仍发送缺项卡', () => {
|
|
21
|
+
const c = config(); c.people.manager.feishu.verified = false; c.people.author = { name: '杨晨誉', employeeId: '409322' };
|
|
22
|
+
const out = opsNotification(report(), c);
|
|
23
|
+
assert.equal(out.mentionMappingComplete, false); assert.deepEqual(out.mentions, []);
|
|
24
|
+
assert.doesNotMatch(json(out.payload), /<at /); assert.match(json(out.payload), /真实 @ 尚未配置/);
|
|
25
|
+
});
|
|
26
|
+
test('同一人员去重,经理本人提交才同步指定观察人', () => {
|
|
27
|
+
const r = report(); r.projects[0].executor = 'manager';
|
|
28
|
+
const out = opsNotification(r, config()), content = json(out.payload);
|
|
29
|
+
assert.deepEqual(out.mentions.sort(), ['verified-manager', 'verified-observer']);
|
|
30
|
+
assert.equal((content.match(/<at id=\\"verified-manager/g) || []).length, 1);
|
|
31
|
+
assert.equal((content.match(/<at id=\\"verified-observer/g) || []).length, 1);
|
|
32
|
+
});
|
|
33
|
+
test('同一飞书ID的不同人员键去重;额外主责和公共兜底人都实际出现在卡片中', () => {
|
|
34
|
+
const r = report(), c = config(); c.people.author.feishu.id = c.people.manager.feishu.id;
|
|
35
|
+
c.people.ops = person('运维', 'verified-ops'); c.owners['wl-produce'].recipients = ['ops']; c.fallbackRecipients = ['observer'];
|
|
36
|
+
const out = opsNotification(r, c), content = json(out.payload);
|
|
37
|
+
assert.deepEqual(out.mentions.sort(), ['verified-manager', 'verified-observer', 'verified-ops']);
|
|
38
|
+
for (const id of out.mentions) assert.equal((content.match(new RegExp('<at id=\\\\"' + id, 'g')) || []).length, 1);
|
|
39
|
+
});
|
|
40
|
+
test('项目限前三摘要,但所有项目责任人仍提醒;不修改输入', () => {
|
|
41
|
+
const r = report(), c = config();
|
|
42
|
+
r.projects = Array.from({ length: 5 }, (_, i) => ({ ...r.projects[0], key: 'project-' + i, project: 'project-' + i, technicalManager: 'm' + i, executor: 'a' + i }));
|
|
43
|
+
for (let i = 0; i < 5; i++) { c.people['m' + i] = person('经理' + i, 'manager-' + i); c.people['a' + i] = person('执行人' + i, 'author-' + i); }
|
|
44
|
+
const before = json(r), out = opsNotification(r, c), content = json(out.payload);
|
|
45
|
+
assert.match(content, /3\/5/); assert.doesNotMatch(content, /project-3 ·/); assert.equal(out.mentions.length, 10); assert.equal(json(r), before);
|
|
46
|
+
});
|
|
47
|
+
test('仅真实同源HTTPS本次记录可点击,缺失地址不伪造详情页', () => {
|
|
48
|
+
for (const url of [undefined, 'http://localhost:8090/', 'https://attacker.invalid/', 'https://jenkins.example.invalid/?token=private']) {
|
|
49
|
+
const r = report(); r.runUrl = url; const out = opsNotification(r);
|
|
50
|
+
assert.doesNotMatch(json(out.payload), /open_url/); assert.doesNotMatch(json(out.payload), /private/);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
test('全局缺项分类且限前两条;恢复卡绿色但仍要求九维和Review', () => {
|
|
54
|
+
const r = report(); r.globalGaps = Array.from({ length: 4 }, (_, i) => ({ id: 'credential-' + i, message: '公共凭据缺项' + i, action: '配置受控凭据' }));
|
|
55
|
+
assert.match(json(opsNotification(r).payload), /凭据/访问权限 4/);
|
|
56
|
+
r.globalGaps = []; r.projects[0].status = 'ready'; r.projects[0].gaps = []; r.status = 'ready'; r.recovered = true;
|
|
57
|
+
const out = opsNotification(r, config()); assert.equal(out.payload.card.header.template, 'green'); assert.match(json(out.payload), /转入现有九维检查与Review准入/);
|
|
58
|
+
r.globalGaps = [{ id: 'missing-config', message: '仍然缺配置' }]; assert.notEqual(opsNotification(r).payload.card.header.template, 'green');
|
|
59
|
+
});
|
|
60
|
+
test('只读取CI机器人、正确签名和飞书确认;密钥不出现在回执中', async () => {
|
|
61
|
+
let calls = 0;
|
|
62
|
+
const out = await sendOpsNotification(report(), config(), { env: { ...env, WL_FEISHU_PROJECT_WEBHOOK: 'https://wrong.invalid/' }, now: 123000, fetchImpl: async (url, options) => {
|
|
63
|
+
calls++; assert.equal(url, env.WL_FEISHU_CI_WEBHOOK); assert.equal(options.redirect, 'error');
|
|
64
|
+
const body = JSON.parse(options.body); assert.equal(body.timestamp, '123');
|
|
65
|
+
assert.equal(body.sign, createHmac('sha256', '123\nfixture-secret').update('').digest('base64')); assert.doesNotMatch(options.body, /fixture-secret/);
|
|
66
|
+
return { status: 200, text: async () => '{"code":0}' };
|
|
67
|
+
} });
|
|
68
|
+
assert.equal(calls, 1); assert.equal(out.status, 'sent'); assert.equal(out.qualityPassed, false); assert.doesNotMatch(json(out), /fixture-secret|ci-fixture/);
|
|
69
|
+
});
|
|
70
|
+
test('超时未知不重发;HTTP和provider错误不可记sent;异常文本去敏', async () => {
|
|
71
|
+
let calls = 0;
|
|
72
|
+
const unknown = await sendOpsNotification(report(), config(), { env, fetchImpl: async () => { calls++; throw new Error('private-secret-token'); } });
|
|
73
|
+
assert.equal(calls, 1); assert.equal(unknown.status, 'unknown'); assert.doesNotMatch(json(unknown), /private-secret-token/);
|
|
74
|
+
for (const response of [{ status: 403 }, { status: 200, text: async () => '{"code":19021}' }, { status: 200, text: async () => '{"code":19021,"StatusCode":0}' }, { status: 200, text: async () => '{}' }]) assert.equal((await sendOpsNotification(report(), {}, { env, fetchImpl: async () => response })).status, 'rejected');
|
|
75
|
+
assert.equal((await sendOpsNotification(report(), {}, { env, fetchImpl: async () => ({ status: 200, text: async () => 'bad-json' }) })).status, 'unknown');
|
|
76
|
+
});
|
|
77
|
+
test('不降级项目机器人;带凭据地址拒绝;无@不会阻止有效CI消息送达', async () => {
|
|
78
|
+
let calls = 0; const fetchImpl = async () => { calls++; return { status: 200, text: async () => '{"StatusCode":0}' }; };
|
|
79
|
+
assert.equal((await sendOpsNotification(report(), {}, { env: { WL_FEISHU_PROJECT_WEBHOOK: env.WL_FEISHU_CI_WEBHOOK, WL_FEISHU_PROJECT_SECRET: env.WL_FEISHU_CI_SECRET }, fetchImpl })).status, 'not-sent');
|
|
80
|
+
assert.equal((await sendOpsNotification(report(), {}, { env: { ...env, WL_FEISHU_CI_WEBHOOK: 'https://name:password@open.feishu.cn/open-apis/bot/v2/hook/ci' }, fetchImpl })).status, 'not-sent');
|
|
81
|
+
assert.equal(calls, 0);
|
|
82
|
+
const out = await sendOpsNotification(report(), {}, { env, fetchImpl }); assert.equal(out.status, 'sent'); assert.equal(out.mentionMappingComplete, false); assert.equal(calls, 1);
|
|
83
|
+
});
|
|
84
|
+
test('缺项信息中的token或地址凭据去敏;报告类型无效拒绝', () => {
|
|
85
|
+
const r = report(); r.projects[0].gaps[0].message = 'secret=really-private https://name:pass@internal.invalid/path?token=private';
|
|
86
|
+
const content = json(opsNotification(r).payload); assert.doesNotMatch(content, /really-private|name:pass|token=private/);
|
|
87
|
+
assert.throws(() => opsNotification({}), /真实运维接入检查报告/);
|
|
88
|
+
});
|