feat(site): join UI, spoke read-only enforcement, live WAN health, fresh-install guard
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
|
||||
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() {
|
||||
@@ -119,3 +119,32 @@ test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () =>
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user