Files
dashcaddy/dashcaddy-api/__tests__/network-syncaccess.test.js
T

133 lines
6.1 KiB
JavaScript

/**
* DC-153 syncAccess contract suite — exercises the src/network/acl-sync.js
* adapter (the injectable Tailscale enforcement engine) directly, plus the
* cross-provider guarantees the judge gate asked for: shapes pass through,
* error classes map to ProviderError codes, success shapes hold, and the
* DEFAULT construction paths run the real DC-148/DC-151 engines against
* injected coord/exec mocks.
*
* Provider-level behavior tests live in network-provider-{tailscale,wireguard}.test.js.
*/
'use strict';
const { createAclSync } = require('../src/network/acl-sync');
const { compileAndValidate, LockoutError } = require('../src/network/acl-compiler');
const { AclPushError } = require('../src/network/acl-push');
function tsInput() {
return {
accessMap: { grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'app', allowed: true }] },
people: [{ id: 'p1', providerUserId: '1001', groups: ['Admin'], status: 'active' }],
resources: [{ name: 'app', host: 'host.ts.net', ports: ['*'] }],
hostDeviceId: 'dev-host',
adminUserIds: ['1001'],
hostResource: { host: 'host.ts.net', ports: ['*'] },
};
}
describe('acl-sync adapter (createAclSync)', () => {
test('compile: runs the real compileAndValidate, returns policy + hash + usersAffected', async () => {
const sync = createAclSync({ pusher: {} }); // compile path needs no pusher
const out = await sync.compile(tsInput());
expect(out.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
expect(out.usersAffected).toBe(1); // 1001 only; host device excluded
// same policy the raw engine produces
expect(out.policy).toEqual(compileAndValidate(tsInput()).policy);
});
test('compile: LockoutError propagates untouched (no admin ids)', async () => {
const sync = createAclSync({ pusher: {} });
const bad = { ...tsInput(), adminUserIds: [] };
await expect(sync.compile(bad)).rejects.toBeInstanceOf(LockoutError);
});
test('preview delegates to the pusher; push goes through the drift guard; getState to getLastKnownGood', async () => {
const pusher = {
previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })),
pushIfNoDrift: jest.fn(async (policy) => ({ drifted: false, ...(await pusher.pushPolicy(policy)) })),
pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'sha256:x' })),
getLastKnownGood: jest.fn(() => ({ hash: 'sha256:x', policy: { acls: [] } })),
};
const sync = createAclSync({ pusher });
await expect(sync.preview({ acls: [] })).resolves.toEqual({ feasible: true, errors: [] });
await expect(sync.push({ acls: [] })).resolves.toEqual({ drifted: false, pushed: true, hash: 'sha256:x' });
expect(sync.getState()).toMatchObject({ hash: 'sha256:x' });
expect(pusher.previewPolicy).toHaveBeenCalledWith({ acls: [] });
// push now flows through the drift guard, not bare pushPolicy
expect(pusher.pushIfNoDrift).toHaveBeenCalledWith({ acls: [] });
expect(pusher.pushPolicy).toHaveBeenCalledWith({ acls: [] });
});
test('built from a coordClient: preview POSTs to /tailnet/{t}/acl/preview', async () => {
const coord = {
request: jest.fn(async (m, p) => {
if (p === '/tailnet/-/acl/preview') return { feasible: true, errors: [] };
return {};
}),
};
const sync = createAclSync({ coordClient: coord, tailnet: '-' });
await sync.preview({ acls: [] });
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/acl/preview', {
body: { acls: [] },
});
});
test('pusher errors[] surface as AclPushError (caller must refuse to push)', async () => {
const coord = {
request: jest.fn(async () => ({ feasible: false, errors: ['dst unreachable'] })),
};
const sync = createAclSync({ coordClient: coord });
await expect(sync.preview({ acls: [] })).rejects.toBeInstanceOf(AclPushError);
});
});
// ── Drift-guard regression (judge r2 blocker 1) ──────────────────────────
describe('acl-sync drift guard (pushIfNoDrift wiring)', () => {
test('push: drifted remote policy → acl_drift_detected, pushPolicy NEVER called', async () => {
const policy = { acls: [] };
const pusher = {
previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })),
pushIfNoDrift: jest.fn(async () => ({ drifted: true, pushed: false })),
pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'h' })),
getLastKnownGood: jest.fn(() => null),
};
const sync = createAclSync({ pusher });
await expect(sync.push(policy)).rejects.toMatchObject({
code: 'acl_drift_detected',
});
expect(pusher.pushIfNoDrift).toHaveBeenCalledTimes(1);
expect(pusher.pushPolicy).not.toHaveBeenCalled();
});
test('push: no drift → delegates to success shape', async () => {
const pusher = {
pushIfNoDrift: jest.fn(async () => ({ drifted: false, pushed: true, hash: 'abc' })),
pushPolicy: jest.fn(),
getLastKnownGood: jest.fn(() => null),
};
const sync = createAclSync({ pusher });
const result = await sync.push({ acls: [] });
expect(result).toEqual({ drifted: false, pushed: true, hash: 'abc' });
});
test('provider: tailscale syncAccess surfaces drifted remote as ProviderError acl_drift_detected (nothing pushed)', async () => {
const { createTailscaleProvider } = require('../src/network/providers/tailscale');
const { ProviderError } = require('../src/network/errors');
const engine = {
compile: jest.fn(async () => ({ policy: { acls: [] }, hash: 'h', usersAffected: 1 })),
preview: jest.fn(async () => ({ feasible: true, errors: [] })),
push: jest.fn(async () => { const e = new Error('drift'); e.code = 'acl_drift_detected'; e.drift = { drifted: true }; throw e; }),
getState: jest.fn(() => null),
};
const provider = createTailscaleProvider({ coordClient: { request: jest.fn() }, aclSync: engine });
await expect(provider.syncAccess(tsInput())).rejects.toMatchObject({
code: 'acl_drift_detected',
retryable: false,
});
await expect(provider.syncAccess(tsInput())).rejects.toBeInstanceOf(ProviderError);
expect(engine.push).toHaveBeenCalledTimes(2);
});
});