322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
/**
|
|
* Tests for src/network/acl-push.js — DC-148.
|
|
*
|
|
* Hermetic: no network. The coord client is stubbed with the same
|
|
* makeFetch rigor as __tests__/tailscale-coord.test.js — a route map
|
|
* keyed by 'METHOD path' returning { status, body }, defaulting to 404.
|
|
* dataDir points at a fresh tmp dir per test so the last-known-good
|
|
* state file never leaks between tests.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
/* eslint-disable require-await */
|
|
// Helper stubs are async to match real client signatures without awaiting.
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { createAclPusher, AclPushError } = require('../src/network/acl-push');
|
|
const { hashAcls } = require('../src/network/acl-compiler');
|
|
|
|
const POLICY = {
|
|
acls: [
|
|
{ action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:443'], proto: ['tcp', 'udp'] },
|
|
{ action: 'accept', src: ['dev-host-1'], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
|
],
|
|
ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }],
|
|
tagOwners: { 'tag:dc-host': ['autogroup:admin'] },
|
|
};
|
|
const OTHER_POLICY = {
|
|
acls: [{ action: 'accept', src: ['9999'], dst: ['x:*'], proto: ['tcp', 'udp'] }],
|
|
};
|
|
|
|
/** Route-map fetch stub (tailscale-coord.test.js rigor). */
|
|
function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) {
|
|
const calls = [];
|
|
const fn = jest.fn(async (method, apiPath, opts) => {
|
|
calls.push({ method, path: apiPath, opts });
|
|
const match = routes[method + ' ' + apiPath];
|
|
if (match) {
|
|
return {
|
|
status: match.status,
|
|
body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body),
|
|
headers: { 'content-type': 'application/json' },
|
|
};
|
|
}
|
|
return { status: defaultStatus, body: JSON.stringify(defaultBody) };
|
|
});
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
/** Coord-client-shaped stub for the coord injection path. */
|
|
function makeCoord(routes) {
|
|
const fetchImpl = makeFetch(routes);
|
|
return {
|
|
fetchImpl,
|
|
request: async (method, apiPath, opts) => {
|
|
const res = await fetchImpl(method, '/api/v2' + apiPath, opts);
|
|
if (res.status >= 400) {
|
|
throw Object.assign(new Error(`coord ${res.status}`), { code: res.status === 400 ? 'bad_input' : 'http_error', status: res.status });
|
|
}
|
|
try { return JSON.parse(res.body); } catch (_) { return res.body; }
|
|
},
|
|
};
|
|
}
|
|
|
|
function tmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dc-acl-push-'));
|
|
}
|
|
|
|
function readState(dir) {
|
|
return JSON.parse(fs.readFileSync(path.join(dir, 'network-acl-state.json'), 'utf8'));
|
|
}
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
// ── previewPolicy ─────────────────────────────────────────────────────────
|
|
|
|
describe('acl-push: previewPolicy', () => {
|
|
test('posts the full policy to /acl/preview and returns the body', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [], warnings: [] } },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
|
const out = await pusher.previewPolicy(POLICY);
|
|
expect(out.feasible).toBe(true);
|
|
expect(fetchImpl.calls).toHaveLength(1);
|
|
const { method, path: p, opts } = fetchImpl.calls[0];
|
|
expect(method).toBe('POST');
|
|
expect(p).toBe('/api/v2/tailnet/-/acl/preview');
|
|
expect(opts.body).toEqual(POLICY); // COMPLETE policy object, never partial
|
|
});
|
|
|
|
test('preview errors[] → AclPushError acl_rejected (refuse to push)', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl/preview': {
|
|
status: 200,
|
|
body: { feasible: false, errors: [{ text: 'port 99999 invalid' }] },
|
|
},
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
|
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
|
name: 'AclPushError', code: 'acl_rejected',
|
|
});
|
|
});
|
|
|
|
test('preview HTTP 400 → AclPushError (mapped)', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 400, body: { message: 'bad acl' } },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
|
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
|
name: 'AclPushError', code: 'acl_rejected',
|
|
});
|
|
});
|
|
|
|
test('fetch throw → AclPushError coord_error (network failure mapped)', async () => {
|
|
const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() });
|
|
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({
|
|
name: 'AclPushError', code: 'coord_error',
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── pushPolicy ────────────────────────────────────────────────────────────
|
|
|
|
describe('acl-push: pushPolicy', () => {
|
|
test('posts the complete policy and stores last-known-good body + hash', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
const out = await pusher.pushPolicy(POLICY);
|
|
expect(out.pushed).toBe(true);
|
|
expect(out.hash).toBe(hashAcls(POLICY.acls));
|
|
// request shape: complete object
|
|
expect(fetchImpl.calls[0].opts.body).toEqual(POLICY);
|
|
expect(fetchImpl.calls[0].opts.body.ssh).toBeDefined();
|
|
expect(fetchImpl.calls[0].opts.body.tagOwners).toBeDefined();
|
|
// state file persisted atomically with policy + hash
|
|
const state = readState(dir);
|
|
expect(state.policy).toEqual(POLICY);
|
|
expect(state.hash).toBe(hashAcls(POLICY.acls));
|
|
expect(state.pushedAt).toBeTruthy();
|
|
expect(pusher.getLastKnownGood().hash).toBe(hashAcls(POLICY.acls));
|
|
});
|
|
|
|
test('failed push (500) stores nothing', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl': { status: 500, body: { message: 'boom' } },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
await expect(pusher.pushPolicy(POLICY)).rejects.toMatchObject({ code: 'http_500' });
|
|
expect(pusher.getLastKnownGood()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── rollback ──────────────────────────────────────────────────────────────
|
|
|
|
describe('acl-push: rollback', () => {
|
|
test('re-pushes the stored last-known-good policy', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
await pusher.pushPolicy(POLICY);
|
|
fetchImpl.calls.length = 0;
|
|
const out = await pusher.rollback();
|
|
expect(out.rolledBack).toBe(true);
|
|
expect(out.hash).toBe(hashAcls(POLICY.acls));
|
|
// the rollback push carries the ORIGINAL stored body verbatim
|
|
expect(fetchImpl.calls[0].opts.body).toEqual(POLICY);
|
|
});
|
|
|
|
test('rollback without a stored policy throws no_last_known_good', async () => {
|
|
const pusher = createAclPusher({ fetchImpl: makeFetch({}), dataDir: tmpDir() });
|
|
await expect(pusher.rollback()).rejects.toMatchObject({ code: 'no_last_known_good' });
|
|
});
|
|
});
|
|
|
|
// ── pushIfNoDrift ─────────────────────────────────────────────────────────
|
|
|
|
describe('acl-push: pushIfNoDrift', () => {
|
|
test('no drift → previews then pushes and updates state', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'GET /api/v2/tailnet/-/acl': { status: 200, body: POLICY },
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
await pusher.pushPolicy(POLICY); // seed stored hash
|
|
fetchImpl.calls.length = 0;
|
|
|
|
const out = await pusher.pushIfNoDrift(POLICY);
|
|
expect(out).toEqual({ pushed: true, hash: hashAcls(POLICY.acls) });
|
|
const paths = fetchImpl.calls.map(c => c.method + ' ' + c.path);
|
|
expect(paths).toEqual([
|
|
'GET /api/v2/tailnet/-/acl',
|
|
'POST /api/v2/tailnet/-/acl/preview',
|
|
'POST /api/v2/tailnet/-/acl',
|
|
]);
|
|
});
|
|
|
|
test('drift (remote acls changed outside DashCaddy) → {drifted:true, pushed:false}, NO push', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
'GET /api/v2/tailnet/-/acl': { status: 200, body: OTHER_POLICY }, // outside edit
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
await pusher.pushPolicy(POLICY);
|
|
fetchImpl.calls.length = 0;
|
|
|
|
const out = await pusher.pushIfNoDrift(POLICY);
|
|
expect(out).toEqual({ drifted: true, pushed: false });
|
|
// only the GET happened — never preview, never a silent overwrite push
|
|
expect(fetchImpl.calls.map(c => c.method)).toEqual(['GET']);
|
|
});
|
|
|
|
test('baseline-only remote change is NOT drift (ssh edited outside)', async () => {
|
|
const dir = tmpDir();
|
|
const editedBaseline = { ...POLICY, ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] };
|
|
const fetchImpl = makeFetch({
|
|
'GET /api/v2/tailnet/-/acl': { status: 200, body: editedBaseline },
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
await pusher.pushPolicy(POLICY);
|
|
fetchImpl.calls.length = 0;
|
|
|
|
const out = await pusher.pushIfNoDrift(POLICY);
|
|
expect(out.pushed).toBe(true);
|
|
});
|
|
|
|
test('first-ever push (no stored state) skips the drift check', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
const out = await pusher.pushIfNoDrift(POLICY);
|
|
expect(out.pushed).toBe(true);
|
|
expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST', 'POST']); // no GET
|
|
});
|
|
|
|
test('preview failure during pushIfNoDrift refuses the push (throws)', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: false, errors: [{ text: 'nope' }] } },
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ code: 'acl_rejected' });
|
|
expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST']); // preview only, no push
|
|
});
|
|
|
|
test('remote read failure during drift check maps to AclPushError', async () => {
|
|
const dir = tmpDir();
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' },
|
|
'GET /api/v2/tailnet/-/acl': { status: 500, body: { message: 'ctrl down' } },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: dir });
|
|
await pusher.pushPolicy(POLICY);
|
|
fetchImpl.calls.length = 0;
|
|
await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ name: 'AclPushError' });
|
|
});
|
|
});
|
|
|
|
// ── coord injection + misc ────────────────────────────────────────────────
|
|
|
|
describe('acl-push: injectable coord client + config', () => {
|
|
test('coord.request is used instead of fetchImpl when both given', async () => {
|
|
const coord = makeCoord({
|
|
'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
|
});
|
|
const fetchImpl = jest.fn();
|
|
const pusher = createAclPusher({ coord: { request: coord.request }, fetchImpl, dataDir: tmpDir() });
|
|
const out = await pusher.previewPolicy(POLICY);
|
|
expect(out.feasible).toBe(true);
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('coord error (TailscaleCoordError-like) is mapped with its code', async () => {
|
|
const coordErr = Object.assign(new Error('unauthorized'), { code: 'unauthorized', status: 401 });
|
|
const coord = { request: async () => { throw coordErr; } };
|
|
const pusher = createAclPusher({ coord, dataDir: tmpDir() });
|
|
await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({ code: 'unauthorized' });
|
|
});
|
|
|
|
test('no coord and no fetchImpl → TypeError', () => {
|
|
expect(() => createAclPusher({ dataDir: tmpDir() })).toThrow(TypeError);
|
|
});
|
|
|
|
test('custom tailnet name is used in URLs', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'POST /api/v2/tailnet/example.ts.net/acl/preview': { status: 200, body: { feasible: true, errors: [] } },
|
|
});
|
|
const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir(), tailnet: 'example.ts.net' });
|
|
await pusher.previewPolicy(POLICY);
|
|
expect(fetchImpl.calls[0].path).toBe('/api/v2/tailnet/example.ts.net/acl/preview');
|
|
});
|
|
|
|
test('AclPushError is exported and operational', () => {
|
|
const e = new AclPushError('x', { code: 'test' });
|
|
expect(e.name).toBe('AclPushError');
|
|
expect(e.code).toBe('test');
|
|
expect(e.isOperational).toBe(true);
|
|
});
|
|
});
|