DC-148: Tailscale ACL compiler + lockout guard + preview/push/rollback + drift detection + tests
This commit is contained in:
4 files changed
+1065
No files matched your search
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user