Files
sencho/backend/src/__tests__/self-update-no-repin.test.ts
T
Anso 275c654407 feat(fleet): detect and update from a new sencho-dev:dev build (#1871)
* feat(fleet): add self dev-build detection primitives

Split compareLocalToRemoteTag into compareLocalToRemoteTagDetailed (returns
the probe's primary digest alongside the match/update/error verdict) with
compareLocalToRemoteTag now a thin wrapper, so a caller that needs both the
verdict and the digest no longer has to probe the same mutable tag twice.

Add detectSelfDevBuildUpdate, which compares the running container's own
image against the rolling ghcr.io/studio-saelix/sencho-dev:dev tag using the
new detailed comparison, laying the groundwork for surfacing dev-build
updates in Fleet.

* feat: add isSenchoDevRepository and isSenchoDevFloatingTag predicates

Add two pure predicate functions to helpers/selfUpdateCompose.ts for
identifying Sencho dev repository references and floating tag variants:

- isSenchoDevRepository: checks if a reference is to the ghcr.io/studio-saelix/sencho-dev
  repository, including digest-pinned and dev-<sha> tag variants
- isSenchoDevFloatingTag: checks if a reference is specifically the floating :dev tag
  on the Sencho dev repository (not digest-pinned, not immutable dev-<sha>)

Both functions reuse existing parsing patterns (normalizeImageRepository for repository
extraction, classifyImagePin idiom for digest and tag detection) to maintain consistency.

Add comprehensive test coverage in self-update-compose.test.ts covering all specified
test cases including edge cases (malformed refs, unrelated repos, digest pins, etc.).

* feat(gitops): wire dev-build detection into MonitorService

Adds a dev_build_update_available notification category and a new
checkSenchoDevBuild() cycle in MonitorService that detects when the
running container has fallen behind the rolling
ghcr.io/studio-saelix/sencho-dev:dev build it is pinned to, using
detectSelfDevBuildUpdate() and isSenchoDevFloatingTag(). Availability
state is written unconditionally so the Fleet update affordance never
depends on notification delivery succeeding, while a separate dedup
key prevents re-notifying for a digest already announced. Also guards
checkSenchoVersion() so a dev-repo pin no longer produces a false
positive stable-release update notification.

* feat(fleet): surface dev-image status and build availability

Fleet's GET /update-status now reports isDevImage (any reference to the
sencho-dev repository, including digest pins) and devBuildUpdateAvailable
(the exact floating :dev tag with a newer build observed, read from the
system-state key MonitorService already maintains). A dev-pinned local
node forces updateAvailable to false and clears any stale stable-release
skip, since that skip was computed before image-pin classification and
would otherwise leak a bogus "Skipped" state onto a dev row.

Made MonitorService's SENCHO_DEV_BUILD_AVAILABLE_KEY constant public so
both call sites share one string instead of duplicating it.

* fix(fleet): omit targetVersion for a dev-image update trigger

updateRequestInit() always forwarded latestVersion (the latest stable
release) as targetVersion whenever it was valid semver, even for a
dev-pinned node. The backend already ignores targetVersion safely for a
floating pin, so this never caused an actual repin, but it produced a
misleading "Update to X.Y.Z" button label and confirm-dialog copy for an
update that installs the dev image, not that stable release.

* feat(fleet): add integration-image badge and dev build update button

NodeCard now shows a persistent "Integration image" badge whenever a
node's compose image is any sencho-dev reference, independent of update
availability, visible to every role. When a newer dev build is available,
a solid brand-colored "Update dev build" button appears alongside it,
admin-only, reusing the existing update trigger and requireAdmin route.
Styled distinctly from the neutral stable "Update to X.Y.Z" button so an
operator always knows which channel they're acting on.

* feat(fleet): add dev-image copy to the local update confirm dialog

LocalUpdateConfirmDialog now recognizes isDevImage and shows a distinct
LOCAL - DEV UPDATE kicker plus copy stating the sencho-dev:dev reference
will be pulled without rewriting the compose image, and that integration
images are unsigned and carry no release attestations. Without this, a
dev-pinned node's update confirmation fell through to the generic "Pulls
Sencho the latest release" copy. FleetView.tsx threads isDevImage from
the node's update status through to the dialog, same source as its other
pin fields.

* feat(fleet): separate dev and stable availability in the Node Updates sheet

The sheet counted stable and dev availability together via the same
updateAvailable field, so a dev-pinned node with a build available fell
into neither the summary counts nor any row action, and would have
misleadingly rendered as "Up to date" once devBuildUpdateAvailable
existed. stableAvailable and devAvailable are now tracked separately: the
changelog dot lights only from stableAvailable (a dev build has no
release changelog), the summary and meta text report the combined total,
a dev row shows "Integration build" instead of a stable version in the
Latest column, and the existing Update button/badge now also fires for
devBuildUpdateAvailable. Update all and Skip stay stable-only, since both
already gate on fields a dev row never satisfies.

* feat(fleet): bring dev-build detection and update to Mobile Fleet

Mobile Fleet previously had no update capability at all: it only polled
/fleet/overview and never called useFleetUpdateStatus, so it could not
show the stable update flow either. It now fetches update status
alongside the overview poll, shows the same "integration" marker as
desktop on any dev-pinned node's card (visible to every role), and gives
admins a dev-build update action.

The action renders as a sibling of the card's own button rather than
nested inside it, since the card is itself a <button> and a nested
button is invalid HTML with broken touch semantics. It reuses the exact
same triggerNodeUpdate/confirmLocalUpdate flow and LocalUpdateConfirmDialog
/ReconnectingOverlay components desktop already renders, so there is no
parallel API implementation to keep in sync.

* feat(notifications): wire dev_build_update_available through the frontend

Adds the category to the frontend NotificationCategory union, its bell
label, the per-node "mute update notifications" bundle, and the bell's
friendly dot-color memo. The changelog navigation and "View changelog"
button stay scoped to node_update_available only: a dev build has no
release changelog entry to navigate to.

* docs: document dev-build detection and update on Fleet

Adds the dev_build_update_available notification category, the
persistent Integration image marker, and the dev-build update action
(desktop and mobile) to the alerts-notifications, verifying-images,
fleet-view, remote-updates, and upgrade pages. States the detection
cadence explicitly: it polls on a fixed interval and reflects the newest
build observed, not necessarily every individual build.

* fix(gitops): sanitize the inconclusive-reason debug log for log injection

CodeQL flagged the dev-build check's debug log as depending on a
user-influenced value (a registry probe failure reason can trace back to
external input). Wraps it with sanitizeForLog(), the existing repo-wide
remediation for this class of finding, matching how registry-api.ts
already handles the same pattern.

* test(gitops): cover the no-repin invariant on a dev-build self-update

Proves triggerUpdate(), called with neither targetVersion nor
targetImageRef (the exact dev-build update call), pulls the current
compose-declared ref unchanged and never stages a compose rewrite.

* fix(fleet): use the shared busy-button pattern on Mobile Fleet's dev update action

Replaces the local Loader2 plus boolean pending logic with BusyButton
so busy behavior and interaction locking stay in sync with the rest
of the app's async click surfaces.

* test(gitops): exercise the production call shape in the no-repin regression

Fleet substitutes the stable compare target when the request body omits
one, so SelfUpdateService receives a targetVersion even for a dev-build
update. The guard that protects a :dev install is therefore the semver
check inside the repin branch, not the absence of a target.

Drives triggerUpdate with a forwarded target against a floating :dev pin
and asserts the reference is pulled unchanged with no staged patch, and
pairs it with a semver case so the negative assertions cannot pass
vacuously.
2026-08-30 15:54:19 -04:00

161 lines
6.4 KiB
TypeScript

/**
* Regression coverage for the no-repin invariant on a floating-tag self-update.
*
* The Fleet dev-build update reaches SelfUpdateService WITH a targetVersion
* even though the frontend omits one: the route substitutes the stable compare
* target (resolveUpdateTarget in routes/fleet.ts) and forwards it through
* ImageOperationService. So the guard that actually protects a :dev install is
* not the absence of a target, it is the `pinKind === 'semver'` test inside the
* repin branch. The worst-case failure is silently rewriting the compose file
* from :dev to a stable tag, which would move the install off the dev channel.
*
* These exercise the real SelfUpdateService decision rather than a mock
* standing in for it, and pair the floating cases with a semver case so the
* negative assertions cannot pass vacuously.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockExecFile, mockExecFileAsync, mockWriteFileSync } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
mockExecFileAsync: vi.fn(),
mockWriteFileSync: vi.fn(),
}));
vi.mock('child_process', () => ({
exec: vi.fn(),
execFile: mockExecFile,
}));
vi.mock('util', () => ({
promisify: () => mockExecFileAsync,
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getGlobalSettings: () => ({}) }) },
}));
// SelfUpdateService imports `fs` as a namespace; spying on the real ESM
// namespace object throws ("Module namespace is not configurable"), so the
// write path is swapped for a mock while every other fs function stays real.
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return { ...actual, writeFileSync: mockWriteFileSync };
});
const DEV_IMAGE_REF = 'ghcr.io/studio-saelix/sencho-dev:dev';
const SEMVER_IMAGE_REF = 'saelix/sencho:0.93.3';
const WORKING_DIR = '/opt/sencho';
const COMPOSE_FILE = '/opt/sencho/docker-compose.yml';
// The stable release the Fleet route resolves and forwards when the caller
// (a dev-image update) supplies no target of its own.
const RESOLVED_TARGET = '0.99.0';
// Mirrors SelfUpdateService's private ComposeContext shape (not exported), so
// the test-only field poke below stays structurally checked against drift.
type TestComposeContext = {
workingDir: string;
configFiles: string;
serviceName: string;
imageName: string;
dataDirHost: string | null;
hostBindMounts: Array<{ source: string; destination: string }>;
};
/** The argv SelfUpdateService hands the helper container, or null if unspawned. */
function helperArgs(): string[] | null {
const call = mockExecFile.mock.calls[0] as [string, string[]] | undefined;
return call ? call[1] : null;
}
describe('SelfUpdateService.triggerUpdate (no-repin invariant)', () => {
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
/** Point the service at a compose project declaring `composeImageRef`. */
async function setupWithComposeImage(composeImageRef: string): Promise<void> {
vi.clearAllMocks();
// `docker pull` yields nothing; the throwaway `cat` container returns the
// compose file, which is what the fresh pin resolution actually parses.
mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) =>
args[0] === 'pull'
? { stdout: '', stderr: '' }
: { stdout: `services:\n sencho:\n image: ${composeImageRef}\n`, stderr: '' },
);
({ default: SelfUpdateService } = await import('../services/SelfUpdateService'));
(SelfUpdateService.getInstance() as unknown as { composeContext: TestComposeContext }).composeContext = {
workingDir: WORKING_DIR,
configFiles: COMPOSE_FILE,
serviceName: 'sencho',
imageName: composeImageRef,
dataDirHost: '/opt/sencho/data',
hostBindMounts: [],
};
}
beforeEach(() => {
vi.clearAllMocks();
});
it('keeps a :dev pin on its own tag when the route forwards a stable target', async () => {
await setupWithComposeImage(DEV_IMAGE_REF);
// The production call shape: Fleet resolved a stable compare target and
// forwarded it, so the repin branch runs and must decline on a floating pin.
await SelfUpdateService.getInstance().triggerUpdate({ targetVersion: RESOLVED_TARGET });
expect(mockExecFileAsync).toHaveBeenCalledWith(
'docker',
['pull', DEV_IMAGE_REF],
expect.objectContaining({ timeout: 300_000 }),
);
// The forwarded stable version must never become the pulled reference.
expect(mockExecFileAsync).not.toHaveBeenCalledWith(
'docker',
['pull', expect.stringContaining(RESOLVED_TARGET)],
expect.anything(),
);
// A staged compose patch is written only when a repin is committed.
expect(mockWriteFileSync).not.toHaveBeenCalled();
const args = helperArgs();
expect(args).not.toBeNull();
expect(args).toContain(`${WORKING_DIR}:${WORKING_DIR}:ro`);
expect(args).not.toContain(`${WORKING_DIR}:${WORKING_DIR}:rw`);
expect(args![args!.length - 1]).not.toContain('cp ');
});
it('pulls the current dev image unchanged when no target is supplied at all', async () => {
await setupWithComposeImage(DEV_IMAGE_REF);
// The legacy pull-current path (no target anywhere) skips the repin branch
// outright rather than declining inside it.
await SelfUpdateService.getInstance().triggerUpdate();
expect(mockExecFileAsync).toHaveBeenCalledWith(
'docker',
['pull', DEV_IMAGE_REF],
expect.objectContaining({ timeout: 300_000 }),
);
expect(mockWriteFileSync).not.toHaveBeenCalled();
expect(helperArgs()).toContain(`${WORKING_DIR}:${WORKING_DIR}:ro`);
});
it('still repins a semver pin to the target (the assertions above are not vacuous)', async () => {
await setupWithComposeImage(SEMVER_IMAGE_REF);
await SelfUpdateService.getInstance().triggerUpdate({ targetVersion: RESOLVED_TARGET });
// Contrast case: a semver pin is exactly what the floating pin must not do.
expect(mockExecFileAsync).toHaveBeenCalledWith(
'docker',
['pull', `saelix/sencho:${RESOLVED_TARGET}`],
expect.objectContaining({ timeout: 300_000 }),
);
expect(mockWriteFileSync).toHaveBeenCalledWith(
expect.stringContaining('.sencho-compose-patch'),
expect.stringContaining(`saelix/sencho:${RESOLVED_TARGET}`),
'utf8',
);
const args = helperArgs();
expect(args).toContain(`${WORKING_DIR}:${WORKING_DIR}:rw`);
expect(args![args!.length - 1]).toContain('cp ');
});
});