feat: add Admiral Hardened Build channel and business assurance surfaces (#1629)

* feat: add Admiral Hardened Build channel and business assurance surfaces

Introduce Studio Saelix entitlement-backed Hardened Build switching, a
single-flight image operation coordinator, Recovery Vault naming, Admiral
Account settings, and typed Fleet update failures while preserving Community
custom-repo and targetless pull-current updates.

* fix: harden image-op paths and clear CI CodeQL/pilot flake

Validate operation IDs before filesystem use, use hostname checks in Fleet
fetch mocks, sanitize registry probe logs, and swallow expected TCP teardown
errors in the pilot reverse-route post-handshake test.

* fix: sanitize image-op docker config write and probe logs

Allowlist-copy registry host keys and base64 auth before writing the
temp DOCKER_CONFIG, and log registry probe failures with a fixed message
so CodeQL no longer flags network-to-file and log-injection mediums.

* fix: address Admiral Hardened Build audit blockers

Expose imageChannel so hardened Fleet peers still POST for typed rejection, claim community updates before 202, terminalize helper failures, gate Hardened on paid, and align support/docs/e2e wording.

* fix: terminalize image ops on helper survival and aborted claims

* fix: prevent recreating persist from overwriting helper-exit failure

* test: assert helper-exit failure lands before recreating persist

* fix: keep current pointer when acknowledging a stale image operation
This commit is contained in:
Anso
2026-07-14 10:47:54 -04:00
committed by GitHub
parent 8ca8ebaa24
commit 381ed2a91f
54 changed files with 2302 additions and 125 deletions
@@ -62,7 +62,7 @@ describe('fetchRemoteMeta Authorization header', () => {
updateError: null,
online: false,
imagePinKind: null,
updateBlocked: false,
updateBlocked: false, imageChannel: null,
});
});
});
@@ -234,10 +234,10 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => {
updateError: null,
online: true,
imagePinKind: null,
updateBlocked: false,
updateBlocked: false, imageChannel: null,
};
}
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false };
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false, imageChannel: null };
});
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
@@ -257,7 +257,7 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => {
updateError: null,
online: false,
imagePinKind: null,
updateBlocked: false,
updateBlocked: false, imageChannel: null,
});
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
@@ -37,7 +37,7 @@ const META_ONLINE_OUTDATED: RemoteMeta = {
updateError: null,
online: true,
imagePinKind: null,
updateBlocked: false,
updateBlocked: false, imageChannel: null,
};
const META_OFFLINE: RemoteMeta = {
@@ -47,7 +47,7 @@ const META_OFFLINE: RemoteMeta = {
updateError: null,
online: false,
imagePinKind: null,
updateBlocked: false,
updateBlocked: false, imageChannel: null,
};
const META_NO_SELF_UPDATE: RemoteMeta = {
@@ -255,7 +255,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () =>
mockTargetForPilot();
mockCompareTargetFetch();
// Remote now reports a different version than before the update (signal 1).
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null });
const tracker = FleetUpdateTrackerService.getInstance();
tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null));
@@ -272,7 +272,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () =>
it('does not drop the cache on a steady-state completed poll (transition guard)', async () => {
mockTargetForPilot();
mockCompareTargetFetch();
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null });
// Already completed before this poll: no transition, so no invalidation.
const tracker = FleetUpdateTrackerService.getInstance();
@@ -41,6 +41,7 @@ const ONLINE = (over: Partial<RemoteMeta> = {}): RemoteMeta => ({
online: true,
imagePinKind: null,
updateBlocked: false,
imageChannel: null,
...over,
});
@@ -217,6 +218,108 @@ describe('POST /api/fleet/nodes/:id/update concurrency', () => {
expect(res.status).toBe(409);
expect(res.body?.error).toMatch(/already in progress/i);
});
it('preserves a typed remote update failure in the response and tracker', async () => {
mockTarget();
mockMeta(ONLINE());
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
try {
if (new URL(String(input)).hostname === 'api.github.com') {
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
}
} catch {
// Non-URL fetch inputs fall through to the remote-update mock.
}
return new Response(JSON.stringify({
error: 'Hardened Build updates require a signed-in admin on that node.',
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
}), { status: 403 });
});
const res = await request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/update`)
.set('Authorization', adminAuth);
expect(res.status).toBe(502);
expect(res.body).toEqual({
error: 'Hardened Build updates require a signed-in admin on that node.',
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
});
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.code)
.toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED');
});
});
describe('POST /api/fleet/nodes/:id/update hardened digest pin', () => {
it('still POSTs when updateBlocked and imageChannel is hardened', async () => {
mockTarget();
mockMeta(ONLINE({ updateBlocked: true, imageChannel: 'hardened', imagePinKind: 'digest' }));
let remoteUpdateCalled = false;
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
try {
if (new URL(String(input)).hostname === 'api.github.com') {
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
}
} catch {
// Non-URL fetch inputs fall through to the remote-update mock.
}
remoteUpdateCalled = true;
return new Response(JSON.stringify({
error: 'Hardened Build updates require a signed-in admin on that node.',
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
}), { status: 403 });
});
const res = await request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/update`)
.set('Authorization', adminAuth);
expect(remoteUpdateCalled).toBe(true);
expect(res.status).toBe(502);
expect(res.body).toEqual({
error: 'Hardened Build updates require a signed-in admin on that node.',
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
});
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.code)
.toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED');
});
});
describe('POST /api/fleet/update-all typed failures', () => {
it('reports remote rejections as failed instead of skipped', async () => {
mockTarget();
mockMeta(ONLINE());
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
try {
if (new URL(String(input)).hostname === 'api.github.com') {
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
}
} catch {
// Non-URL fetch inputs fall through to the remote-update mock.
}
return new Response(JSON.stringify({
error: 'Hardened Build updates require a signed-in admin on that node.',
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
}), { status: 403 });
});
const res = await request(app)
.post('/api/fleet/update-all')
.set('Authorization', adminAuth);
expect(res.status).toBe(202);
expect(res.body).toEqual({
updating: [],
skipped: [],
failed: [{
nodeId: proxyNodeId,
name: 'proxy-hardening-test',
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
error: 'Hardened Build updates require a signed-in admin on that node.',
}],
});
});
});
describe('clear-route authorization', () => {
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockGetSystemState, mockPost, mockGetTier } = vi.hoisted(() => ({
mockGetSystemState: vi.fn(),
mockPost: vi.fn(),
mockGetTier: vi.fn((): 'paid' | 'community' => 'paid'),
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getSystemState: mockGetSystemState,
}),
},
}));
vi.mock('../services/LicenseService', () => ({
LicenseService: {
getInstance: () => ({
getTier: mockGetTier,
}),
},
}));
vi.mock('axios', () => ({
default: {
post: mockPost,
isAxiosError: vi.fn(),
},
}));
import { validateAllowedImageRefAgainstRequirement } from '../helpers/allowedImageRef';
import { HardenedEntitlementService } from '../services/HardenedEntitlementService';
const registryRequirement = {
registry_host: 'ghcr.io',
package_scope: 'studio-saelix/sencho-hardened',
credential_instructions: 'Create a pull token.',
supports_pull_token: true,
};
const entitlementFixture = {
hardened_build_access: true,
channel: 'hardened',
allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
pin_recommendation: 'Use the supplied digest.',
registry_requirement: registryRequirement,
checked_at: '2026-07-13T12:00:00.000Z',
};
beforeEach(() => {
vi.clearAllMocks();
HardenedEntitlementService.getInstance().invalidateCache();
mockGetTier.mockReturnValue('paid');
mockGetSystemState.mockImplementation((key: string) => ({
license_key: 'license-for-test',
instance_id: 'instance-for-test',
})[key] ?? '');
delete process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB;
});
describe('allowed Hardened image references', () => {
it('rejects a bare digest', () => {
expect(validateAllowedImageRefAgainstRequirement(
'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
registryRequirement,
)).toBe(false);
});
it('rejects an image outside the entitled package scope', () => {
expect(validateAllowedImageRefAgainstRequirement(
'ghcr.io/studio-saelix/other@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
registryRequirement,
)).toBe(false);
});
});
describe('HardenedEntitlementService', () => {
it('rejects entitlement checks when the tier is not paid', async () => {
mockGetTier.mockReturnValue('community');
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled';
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
.resolves.toEqual({ success: false, code: 'unauthorized' });
});
it('ignores the entitlement stub in production', async () => {
const previous = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled';
mockPost.mockResolvedValue({ status: 200, data: entitlementFixture });
try {
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
.resolves.toMatchObject({ success: true, entitlement: entitlementFixture });
expect(mockPost).toHaveBeenCalledTimes(1);
} finally {
process.env.NODE_ENV = previous;
}
});
it('returns the entitled stub response', async () => {
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled';
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
.resolves.toMatchObject({
success: true,
entitlement: {
...entitlementFixture,
checked_at: expect.any(String),
},
});
});
it('returns the unauthorized stub response', async () => {
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'unauthorized';
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
.resolves.toEqual({ success: false, code: 'unauthorized' });
});
it('invalidates a cached status entitlement', async () => {
mockPost.mockResolvedValue({ status: 200, data: entitlementFixture });
const service = HardenedEntitlementService.getInstance();
await service.getEntitlement('status');
await service.getEntitlement('status');
expect(mockPost).toHaveBeenCalledTimes(1);
service.invalidateCache();
await service.getEntitlement('status');
expect(mockPost).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { classifyImageChannel, normalizeImageRepository } from '../helpers/imageChannel';
describe('image channel classification', () => {
it.each([
'saelix/sencho:latest',
'docker.io/saelix/sencho:latest',
'index.docker.io/saelix/sencho@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'ghcr.io/studio-saelix/sencho:v1.2.3',
'ghcr.io/studio-saelix/sencho-dev:dev',
])('classifies the canonical Community alias %s', (imageRef) => {
expect(classifyImageChannel(imageRef)).toBe('community');
});
it('classifies the exact Hardened repository', () => {
expect(classifyImageChannel('ghcr.io/studio-saelix/sencho-hardened:v1.2.3')).toBe('hardened');
});
it('keeps custom and similarly named repositories unknown', () => {
expect(classifyImageChannel('registry.example.com:5000/sencho:1.0.0')).toBe('unknown');
expect(classifyImageChannel('ghcr.io/studio-saelix/sencho-hardened-mirror:1.0.0')).toBe('unknown');
});
it('normalizes Docker Hub aliases without changing registries that use ports', () => {
expect(normalizeImageRepository('docker.io/saelix/sencho:latest')).toBe('saelix/sencho');
expect(normalizeImageRepository('index.docker.io/saelix/sencho:latest')).toBe('saelix/sencho');
expect(normalizeImageRepository('registry.example.com:5000/saelix/sencho:latest'))
.toBe('registry.example.com:5000/saelix/sencho');
});
});
@@ -0,0 +1,273 @@
import fs from 'fs/promises';
import path from 'path';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { cleanupTestDb, setupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let ImageOperationService: typeof import('../services/ImageOperationService').ImageOperationService;
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
let HardenedEntitlementService: typeof import('../services/HardenedEntitlementService').HardenedEntitlementService;
let RegistryService: typeof import('../services/RegistryService').RegistryService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ ImageOperationService } = await import('../services/ImageOperationService'));
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
({ HardenedEntitlementService } = await import('../services/HardenedEntitlementService'));
({ RegistryService } = await import('../services/RegistryService'));
});
afterEach(async () => {
vi.restoreAllMocks();
const selfUpdate = SelfUpdateService.getInstance() as unknown as {
pendingHelperExitError: string | undefined;
helperExitListeners: Array<(error: string | null) => void>;
};
selfUpdate.pendingHelperExitError = undefined;
selfUpdate.helperExitListeners = [];
await fs.rm(path.join(tmpDir, 'image-operation-current.json'), { force: true });
await fs.rm(path.join(tmpDir, 'image-operations'), { recursive: true, force: true });
});
afterAll(() => cleanupTestDb(tmpDir));
describe('ImageOperationService', () => {
it('rejects a second update while the first operation is claimed', async () => {
let releaseUpdate: (() => void) | undefined;
let markTriggered: (() => void) | undefined;
const triggered = new Promise<void>(resolve => { markTriggered = resolve; });
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => {
markTriggered!();
await new Promise<void>(resolve => { releaseUpdate = resolve; });
});
const service = ImageOperationService.getInstance();
const first = service.runCommunityUpdate();
await triggered;
const second = await service.runCommunityUpdate();
expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
releaseUpdate!();
await expect(first).resolves.toEqual({ ok: true });
});
it('claims synchronously before checking existing operations', async () => {
let releaseLookup: (() => void) | undefined;
let signalLookupStarted: (() => void) | undefined;
const lookupStarted = new Promise<void>(resolve => { signalLookupStarted = resolve; });
let lookups = 0;
const service = ImageOperationService.getInstance();
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(service, 'getCurrentOperation').mockImplementation(async () => {
lookups += 1;
if (lookups === 1) {
signalLookupStarted!();
await new Promise<void>(resolve => { releaseLookup = resolve; });
}
return null;
});
const first = service.claimCommunityUpdate();
await lookupStarted;
const second = await service.claimCommunityUpdate();
releaseLookup!();
await expect(first).resolves.toEqual({ ok: true });
expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
});
it('persists a terminal failure when the update helper reports an error', async () => {
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
const result = await ImageOperationService.getInstance().runCommunityUpdate();
const current = await ImageOperationService.getInstance().getCurrentOperation();
expect(result).toEqual({ ok: false, failureCode: 'update_failed' });
expect(current?.state).toBe('failed');
expect(current?.failureCode).toBe('update_failed');
});
it('fails a hardened update when the helper reports an error and binds its marker to the operation', async () => {
const entitlement = {
success: true as const,
entitlement: {
hardened_build_access: true,
channel: 'hardened' as const,
allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened:latest',
pin_recommendation: 'ghcr.io/studio-saelix/sencho-hardened:latest',
checked_at: '2026-07-13T00:00:00.000Z',
registry_requirement: {
registry_host: 'ghcr.io',
package_scope: 'studio-saelix/sencho-hardened',
credential_instructions: 'Use a pull token.',
supports_pull_token: true,
},
},
};
const resolved = {
filePath: '/compose.yml',
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:latest',
pinKind: 'semver' as const,
fileContent: 'services: {}',
};
let markerFile = '';
let markerContent = '';
vi.spyOn(HardenedEntitlementService.getInstance(), 'getEntitlement').mockResolvedValue(entitlement);
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(resolved);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(RegistryService.getInstance(), 'resolveDockerConfigForHost').mockResolvedValue({
config: { auths: { 'ghcr.io': { auth: 'credential' } } },
warnings: [],
});
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async options => {
markerFile = options?.successMarkerFile ?? '';
markerContent = options?.successMarkerContent ?? '';
});
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
const service = ImageOperationService.getInstance();
const fingerprint = service.computePreflightFingerprint(
resolved.filePath,
resolved.imageRef,
resolved.pinKind,
entitlement.entitlement.allowed_image_ref,
);
const result = await service.switchToHardened(fingerprint);
const current = await service.getCurrentOperation();
expect(result).toEqual({ ok: false, code: 'update_failed' });
expect(current?.state).toBe('failed');
expect(markerFile).toBe(path.join(tmpDir, `image-op-success-${current?.operationId}.json`));
expect(markerContent).toBe(JSON.stringify({ ok: true, operationId: current?.operationId }));
});
it('fails a recreating community op when the helper exits after handoff', async () => {
let helperExit: ((error: string | null) => void) | undefined;
const callOrder: string[] = [];
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => {
callOrder.push('watch');
helperExit = listener;
});
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => {
callOrder.push('trigger');
});
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
const service = ImageOperationService.getInstance();
const result = await service.runCommunityUpdate();
const mid = await service.getCurrentOperation();
expect(result).toEqual({ ok: true });
expect(mid?.state).toBe('recreating');
expect(callOrder).toEqual(['watch', 'trigger']);
expect(helperExit).toBeTypeOf('function');
helperExit!('Helper container exited without restarting Sencho');
await vi.waitFor(async () => {
const current = await service.getCurrentOperation();
expect(current?.state).toBe('failed');
expect(current?.failureCode).toBe('update_failed');
});
});
it('does not let recreating persist overwrite a concurrent helper-exit failure', async () => {
let helperExit: ((error: string | null) => void) | undefined;
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => {
helperExit = listener;
});
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
const service = ImageOperationService.getInstance();
const proto = Object.getPrototypeOf(service) as {
persist: (operation: unknown) => Promise<void>;
};
const realPersist = proto.persist.bind(service);
vi.spyOn(service as unknown as { persist: (operation: { state: string }) => Promise<void> }, 'persist')
.mockImplementation(async (operation) => {
if (operation.state === 'recreating' && helperExit) {
const exit = helperExit;
helperExit = undefined;
// Fail must land on disk before the recreating write runs. That is the
// overwrite order without CAS: helper-exit failed, then late recreating.
exit('Helper container exited without restarting Sencho');
await vi.waitFor(async () => {
const current = await service.getCurrentOperation();
expect(current?.state).toBe('failed');
expect(current?.failureCode).toBe('update_failed');
});
await realPersist(operation);
return;
}
return realPersist(operation);
});
await service.runCommunityUpdate();
const current = await service.getCurrentOperation();
expect(current?.state).toBe('failed');
expect(current?.failureCode).toBe('update_failed');
});
it('does not let acknowledging a stale failure replace an active current operation', async () => {
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
const service = ImageOperationService.getInstance();
await service.runCommunityUpdate();
const failedA = await service.getCurrentOperation();
expect(failedA?.state).toBe('failed');
const operationAId = failedA!.operationId;
const claimB = await service.claimCommunityUpdate();
expect(claimB).toEqual({ ok: true });
const currentB = await service.getCurrentOperation();
expect(currentB?.state).toBe('pending_pull');
expect(currentB?.operationId).not.toBe(operationAId);
expect(await service.acknowledge(operationAId)).toBe(true);
const stillB = await service.getCurrentOperation();
expect(stillB?.operationId).toBe(currentB!.operationId);
expect(stillB?.state).toBe('pending_pull');
const ackedA = await service.getOperation(operationAId);
expect(ackedA?.acknowledgedAt).toBeTruthy();
const claimC = await service.claimCommunityUpdate();
expect(claimC).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
});
it('replays a pending helper exit to a late onceHelperExit listener', async () => {
const svc = SelfUpdateService.getInstance();
const internal = svc as unknown as { pendingHelperExitError: string | undefined };
internal.pendingHelperExitError = 'Helper container exited without restarting Sencho';
const seen = new Promise<string | null>((resolve) => {
svc.onceHelperExit(resolve);
});
await expect(seen).resolves.toBe('Helper container exited without restarting Sencho');
expect(internal.pendingHelperExitError).toBeUndefined();
});
it('changes the fingerprint when a preflight value changes', () => {
const service = ImageOperationService.getInstance();
const baseline = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaa');
const changed = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:bbb');
expect(changed).not.toBe(baseline);
});
});
@@ -55,7 +55,7 @@ describe('NodeRegistry.fetchMetaForNode', () => {
updateError: null,
online: false,
imagePinKind: null,
updateBlocked: false,
updateBlocked: false, imageChannel: null,
});
expect(axiosSpy).not.toHaveBeenCalled();
db.deleteNode(nodeId);
@@ -249,8 +249,12 @@ describe('R1-B: PilotTunnelBridge.acceptReverseLocal route events', () => {
// Real local server. Have it close the connection immediately after
// accept so the bridge socket sees 'close' (and possibly 'error') on
// an already-connected socket.
// an already-connected socket. Swallow expected teardown noise so a
// late ECONNRESET cannot surface as an unhandled Vitest exception.
const accepted: net.Socket[] = [];
const upstream = net.createServer((socket) => {
accepted.push(socket);
socket.on('error', () => { /* expected post-handshake teardown */ });
socket.end();
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', () => resolve()));
@@ -284,7 +288,10 @@ describe('R1-B: PilotTunnelBridge.acceptReverseLocal route events', () => {
);
expect(failCalls).toHaveLength(0);
upstream.close();
for (const socket of accepted) {
try { socket.destroy(); } catch { /* ignore */ }
}
await new Promise<void>((resolve) => upstream.close(() => resolve()));
bridge.close();
});
});
@@ -26,7 +26,7 @@ afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
const ONLINE = { startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false } as const;
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 };
@@ -43,7 +43,7 @@ describe('remoteSupportsCrossNodeRbac', () => {
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 });
.mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false, imageChannel: null });
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
});
@@ -51,7 +51,7 @@ describe('remoteSupportsCrossNodeRbac', () => {
// 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 });
.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);
});
@@ -59,6 +59,24 @@ afterEach(() => {
});
describe('POST /api/system/update', () => {
it('rejects hardened updates from node-proxy credentials', async () => {
mockSelfUpdateAvailable({
pinInfo: {
pinKind: 'digest',
composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
filePath: '/opt/sencho/docker-compose.yml',
},
});
const machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const res = await request(app)
.post('/api/system/update')
.set('Authorization', machineAuth);
expect(res.status).toBe(403);
expect(res.body.code).toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED');
});
it('returns 400 for a supplied but invalid targetVersion', async () => {
mockSelfUpdateAvailable();
@@ -99,12 +117,27 @@ describe('POST /api/system/update', () => {
expect(res.body?.message).toMatch(/restart/i);
// triggerUpdate runs on res finish + delay; flush the microtask queue.
await new Promise(r => setTimeout(r, 600));
expect(triggerSpy).toHaveBeenCalledWith({ targetVersion: '0.99.0' });
expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({
targetVersion: '0.99.0',
successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/),
successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/),
}));
});
});
describe('GET /api/meta pin subset', () => {
it('exposes imagePinKind and updateBlocked but never composeImageRef', async () => {
it('does not expose a raw update error with a private image reference', async () => {
mockSelfUpdateAvailable();
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError')
.mockReturnValue('pull private.registry.example/internal/sencho:1.0 failed');
const res = await request(app).get('/api/meta');
expect(res.body.updateError).toBe('update_failed');
expect(JSON.stringify(res.body)).not.toContain('private.registry.example');
});
it('exposes imagePinKind, updateBlocked, and imageChannel but never composeImageRef', async () => {
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.93.3', filePath: '/opt/sencho/docker-compose.yml' },
});
@@ -114,19 +147,21 @@ describe('GET /api/meta pin subset', () => {
expect(res.status).toBe(200);
expect(res.body.imagePinKind).toBe('semver');
expect(res.body.updateBlocked).toBe(false);
expect(res.body.imageChannel).toBe('community');
expect(res.body).not.toHaveProperty('composeImageRef');
expect(res.body).not.toHaveProperty('targetImageRef');
});
it('reports updateBlocked=true for a digest pin', async () => {
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'digest', composeImageRef: 'saelix/sencho@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' },
pinInfo: { pinKind: 'digest', composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' },
});
const res = await request(app).get('/api/meta');
expect(res.body.imagePinKind).toBe('digest');
expect(res.body.updateBlocked).toBe(true);
expect(res.body.imageChannel).toBe('hardened');
});
});