/** * Unit coverage for the cross-node-rbac capability probe used to gate * mixed-version cross-node operations. It probes the remote's live /api/meta on * every call (no cross-request caching), must fail closed when the capability * cannot be determined, and must re-verify each time so a downgraded remote is * detected immediately rather than trusted from a stale verdict. */ import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import type { RemoteMeta } from '../services/CapabilityRegistry'; let remoteSupportsCrossNodeRbac: typeof import('../helpers/remoteCapabilities').remoteSupportsCrossNodeRbac; let remoteAdvertisesCapability: typeof import('../helpers/remoteCapabilities').remoteAdvertisesCapability; let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; let tmpDir: string; const NODE_ID = 4242; beforeAll(async () => { tmpDir = await setupTestDb(); ({ remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } = await import('../helpers/remoteCapabilities')); ({ NodeRegistry } = await import('../services/NodeRegistry')); }); afterAll(() => cleanupTestDb(tmpDir)); afterEach(() => vi.restoreAllMocks()); const ONLINE = { startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null } as const; const capable: RemoteMeta = { version: '0.93.0', capabilities: ['fleet', 'cross-node-rbac'], ...ONLINE }; const incapable: RemoteMeta = { version: '0.92.0', capabilities: ['fleet', 'labels'], ...ONLINE }; describe('remoteSupportsCrossNodeRbac', () => { it('returns true when the remote advertises cross-node-rbac', async () => { vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(capable); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true); }); it('returns false when the remote does not advertise it', async () => { vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(incapable); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false); }); it('fails closed when the remote is offline (empty capabilities)', async () => { vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') .mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false, imageChannel: null }); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false); }); it('trusts a reachable remote that advertises the capability even with a non-semver version', async () => { // A 0.0.0-dev image reports version null (non-semver) but is reachable and // genuinely advertises the capability; it must not be wrongly denied. vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') .mockResolvedValue({ version: null, capabilities: ['fleet', 'cross-node-rbac'], startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null }); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true); }); it('fails closed when the meta fetch throws (unreachable)', async () => { vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockRejectedValue(new Error('unreachable')); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false); }); it('re-probes on every call so a downgraded remote is detected immediately (no stale verdict)', async () => { const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') .mockResolvedValueOnce(capable) // first probe: current remote .mockResolvedValue(incapable); // after the remote is swapped for older code expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true); expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false); expect(spy).toHaveBeenCalledTimes(2); }); it('dedupes concurrent probes for the same node into a single fetch', async () => { const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(capable); const [a, b] = await Promise.all([ remoteSupportsCrossNodeRbac(NODE_ID), remoteSupportsCrossNodeRbac(NODE_ID), ]); expect(a).toBe(true); expect(b).toBe(true); expect(spy).toHaveBeenCalledTimes(1); }); }); describe('remoteAdvertisesCapability', () => { it('returns true when the remote advertises the requested capability', async () => { vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') .mockResolvedValue({ version: '0.93.0', capabilities: ['stack-down-remove-volumes'], ...ONLINE }); expect(await remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes')).toBe(true); }); it('dedupes concurrent probes per node+capability, not per node alone', async () => { const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') .mockResolvedValue({ version: '0.93.0', capabilities: ['cross-node-rbac', 'stack-down-remove-volumes'], ...ONLINE }); const [rbac, volumes] = await Promise.all([ remoteAdvertisesCapability(NODE_ID, 'cross-node-rbac'), remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes'), ]); expect(rbac).toBe(true); expect(volumes).toBe(true); expect(spy).toHaveBeenCalledTimes(2); }); });