DC-151: WG nftables matrix compiler + transactional apply/rollback + tests
This commit is contained in:
2 files changed
+1101
No files matched your search
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Tests for src/network/wg-firewall.js (DC-151).
|
||||
* Coverage:
|
||||
* - golden ruleset text: single resource single grant, group grant
|
||||
* expansion, blocked person absent from ALL sets, default-deny tails,
|
||||
* admin ports only from admin set
|
||||
* - corrected baseline: WG UDP handshake rule NOT bound to dcnet0
|
||||
* (externalInterface scoping + null → unscoped), peer-to-peer forward
|
||||
* default-deny (oifname dcnet0 drop)
|
||||
* - name sanitization: 'plex-2!' → net_dc_res_plex_2, collision guard
|
||||
* - admin lockout guard; assertAdminIsolation (positive + adversarial)
|
||||
* - transactional apply (mocked execFile + fs): check-failure → no apply
|
||||
* + temp deleted; apply-failure → {applied:false} + rollback file
|
||||
* unchanged; verify-mismatch → rollback attempted; rollback() re-applies
|
||||
* previous; unique temp paths across applies
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
compileRuleset,
|
||||
createWgFirewallApplier,
|
||||
assertAdminIsolation,
|
||||
resourceSetName,
|
||||
FirewallCheckError,
|
||||
FirewallApplyError,
|
||||
} = require('../src/network/wg-firewall');
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const PEOPLE = [
|
||||
{
|
||||
id: 'admin1',
|
||||
displayName: 'Admin',
|
||||
groups: ['family'],
|
||||
status: 'active',
|
||||
devices: [{ keyId: 'k1', name: 'laptop', ip: '10.53.0.10' }],
|
||||
},
|
||||
{
|
||||
id: 'alice',
|
||||
displayName: 'Alice',
|
||||
groups: ['family'],
|
||||
status: 'active',
|
||||
devices: [{ keyId: 'k2', name: 'phone', ip: '10.53.1.20' }],
|
||||
},
|
||||
{
|
||||
id: 'bob',
|
||||
displayName: 'Bob',
|
||||
groups: [],
|
||||
status: 'active',
|
||||
devices: [{ keyId: 'k3', name: 'pc', ip: '10.53.2.30' }],
|
||||
},
|
||||
{
|
||||
id: 'mallory',
|
||||
displayName: 'Mallory',
|
||||
groups: ['family'],
|
||||
status: 'blocked',
|
||||
devices: [{ keyId: 'k4', name: 'tab', ip: '10.53.3.40' }],
|
||||
},
|
||||
];
|
||||
|
||||
const PEERS = {
|
||||
'admin1/dev1': '10.53.0.10',
|
||||
'alice/dev1': '10.53.1.20',
|
||||
'bob/dev1': '10.53.2.30',
|
||||
'mallory/dev1': '10.53.3.40',
|
||||
};
|
||||
|
||||
const RESOURCES = {
|
||||
plex: { ip: '10.53.0.1', port: 32400, protocol: 'tcp' },
|
||||
nas: { ip: '10.53.9.9', port: 445, protocol: 'tcp' },
|
||||
};
|
||||
|
||||
const BASE = {
|
||||
accessMap: { grants: [] },
|
||||
people: PEOPLE,
|
||||
peerAddresses: PEERS,
|
||||
resources: RESOURCES,
|
||||
adminPeers: ['admin1'],
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Compiler: golden ruleset text
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: compileRuleset golden text', () => {
|
||||
test('single resource single grant — exact set element + accept rule', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'plex', allowed: true }],
|
||||
},
|
||||
resources: { plex: RESOURCES.plex },
|
||||
});
|
||||
expect(text).toContain('set net_dc_res_plex {');
|
||||
expect(text).toContain('elements = { 10.53.1.20 }');
|
||||
expect(text).toContain(
|
||||
'ip saddr @net_dc_res_plex ip daddr 10.53.0.1 tcp dport 32400 accept comment "resource plex"'
|
||||
);
|
||||
// blocked + ungranted peers are NOT in the plex resource set
|
||||
const plexSet = text.split('set net_dc_res_plex {')[1].split('}')[0];
|
||||
expect(plexSet).not.toContain('10.53.3.40'); // mallory
|
||||
expect(plexSet).not.toContain('10.53.2.30'); // bob
|
||||
expect(plexSet).not.toContain('10.53.0.10'); // admin not granted plex → not in res set
|
||||
expect(plexSet).toContain('elements = { 10.53.1.20');
|
||||
});
|
||||
|
||||
test('group grant expands to every active member (person-override honored)', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'person', id: 'alice' }, resource: 'nas', allowed: false },
|
||||
],
|
||||
},
|
||||
resources: { nas: RESOURCES.nas },
|
||||
});
|
||||
// family = admin1, alice, mallory(blocked); alice overridden to false
|
||||
expect(text).toMatch(/set net_dc_res_nas \{\s*\n\s*type ipv4_addr\s*\n\s*elements = \{ 10\.53\.0\.10 \}/);
|
||||
expect(text).toContain('ip saddr @net_dc_res_nas ip daddr 10.53.9.9 tcp dport 445 accept');
|
||||
});
|
||||
|
||||
test('blocked person absent from ALL sets (resource sets and admin set)', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true }],
|
||||
},
|
||||
adminPeers: ['admin1', 'mallory'], // mallory blocked → must NOT get admin
|
||||
});
|
||||
const adminSetMatch = text.match(/set net_dc_admins \{[^}]*elements = \{ ([^}]*) \}/);
|
||||
expect(adminSetMatch).toBeTruthy();
|
||||
const adminEls = adminSetMatch[1].split(', ');
|
||||
expect(adminEls).toEqual(['10.53.0.10']); // mallory's 10.53.3.40 excluded
|
||||
expect(adminEls).not.toContain('10.53.3.40');
|
||||
});
|
||||
|
||||
test('default-deny tails present: hub input, peer egress, peer-to-peer', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain('ct state new drop comment "default-deny: hub input"');
|
||||
expect(text).toContain('drop comment "default-deny: peer egress without grant"');
|
||||
expect(text).toContain('drop comment "default-deny: peer-to-peer / new hub-to-peer"');
|
||||
});
|
||||
|
||||
test('admin ports only from admin set (3001 + 22)', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain(
|
||||
'iifname "dcnet0" ip saddr @net_dc_admins tcp dport { 22, 3001 } accept comment "admin ports: admin peers only"'
|
||||
);
|
||||
});
|
||||
|
||||
test('table declared as inet dcnet with destroy-first', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain('destroy table inet dcnet');
|
||||
expect(text).toContain('table inet dcnet {');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Compiler: corrected baseline (judge gate)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: WG UDP handshake + peer-to-peer baseline (corrected)', () => {
|
||||
test('UDP allow rule is NOT bound to dcnet0 — scoped to external interface', () => {
|
||||
const text = compileRuleset({ ...BASE, externalInterface: 'eth0' });
|
||||
const udpLines = text.split('\n').filter((l) => l.includes('udp dport 51820'));
|
||||
expect(udpLines).toHaveLength(1);
|
||||
expect(udpLines[0]).toContain('iifname "eth0"');
|
||||
expect(udpLines[0]).not.toContain('dcnet0');
|
||||
});
|
||||
|
||||
test('externalInterface null → unscoped UDP rule with comment', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
const udpLines = text.split('\n').filter((l) => l.includes('udp dport 51820'));
|
||||
expect(udpLines).toHaveLength(1);
|
||||
expect(udpLines[0]).not.toContain('iifname');
|
||||
expect(udpLines[0]).toContain('external iface unspecified');
|
||||
});
|
||||
|
||||
test('custom listen port honored', () => {
|
||||
const text = compileRuleset({ ...BASE, wgListenPort: 51821, externalInterface: 'ens3' });
|
||||
expect(text).toContain('iifname "ens3" udp dport 51821 accept');
|
||||
});
|
||||
|
||||
test('peer-to-peer forwarding default-deny: oifname dcnet0 drop tail', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(text).toContain('oifname "dcnet0" drop');
|
||||
// and no accept rule that would let peer→peer through ahead of it
|
||||
const forward = text.split('chain forward {')[1].split('}')[0];
|
||||
expect(forward).not.toMatch(/oifname "dcnet0".*accept/);
|
||||
});
|
||||
|
||||
test('hub-local resource lands in input chain; peer resource in forward chain', () => {
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'nas', allowed: true }],
|
||||
},
|
||||
});
|
||||
const input = text.split('chain input {')[1].split('chain forward')[0];
|
||||
const forward = text.split('chain forward {')[1];
|
||||
expect(input).toContain('@net_dc_res_plex');
|
||||
expect(input).not.toContain('@net_dc_res_nas');
|
||||
expect(forward).toContain('@net_dc_res_nas');
|
||||
expect(forward).not.toContain('@net_dc_res_plex');
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Compiler: sanitization + guards
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: sanitization and guards', () => {
|
||||
test("resource 'plex-2!' sanitizes to a safe set name", () => {
|
||||
expect(resourceSetName('plex-2!')).toBe('net_dc_res_plex_2');
|
||||
const text = compileRuleset({
|
||||
...BASE,
|
||||
resources: { 'plex-2!': { ip: '10.53.0.1', port: 32400 } },
|
||||
accessMap: {
|
||||
grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'plex-2!', allowed: true }],
|
||||
},
|
||||
});
|
||||
expect(text).toContain('set net_dc_res_plex_2 {');
|
||||
expect(text).toContain('@net_dc_res_plex_2');
|
||||
expect(text).toContain('ip saddr @net_dc_res_plex_2 ip daddr 10.53.0.1 tcp dport 32400 accept');
|
||||
});
|
||||
|
||||
test('set-name collision between distinct resources throws', () => {
|
||||
expect(() =>
|
||||
compileRuleset({ ...BASE, resources: { 'plex 1': { ip: '10.53.0.1', port: 1 }, 'plex!1': { ip: '10.53.0.2', port: 2 } } })
|
||||
).toThrow(/collision/);
|
||||
});
|
||||
|
||||
test('admin lockout guard: zero resolvable admin peers throws', () => {
|
||||
expect(() => compileRuleset({ ...BASE, adminPeers: ['ghost'] })).toThrow(/lockout/);
|
||||
});
|
||||
|
||||
test('invalid resource (missing ip / bad port) throws', () => {
|
||||
expect(() => compileRuleset({ ...BASE, resources: { x: { port: 80 } } })).toThrow(/ip required/);
|
||||
expect(() => compileRuleset({ ...BASE, resources: { x: { ip: '10.53.0.1', port: 0 } } })).toThrow(/port/);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// assertAdminIsolation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe('wg-firewall: assertAdminIsolation', () => {
|
||||
test('passes on a correctly compiled ruleset', () => {
|
||||
const text = compileRuleset(BASE);
|
||||
expect(assertAdminIsolation(text, { adminPeers: ['admin1'] })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects an admin port allowed without the admin set', () => {
|
||||
const bad = 'iifname "dcnet0" tcp dport 22 accept';
|
||||
expect(() => assertAdminIsolation(bad)).toThrow(/without @net_dc_admins/);
|
||||
});
|
||||
|
||||
test('rejects an admin port allowed from a resource set', () => {
|
||||
const bad = 'iifname "dcnet0" ip saddr @net_dc_res_plex tcp dport 3001 accept';
|
||||
expect(() => assertAdminIsolation(bad)).toThrow(/resource set/);
|
||||
});
|
||||
|
||||
test('rejects an admin port allowed from a literal peer IP', () => {
|
||||
const bad = 'iifname "dcnet0" ip saddr 10.53.4.4 tcp dport 22 accept';
|
||||
expect(() => assertAdminIsolation(bad)).toThrow(/literal peer saddr/);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Transactional applier (hermetic: mocked execFile + fs)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function mockFs() {
|
||||
const files = new Map();
|
||||
return {
|
||||
files,
|
||||
writeFileSync: jest.fn((p, c) => files.set(p, c)),
|
||||
readFileSync: jest.fn((p) => {
|
||||
if (!files.has(p)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; }
|
||||
return files.get(p);
|
||||
}),
|
||||
existsSync: jest.fn((p) => files.has(p)),
|
||||
unlinkSync: jest.fn((p) => files.delete(p)),
|
||||
};
|
||||
}
|
||||
|
||||
/** execFile mock with a per-call script of behaviors keyed by args. */
|
||||
function mockExec({ current = 'table inet dcnet { }', checkErr, applyErr, verifyOut } = {}) {
|
||||
const calls = [];
|
||||
const impl = async (cmd, args) => { // eslint-disable-line require-await -- scripted mock, no awaits by design
|
||||
calls.push({ cmd, args: [...args] });
|
||||
const joined = args.join(' ');
|
||||
if (joined.startsWith('-c -f')) {
|
||||
if (checkErr) {
|
||||
const e = new Error('check failed');
|
||||
e.stderr = 'syntax error near unexpected token';
|
||||
throw e;
|
||||
}
|
||||
return { stdout: '' };
|
||||
}
|
||||
if (joined.startsWith('-f')) {
|
||||
if (applyErr) {
|
||||
const e = new Error('apply failed');
|
||||
e.stderr = 'operation failed: Device or resource busy';
|
||||
throw e;
|
||||
}
|
||||
return { stdout: '' };
|
||||
}
|
||||
if (joined.startsWith('list table')) {
|
||||
if (verifyOut !== undefined) return { stdout: verifyOut };
|
||||
return { stdout: current };
|
||||
}
|
||||
return { stdout: '' };
|
||||
};
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
const RULESET = compileRuleset(BASE);
|
||||
const RULESET2 = compileRuleset({
|
||||
...BASE,
|
||||
accessMap: { grants: [{ principal: { type: 'person', id: 'bob' }, resource: 'plex', allowed: true }] },
|
||||
});
|
||||
|
||||
describe('wg-firewall: transactional apply', () => {
|
||||
test('happy path: check → rollback write → apply → verify; temp cleaned up', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl,
|
||||
fsImpl: fs,
|
||||
tempDir: '/tmp/x',
|
||||
rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
const res = await applier.apply(RULESET);
|
||||
expect(res.applied).toBe(true);
|
||||
expect(res.verified).toBe(true);
|
||||
// order: check(-c -f) before rollback write before apply(-f) before verify
|
||||
const checkIdx = calls.findIndex((c) => c.args[0] === '-c');
|
||||
const applyIdx = calls.findIndex((c) => c.args[0] === '-f');
|
||||
const listIdxs = calls.map((c, i) => (c.args[0] === 'list' ? i : -1)).filter((i) => i >= 0);
|
||||
expect(checkIdx).toBe(0);
|
||||
expect(applyIdx).toBeGreaterThan(checkIdx);
|
||||
// one `list` before apply (capture previous) + one after (verify)
|
||||
expect(listIdxs).toHaveLength(2);
|
||||
expect(listIdxs[0]).toBeLessThan(applyIdx);
|
||||
expect(listIdxs[1]).toBeGreaterThan(applyIdx);
|
||||
const writePaths = fs.writeFileSync.mock.calls.map((c) => c[0]);
|
||||
const tempWrites = writePaths.filter((p) => p !== '/tmp/x/rollback.nft');
|
||||
expect(tempWrites).toHaveLength(1);
|
||||
const tempPath = tempWrites[0];
|
||||
// write order: temp (for check) then rollback BEFORE the apply exec
|
||||
expect(writePaths).toEqual([tempPath, '/tmp/x/rollback.nft']);
|
||||
const applyCall = calls[applyIdx];
|
||||
expect(applyCall.args).toEqual(['-f', tempPath]);
|
||||
expect(applyCall.args[1]).not.toBe('/tmp/x/rollback.nft');
|
||||
// rollback file holds the PREVIOUS table dump
|
||||
expect(fs.readFileSync('/tmp/x/rollback.nft')).toBe('table inet dcnet { }');
|
||||
// temp file deleted at the end
|
||||
expect(fs.existsSync(tempPath)).toBe(false);
|
||||
expect(res.previousExisted).toBe(true);
|
||||
});
|
||||
|
||||
test('check failure → no apply, temp deleted, FirewallCheckError with stderr', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec({ checkErr: true });
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.apply(RULESET)).rejects.toMatchObject({
|
||||
name: 'FirewallCheckError',
|
||||
code: 'check_failed',
|
||||
applied: false,
|
||||
stderr: expect.stringContaining('syntax error'),
|
||||
});
|
||||
// NOTHING applied: no bare `-f` run, no `nft list` verify, no rollback write
|
||||
expect(calls.filter((c) => c.args[0] === '-f')).toHaveLength(0);
|
||||
expect(calls.filter((c) => c.args[0] === 'list')).toHaveLength(0);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledTimes(1); // only the temp file
|
||||
expect(fs.files.size).toBe(0); // temp deleted
|
||||
});
|
||||
|
||||
test('apply failure → {applied:false} thrown, rollback file unchanged, old rules intact', async () => {
|
||||
const fs = mockFs();
|
||||
fs.files.set('/tmp/x/rollback.nft', 'PREVIOUS-ROLLBACK-CONTENT');
|
||||
const { impl, calls } = mockExec({ applyErr: true });
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.apply(RULESET)).rejects.toMatchObject({
|
||||
name: 'FirewallApplyError',
|
||||
applied: false,
|
||||
});
|
||||
// rollback file NOT overwritten by the failed apply (old rules intact)
|
||||
expect(fs.readFileSync('/tmp/x/rollback.nft')).toBe('PREVIOUS-ROLLBACK-CONTENT');
|
||||
// temp cleaned, only the rollback file remains
|
||||
expect(fs.files.size).toBe(1);
|
||||
// verify never reached: the only `nft -f` run is the failed apply —
|
||||
// no rollback re-apply, and exactly one `list` (the pre-apply capture)
|
||||
expect(calls.filter((c) => c.args[0] === '-f')).toHaveLength(1);
|
||||
expect(calls.filter((c) => c.args[0] === 'list')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('verify mismatch → rollback attempted, verify error carries rolledBack', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec({ verifyOut: '' }); // list returns no table
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.apply(RULESET)).rejects.toMatchObject({
|
||||
name: 'FirewallVerifyError',
|
||||
code: 'verify_failed',
|
||||
rolledBack: true,
|
||||
});
|
||||
// last exec is the rollback `nft -f <rollbackFile>`
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last.args).toEqual(['-f', '/tmp/x/rollback.nft']);
|
||||
});
|
||||
|
||||
test('rollback() re-applies the previous ruleset file', async () => {
|
||||
const fs = mockFs();
|
||||
fs.files.set('/tmp/x/rollback.nft', 'table inet dcnet { OLD }');
|
||||
const { impl, calls } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
const res = await applier.rollback();
|
||||
expect(res.applied).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].args).toEqual(['-f', '/tmp/x/rollback.nft']);
|
||||
});
|
||||
|
||||
test('rollback() without a rollback file throws', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
await expect(applier.rollback()).rejects.toThrow(/no rollback file/);
|
||||
});
|
||||
|
||||
test('two applies never share a temp path', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl } = mockExec();
|
||||
const applier = createWgFirewallApplier({
|
||||
execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft',
|
||||
});
|
||||
const r1 = await applier.apply(RULESET);
|
||||
const r2 = await applier.apply(RULESET2);
|
||||
expect(r1.tempPath).not.toBe(r2.tempPath);
|
||||
expect(r1.tempPath).toMatch(/dcnet-nft-/);
|
||||
expect(r2.tempPath).toMatch(/dcnet-nft-/);
|
||||
});
|
||||
|
||||
test('apply rejects empty/non-string ruleset before any I/O', async () => {
|
||||
const fs = mockFs();
|
||||
const { impl, calls } = mockExec();
|
||||
const applier = createWgFirewallApplier({ execFileImpl: impl, fsImpl: fs });
|
||||
await expect(applier.apply('')).rejects.toThrow(/non-empty/);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('FirewallCheckError is exported and distinguishable', () => {
|
||||
const e = new FirewallCheckError('x', { stderr: 's' });
|
||||
expect(e instanceof Error).toBe(true);
|
||||
expect(e.applied).toBe(false);
|
||||
expect(new FirewallApplyError('y').applied).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user