@celilo/cli 1.13.0 → 2.0.0
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/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: run ONE remote primitive from its own process, the way a hook
|
|
3
|
+
* does.
|
|
4
|
+
*
|
|
5
|
+
* The remote-ops bridge blocks its caller in `spawnSync` while celilo's event
|
|
6
|
+
* loop answers the socket, so the asking half can never share a process with
|
|
7
|
+
* the broker — in production it never does. A test that calls a bridged
|
|
8
|
+
* primitive in the broker's own process deadlocks: the loop that must accept
|
|
9
|
+
* the connection is the loop spawnSync is blocking. This fixture is what
|
|
10
|
+
* keeps the test topology honest.
|
|
11
|
+
*
|
|
12
|
+
* One instruction on argv[2] (JSON), one result line on stdout.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
installAuthorizedKey,
|
|
17
|
+
probeHttp,
|
|
18
|
+
remoteExec,
|
|
19
|
+
serviceCtl,
|
|
20
|
+
streamBackup,
|
|
21
|
+
streamRestore,
|
|
22
|
+
} from '@celilo/capabilities';
|
|
23
|
+
|
|
24
|
+
const instruction = JSON.parse(process.argv[2] ?? '{}') as {
|
|
25
|
+
op: string;
|
|
26
|
+
target: { ipv4_address: string; user?: string; port?: number; identityFile?: string };
|
|
27
|
+
command?: string;
|
|
28
|
+
unit?: string;
|
|
29
|
+
action?: 'restart';
|
|
30
|
+
producerCommand?: string;
|
|
31
|
+
consumerCommand?: string;
|
|
32
|
+
localPath?: string;
|
|
33
|
+
password?: string;
|
|
34
|
+
opts?: { timeoutMs?: number; input?: string; env?: Record<string, string> };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let result: unknown;
|
|
38
|
+
switch (instruction.op) {
|
|
39
|
+
case 'remoteExec':
|
|
40
|
+
result = remoteExec(instruction.target, instruction.command ?? '', instruction.opts ?? {});
|
|
41
|
+
break;
|
|
42
|
+
case 'serviceCtl':
|
|
43
|
+
result = serviceCtl(
|
|
44
|
+
instruction.target,
|
|
45
|
+
instruction.unit ?? '',
|
|
46
|
+
instruction.action ?? 'restart',
|
|
47
|
+
);
|
|
48
|
+
break;
|
|
49
|
+
case 'streamBackup':
|
|
50
|
+
result = streamBackup(
|
|
51
|
+
instruction.target,
|
|
52
|
+
instruction.producerCommand ?? '',
|
|
53
|
+
instruction.localPath ?? '',
|
|
54
|
+
);
|
|
55
|
+
break;
|
|
56
|
+
case 'streamRestore':
|
|
57
|
+
result = streamRestore(
|
|
58
|
+
instruction.target,
|
|
59
|
+
instruction.localPath ?? '',
|
|
60
|
+
instruction.consumerCommand ?? '',
|
|
61
|
+
);
|
|
62
|
+
break;
|
|
63
|
+
case 'installAuthorizedKey':
|
|
64
|
+
result = installAuthorizedKey(instruction.target, instruction.password ?? '');
|
|
65
|
+
break;
|
|
66
|
+
case 'probeHttpRefused':
|
|
67
|
+
result = await probeHttp(instruction.target, { port: 80 }, async () => {
|
|
68
|
+
throw new Error('fetch must not run for a refused target');
|
|
69
|
+
});
|
|
70
|
+
break;
|
|
71
|
+
case 'probeHttpAllowed':
|
|
72
|
+
result = await probeHttp(
|
|
73
|
+
instruction.target,
|
|
74
|
+
{ port: 80 },
|
|
75
|
+
async () => new Response('ok', { status: 200 }),
|
|
76
|
+
);
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
throw new Error(`unknown op '${instruction.op}'`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(JSON.stringify(result));
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unjailed advisory lint (hook-process-boundary task 4.7, design D8).
|
|
3
|
+
*
|
|
4
|
+
* The integration tests below run REAL hook scripts through
|
|
5
|
+
* `executeHookScript` — the spawned shim, the real env hand-off, the real
|
|
6
|
+
* `node:fs` wrappers — because the lint's whole subject is what happens
|
|
7
|
+
* inside the child process, and a test that called `installUnjailedLint` in
|
|
8
|
+
* the test process would wrap `node:fs` for every other test in the run.
|
|
9
|
+
*
|
|
10
|
+
* `CELILO_HOOK_JAIL=off` forces the unjailed path on every platform, so these
|
|
11
|
+
* assertions hold on a Linux CI box with bubblewrap installed as well as on a
|
|
12
|
+
* Mac with none. That is also the honest reach: the lint's input is the same
|
|
13
|
+
* mount set the jail would have used, and the only difference between this
|
|
14
|
+
* run and a jailed one is that nothing enforces it.
|
|
15
|
+
*
|
|
16
|
+
* Rule 7.6, watched both ways while landing: with the shim's install call
|
|
17
|
+
* removed, the warning assertion goes red; with it restored, the negative
|
|
18
|
+
* assertion (in-set access is silent) stays green.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
|
22
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join, resolve } from 'node:path';
|
|
25
|
+
import { executeHookScript, hookChildEnv } from './executor';
|
|
26
|
+
import type { MountSetWire } from './hook-protocol';
|
|
27
|
+
import { createCapturingLogger } from './logger';
|
|
28
|
+
import { deriveMountSet } from './mount-set';
|
|
29
|
+
import type { HookContext } from './types';
|
|
30
|
+
import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint';
|
|
31
|
+
|
|
32
|
+
/** The workspace copy of the package every module bundles. */
|
|
33
|
+
const CAPABILITIES_PACKAGE = resolve(import.meta.dir, '../../../../packages/capabilities');
|
|
34
|
+
|
|
35
|
+
const scratchRoots: string[] = [];
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
for (const root of scratchRoots) rmSync(root, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A minimal module fixture: a hook script and its own bundled
|
|
43
|
+
* `@celilo/capabilities` (celilo#173), so the shim's brand check sees a real
|
|
44
|
+
* module and the derivation has a real tree to bind.
|
|
45
|
+
*/
|
|
46
|
+
function scratchModule(script: string): string {
|
|
47
|
+
const root = mkdtempSync(join(tmpdir(), 'celilo-lint-'));
|
|
48
|
+
scratchRoots.push(root);
|
|
49
|
+
writeFileSync(join(root, 'hook.ts'), script);
|
|
50
|
+
mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true });
|
|
51
|
+
symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities'));
|
|
52
|
+
return root;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const describeSet = (overrides: Partial<MountSetWire>): MountSetWire => ({
|
|
56
|
+
entries: [
|
|
57
|
+
{ path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run' },
|
|
58
|
+
{ path: '/srv/celilo/mod', mode: 'ro', reason: "the module's own tree" },
|
|
59
|
+
{ path: '/srv/celilo/mod/state', mode: 'rw', reason: 'the sanctioned writable directory' },
|
|
60
|
+
{ path: '/srv/staged', mode: 'rw', reason: "contract input 'backup_dir' (write)" },
|
|
61
|
+
],
|
|
62
|
+
chdir: '/srv/celilo/mod',
|
|
63
|
+
...overrides,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('classifyAccess', () => {
|
|
67
|
+
const set = describeSet({});
|
|
68
|
+
|
|
69
|
+
test('a path under no row is absent', () => {
|
|
70
|
+
expect(classifyAccess(set, '/etc/hosts', false)).toBe('absent');
|
|
71
|
+
expect(classifyAccess(set, '/srv/other-mod', true)).toBe('absent');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a read inside the module tree is allowed; a write is read-only', () => {
|
|
75
|
+
expect(classifyAccess(set, '/srv/celilo/mod/hook.ts', false)).toBe('allowed');
|
|
76
|
+
expect(classifyAccess(set, '/srv/celilo/mod/hook.ts', true)).toBe('read-only');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a later row wins, which is how a writable directory sits inside a read-only tree', () => {
|
|
80
|
+
expect(classifyAccess(set, '/srv/celilo/mod/state/cursor', true)).toBe('allowed');
|
|
81
|
+
expect(classifyAccess(set, '/srv/celilo/mod/state/cursor', false)).toBe('allowed');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a tmpfs row allows both', () => {
|
|
85
|
+
expect(classifyAccess(set, '/tmp/x', false)).toBe('allowed');
|
|
86
|
+
expect(classifyAccess(set, '/tmp/x', true)).toBe('allowed');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('the paths the namespace itself provides are allowed', () => {
|
|
90
|
+
expect(classifyAccess(set, '/proc/self/status', false)).toBe('allowed');
|
|
91
|
+
expect(classifyAccess(set, '/dev/null', true)).toBe('allowed');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a relative path resolves against chdir, the jailed hook working directory', () => {
|
|
95
|
+
expect(classifyAccess(set, 'hook.ts', false)).toBe('allowed');
|
|
96
|
+
expect(classifyAccess(set, 'hook.ts', true)).toBe('read-only');
|
|
97
|
+
expect(classifyAccess(set, '../outside', false)).toBe('absent');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('the environment hand-off', () => {
|
|
102
|
+
test('the serializer and the parser agree, starting from the real derivation', () => {
|
|
103
|
+
const modulePath = mkdtempSync(join(tmpdir(), 'celilo-lint-req-'));
|
|
104
|
+
scratchRoots.push(modulePath);
|
|
105
|
+
const set = deriveMountSet({
|
|
106
|
+
modulePath,
|
|
107
|
+
stateDir: join(modulePath, 'state'),
|
|
108
|
+
socketDir: '/tmp/celilo-hook-abc',
|
|
109
|
+
runtimePath: '/usr/local/bin/bun',
|
|
110
|
+
runnerPath: '/srv/celilo/src/hooks/hook-runner.ts',
|
|
111
|
+
runtimeModulePaths: ['/srv/celilo/node_modules'],
|
|
112
|
+
pathInputs: [{ name: 'backup_dir', value: '/tmp/staged', access: 'write' }],
|
|
113
|
+
});
|
|
114
|
+
const parsed = parseLintMountSet(mountSetEnvValue(set));
|
|
115
|
+
expect(parsed).toEqual(JSON.parse(JSON.stringify(set)));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('absent or invalid input disables the lint rather than failing the hook', () => {
|
|
119
|
+
expect(parseLintMountSet(undefined)).toBeUndefined();
|
|
120
|
+
expect(parseLintMountSet('')).toBeUndefined();
|
|
121
|
+
expect(parseLintMountSet('not json')).toBeUndefined();
|
|
122
|
+
expect(parseLintMountSet(JSON.stringify({ entries: [{ path: 1 }] }))).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('hookChildEnv carries the set only when one is given', () => {
|
|
126
|
+
const set = describeSet({});
|
|
127
|
+
const env = hookChildEnv('/tmp/s', '/tmp/r', set);
|
|
128
|
+
expect(parseLintMountSet(env.CELILO_HOOK_MOUNT_SET)).toEqual(set);
|
|
129
|
+
expect('CELILO_HOOK_MOUNT_SET' in hookChildEnv('/tmp/s', '/tmp/r', undefined)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('the lint inside a real hook run', () => {
|
|
134
|
+
const savedJail = process.env.CELILO_HOOK_JAIL;
|
|
135
|
+
const savedModePath = process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
136
|
+
let modeStore: string;
|
|
137
|
+
|
|
138
|
+
const startUnjailed = (): void => {
|
|
139
|
+
// `off` forces the unjailed path on every platform, mount set and all —
|
|
140
|
+
// which is exactly the configuration the lint exists for.
|
|
141
|
+
process.env.CELILO_HOOK_JAIL = 'off';
|
|
142
|
+
modeStore = join(mkdtempSync(join(tmpdir(), 'celilo-lint-mode-')), 'mode.json');
|
|
143
|
+
scratchRoots.push(modeStore);
|
|
144
|
+
process.env.CELILO_HOOK_JAIL_MODE_PATH = modeStore;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
if (savedJail === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
149
|
+
else process.env.CELILO_HOOK_JAIL = savedJail;
|
|
150
|
+
if (savedModePath === undefined) delete process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
151
|
+
else process.env.CELILO_HOOK_JAIL_MODE_PATH = savedModePath;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const run = async (
|
|
155
|
+
script: string,
|
|
156
|
+
): Promise<{ messages: Array<{ level: string; message: string }> }> => {
|
|
157
|
+
startUnjailed();
|
|
158
|
+
const root = scratchModule(script);
|
|
159
|
+
mkdirSync(join(root, 'state'), { recursive: true });
|
|
160
|
+
const { logger, messages } = createCapturingLogger();
|
|
161
|
+
const context: HookContext = {
|
|
162
|
+
config: {},
|
|
163
|
+
secrets: {},
|
|
164
|
+
systems: [],
|
|
165
|
+
logger,
|
|
166
|
+
debug: false,
|
|
167
|
+
screenshotDir: join(root, 'artifacts'),
|
|
168
|
+
stateDir: join(root, 'state'),
|
|
169
|
+
capabilities: {},
|
|
170
|
+
};
|
|
171
|
+
await executeHookScript(join(root, 'hook.ts'), context, {
|
|
172
|
+
timeoutMs: 60_000,
|
|
173
|
+
idleTimeoutMs: 60_000,
|
|
174
|
+
jail: { modulePath: root, pathInputs: [] },
|
|
175
|
+
});
|
|
176
|
+
return { messages };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const advisories = (messages: Array<{ level: string; message: string }>): string[] =>
|
|
180
|
+
messages
|
|
181
|
+
.filter((m) => m.level === 'warn' && m.message.startsWith('Hook advisory:'))
|
|
182
|
+
.map((m) => m.message);
|
|
183
|
+
|
|
184
|
+
test('an access outside the mount set warns, and says it is advisory', async () => {
|
|
185
|
+
const { messages } = await run(`
|
|
186
|
+
import { defineHook } from '@celilo/capabilities';
|
|
187
|
+
import { readFileSync } from 'node:fs';
|
|
188
|
+
export default defineHook({
|
|
189
|
+
requires: [] as const,
|
|
190
|
+
handler: async () => {
|
|
191
|
+
try { readFileSync('/etc/hosts', 'utf-8'); } catch { /* the read is the point, not the bytes */ }
|
|
192
|
+
return {};
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
`);
|
|
196
|
+
|
|
197
|
+
const warnings = advisories(messages);
|
|
198
|
+
expect(warnings.length).toBe(1);
|
|
199
|
+
expect(warnings[0]).toContain('/etc/hosts');
|
|
200
|
+
expect(warnings[0]).toContain('on a jailed host');
|
|
201
|
+
// The one sentence the task forbids losing: what it is, and what it is not.
|
|
202
|
+
expect(warnings[0]).toContain('Advisory lint, not a security boundary');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('access inside the mount set — including the writable carve-outs — is silent', async () => {
|
|
206
|
+
const { messages } = await run(`
|
|
207
|
+
import { defineHook } from '@celilo/capabilities';
|
|
208
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
209
|
+
import { join } from 'node:path';
|
|
210
|
+
export default defineHook({
|
|
211
|
+
requires: [] as const,
|
|
212
|
+
handler: async (ctx) => {
|
|
213
|
+
// Its own tree, read-only in the mount set: reads are fine.
|
|
214
|
+
readFileSync(join(import.meta.dir, 'hook.ts'), 'utf-8');
|
|
215
|
+
// state/ sits INSIDE that tree and is carved read-write; both
|
|
216
|
+
// assertions ride the same run, which is the last-wins ordering.
|
|
217
|
+
writeFileSync(join(ctx.stateDir as string, 'cursor'), 'run-1');
|
|
218
|
+
readFileSync(join(ctx.stateDir as string, 'cursor'), 'utf-8');
|
|
219
|
+
return {};
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
`);
|
|
223
|
+
|
|
224
|
+
expect(advisories(messages)).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a write outside the mount set warns as absent, per path, and the cap holds', async () => {
|
|
228
|
+
const { messages } = await run(`
|
|
229
|
+
import { defineHook } from '@celilo/capabilities';
|
|
230
|
+
import { readFileSync } from 'node:fs';
|
|
231
|
+
import { homedir } from 'node:os';
|
|
232
|
+
import { join } from 'node:path';
|
|
233
|
+
export default defineHook({
|
|
234
|
+
requires: [] as const,
|
|
235
|
+
handler: async () => {
|
|
236
|
+
for (let i = 0; i < 30; i++) {
|
|
237
|
+
try { readFileSync(join(homedir(), 'celilo-lint-nope-' + i)); } catch { /* expected */ }
|
|
238
|
+
}
|
|
239
|
+
return {};
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
`);
|
|
243
|
+
|
|
244
|
+
const warnings = advisories(messages);
|
|
245
|
+
const listed = warnings.filter((w) => !w.includes('suppressed'));
|
|
246
|
+
const capNotice = warnings.filter((w) => w.includes('will be suppressed'));
|
|
247
|
+
expect(listed.length).toBe(25);
|
|
248
|
+
expect(capNotice.length).toBe(1);
|
|
249
|
+
expect(listed[0]).toContain('absent');
|
|
250
|
+
});
|
|
251
|
+
});
|