/** * 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); }); });