9d266d2e4c
Completes the multi-site join layer on top of the v2.2.0 endpoints: - UI (Master Site modal): a fresh install (canJoin) gets a 'Join an Existing Site' form (master URL + stj_ key); a master gets a 'Site Join Keys' manager (mint/revoke/list, key shown once); WAN Sync Health now reflects a live probe. - POST /api/site/ping (Bearer stj_ key, no admin session): lightweight master reachability probe for WAN health (cheap vs /export). - Spoke read-only: directory-write routes (resources/edges/groups/secrets/ grants/driver-action/discovered) reject with 403 pointing at the master. - Fresh-install guard: /api/site/join refuses unless no users beyond the bootstrap admin and no enrolled agents (siteIsFresh), and site-status exposes canJoin so the UI only offers join on a genuinely fresh install. The bootstrap's seeded default resources are NOT the signal (they always exist). - The spoke stores the join key (masterJoinKey) in /config/site.json so WAN health (and a future write-proxy) can reach the master. - Tests: siteIsFresh cases in tests/site_join.test.js.
151 lines
5.8 KiB
JavaScript
151 lines
5.8 KiB
JavaScript
'use strict';
|
|
|
|
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
|
|
|
|
// In-memory model stubs so importDirectory can be exercised without a DB.
|
|
function makeStore() {
|
|
const rows = [];
|
|
const edges = [];
|
|
return {
|
|
Resource: {
|
|
list: async () => rows.map(r => ({ ...r })),
|
|
create: async (d) => { rows.push({ ...d }); return { ...d }; },
|
|
update: async (id, d) => {
|
|
const i = rows.findIndex(r => r.id === id);
|
|
if (i >= 0) rows[i] = { ...rows[i], ...d };
|
|
return rows[i];
|
|
},
|
|
get rows() { return rows; }
|
|
},
|
|
ResourceEdge: {
|
|
list: async () => edges.map(e => ({ ...e })),
|
|
create: async (d) => { edges.push({ ...d }); return { ...d }; },
|
|
delete: async (id) => {
|
|
const i = edges.findIndex(e => e.id === id);
|
|
if (i >= 0) edges.splice(i, 1);
|
|
},
|
|
get rows() { return edges; }
|
|
}
|
|
};
|
|
}
|
|
|
|
test('importDirectory creates new resources and edges', async () => {
|
|
const s = makeStore();
|
|
const exportData = {
|
|
resources: [
|
|
{ id: 'r1', kind: 'site', name: 'Main Office', slug: 'site_main-office', metadata: { address: '10.0.0.1' } },
|
|
{ id: 'r2', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: { ip: '10.0.0.10' } }
|
|
],
|
|
edges: [{ id: 'e1', parentId: 'r1', childId: 'r2', relation: 'contains' }]
|
|
};
|
|
|
|
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
|
|
|
expect(s.Resource.rows.length).toBe(2);
|
|
expect(s.ResourceEdge.rows.length).toBe(1);
|
|
expect(res.created).toBe(2);
|
|
expect(res.updated).toBe(0);
|
|
expect(res.edgeCount).toBe(1);
|
|
expect(s.Resource.rows[0].slug).toBe('site_main-office');
|
|
});
|
|
|
|
test('importDirectory updates existing resources by slug (master is authoritative)', async () => {
|
|
const s = makeStore();
|
|
await s.Resource.create({ id: 'local1', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: {} });
|
|
|
|
const exportData = {
|
|
resources: [
|
|
{ id: 'r2', kind: 'host', name: 'web01-new', slug: 'host_web-01', metadata: { ip: '10.0.0.9' } }
|
|
],
|
|
edges: []
|
|
};
|
|
|
|
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
|
|
|
expect(s.Resource.rows.length).toBe(1); // upsert, not duplicate
|
|
expect(s.Resource.rows[0].name).toBe('web01-new');
|
|
expect(res.updated).toBe(1);
|
|
});
|
|
|
|
test('importDirectory clears stale edges then recreates from master', async () => {
|
|
const s = makeStore();
|
|
await s.Resource.create({ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} });
|
|
await s.Resource.create({ id: 'r2', kind: 'host', name: 'old', slug: 'host_old', metadata: {} });
|
|
await s.ResourceEdge.create({ id: 'stale', parentId: 'r1', childId: 'r2', relation: 'contains' });
|
|
|
|
const exportData = {
|
|
resources: [
|
|
{ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} },
|
|
{ id: 'r3', kind: 'host', name: 'new', slug: 'host_new', metadata: {} }
|
|
],
|
|
edges: [{ id: 'e9', parentId: 'r1', childId: 'r3', relation: 'contains' }]
|
|
};
|
|
|
|
await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
|
|
|
const edgeIds = s.ResourceEdge.rows.map(e => e.id);
|
|
expect(edgeIds).toContain('e9');
|
|
expect(edgeIds).not.toContain('stale');
|
|
});
|
|
|
|
test('scalarResource strips relation fields but keeps metadata', () => {
|
|
const o = scalarResource({
|
|
id: 'x', kind: 'host', name: 'n', slug: 's', metadata: { a: 1 },
|
|
edgesAsParent: [1], edgesAsChild: [2], groups: [3],
|
|
toJSON() { return this; }
|
|
});
|
|
expect(o.edgesAsParent).toBeUndefined();
|
|
expect(o.edgesAsChild).toBeUndefined();
|
|
expect(o.groups).toBeUndefined();
|
|
expect(o.metadata.a).toBe(1);
|
|
});
|
|
|
|
test('scalarEdge keeps parentId/childId/relation', () => {
|
|
const e = scalarEdge({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains', toJSON() { return this; } });
|
|
expect(e).toEqual({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains' });
|
|
});
|
|
|
|
test('ldapAddArgs builds a continue-on-error admin bind', () => {
|
|
const a = ldapAddArgs({ bindDN: 'cn=admin,dc=example,dc=com', ldapCred: 'test-bind', ldifFile: '/tmp/x.ldif' });
|
|
expect(a).toContain('-c');
|
|
expect(a).toContain('-x');
|
|
expect(a).toContain('cn=admin,dc=example,dc=com');
|
|
expect(a).toContain('/tmp/x.ldif');
|
|
expect(a.indexOf('-D') < a.indexOf('-w')).toBe(true);
|
|
});
|
|
|
|
test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () => {
|
|
expect(baseDnFrom({ stack: { ldapBaseDn: 'dc=stack,dc=com' } })).toBe('dc=stack,dc=com');
|
|
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com');
|
|
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe('');
|
|
});
|
|
|
|
// The fresh-install guard: only no-users-beyond-admin + no-agents may join.
|
|
test('siteIsFresh is true with only the bootstrap admin and no agents', async () => {
|
|
const User = { listDetail: async () => [{ uid: 'admin', isServiceAccount: false }] };
|
|
const Agent = { list: async () => [] };
|
|
expect(await siteIsFresh({ User, Agent })).toBe(true);
|
|
});
|
|
|
|
test('siteIsFresh is false with a second real user', async () => {
|
|
const User = { listDetail: async () => [{ uid: 'admin' }, { uid: 'bob' }] };
|
|
const Agent = { list: async () => [] };
|
|
expect(await siteIsFresh({ User, Agent })).toBe(false);
|
|
});
|
|
|
|
test('siteIsFresh is false with an enrolled agent', async () => {
|
|
const User = { listDetail: async () => [{ uid: 'admin' }] };
|
|
const Agent = { list: async () => [{ id: 'a1' }] };
|
|
expect(await siteIsFresh({ User, Agent })).toBe(false);
|
|
});
|
|
|
|
test('siteIsFresh ignores service accounts', async () => {
|
|
const User = { listDetail: async () => [
|
|
{ uid: 'admin' },
|
|
{ uid: 'sso-svc', isServiceAccount: true },
|
|
{ uid: 'ldapclient', isServiceAccount: true }
|
|
] };
|
|
const Agent = { list: async () => [] };
|
|
expect(await siteIsFresh({ User, Agent })).toBe(true);
|
|
});
|