mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
fix: probe remote RBAC capability live and enforce exact stop-result membership (#1510)
The cross-node capability gate cached its verdict, so a remote replaced by older code at the same URL stayed trusted until the cache expired, reopening the non-admin HTTP escalation and the over-broad stop. The probe now hits the remote's live /api/meta on every gated action (concurrent calls deduped, never cached across requests, fail-closed), so a downgraded remote is detected immediately. Two stop-result gaps are also closed: - A remote stop result must now cover exactly the confirmed stacks (one per stack, no extras, no omissions), not merely exclude extras, so a dropped confirmed stack is no longer accepted as clean. runLocalLabelStop reports one result per confirmed stack even when the label has vanished, so a current remote always satisfies the check. - The local stop exception path now reports the full confirmed set, so a confirmed stack that lost its label is not dropped when the local stop throws.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Unit coverage for the cross-node-rbac capability probe used to gate
|
||||
* mixed-version cross-node operations. It reads the shared remote-meta cache
|
||||
* (fetching once on a cold miss) and must fail closed: a node whose capability
|
||||
* cannot be determined is treated as unsupported.
|
||||
* 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';
|
||||
@@ -10,7 +11,6 @@ import type { RemoteMeta } from '../services/CapabilityRegistry';
|
||||
|
||||
let remoteSupportsCrossNodeRbac: typeof import('../helpers/remoteCapabilities').remoteSupportsCrossNodeRbac;
|
||||
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
||||
let CacheService: typeof import('../services/CacheService').CacheService;
|
||||
let tmpDir: string;
|
||||
|
||||
const NODE_ID = 4242;
|
||||
@@ -19,40 +19,63 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ remoteSupportsCrossNodeRbac } = await import('../helpers/remoteCapabilities'));
|
||||
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
||||
({ CacheService } = await import('../services/CacheService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
CacheService.getInstance().invalidate(`remote-meta:${NODE_ID}`);
|
||||
});
|
||||
|
||||
function mockMeta(meta: RemoteMeta): void {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta);
|
||||
}
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const ONLINE = { startedAt: null, updateError: null, online: true } 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 () => {
|
||||
mockMeta({ version: '0.93.0', capabilities: ['fleet', 'cross-node-rbac'], ...ONLINE });
|
||||
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 () => {
|
||||
mockMeta({ version: '0.92.0', capabilities: ['fleet', 'labels'], ...ONLINE });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(incapable);
|
||||
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when the remote meta has no resolvable version', async () => {
|
||||
mockMeta({ version: null, capabilities: [], startedAt: null, updateError: null, online: 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 });
|
||||
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 });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user