mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 22:25:30 +00:00
fix(image-updates): match any local RepoDigest against the remote tag (#1695)
* fix(image-updates): match any local RepoDigest against the remote tag Docker can list a stale multi-arch index digest ahead of the current one on the same image. Selecting only the first RepoDigest caused false same-tag rebuilds (for example redis:8.8.0) even when another digest equaled the registry primary. Compare every matching candidate and keep fail-closed behavior for empty, unknown-platform, and classification errors. Fixes #1684 * fix(image-updates): surface digest verification failures to operators Carry comparator errors into update-preview as check_error / verification_failed, prefer failed checks over sticky has_update in Fleet and the sidebar, and keep Update Guard from claiming no pending update when verification failed. * fix(e2e): align sidebar truncation spec with check-failed precedence StackRow now shows the check-failed icon over a stale update dot, but this spec still asserted the old precedence and failed deterministically in CI on every attempt. * fix(fleet): treat verification-only previews as non-actionable Fresh update-preview wins over sticky fleet booleans: disable Apply, exclude from ready counts, and move verification-only stacks into the check-failures advisory (including remote-labeled names). * fix(fleet): move preview actionability helpers out of the view Exporting non-components from AutoUpdateReadinessView tripped react-refresh lint in CI. Keep the helpers in a shared lib module and drop an unused mock arg. * fix(fleet): drop sticky cards when fresh preview clears the update A successful no-update preview now removes the pending Fleet card instead of leaving Apply enabled. Verification-only stacks still go to the advisory, and empty-state copy no longer claims all-clear while checks remain unresolved. * fix(image-updates): hold full-stack apply for review when another image fails verification A confirmed update or rebuild on one image previously left the whole stack fully actionable even when a different image in the same stack failed digest verification: Anatomy claimed "safe to apply", Update Guard reported ready, and Fleet's full-stack Apply stayed enabled, all while showing the verification-failure text right next to those claims. isActionableUpdatePreview now requires no verification failure anywhere in the stack; a new isReviewRequiredUpdatePreview flags the mixed state so Fleet still surfaces the card (not silently cleared) with Apply now disabled and a "Review · unverified" badge. Anatomy's banner says "review required" instead of a bump-based safety claim and withholds its Apply button. Update Guard's pending-update signal downgrades from ok to attention. Per-service apply (Fleet's per-image row) is deliberately left enabled since a service-scoped update to the confirmed image does not touch the unverified one. * fix(image-updates): treat rebuild_available symmetrically with has_update in Update Guard updatePreviewSignal only downgraded to 'attention' inside the has_update branch, so a rebuild-only stack (has_update false, rebuild_available true) with a sibling verification failure fell through to the plain verification-only 'unknown' branch and never mentioned the pending rebuild, inconsistent with isReviewRequiredUpdatePreview on the frontend which treats has_update and rebuild_available the same way. Also adds desktop-card coverage for the mixed state (previously only the mobile card was exercised) and locks in blocked/major-bump precedence over the new review-required badge/banner in both Fleet and Anatomy. * fix(image-updates): derive the mixed-verification review-hold from per-image detail, not the stack aggregate has_update and check_error are independent per image: a tag-based update can be confirmed via the registry's tag list even when that same image's own digest comparison against the current tag errored (already covered by an existing update-preview-service test). The stack-level verification_failed and has_update flags can therefore both be true for the SAME single image, which the previous review-hold treated identically to a genuinely different image failing verification: Update Guard said "another image failed digest verification" and Fleet told the user to "apply the confirmed service individually" on a single-service stack where no such affordance exists. isReviewRequiredUpdatePreview (and isActionableUpdatePreview) now walk the preview's images to require a pure failure image (check_error, no has_update of its own) alongside a genuinely different confirmed image or rebuild, falling back to the old aggregate-only judgment when per-image detail is unavailable. StackAnatomyPanel now imports the shared helper instead of hand-rolling the same predicate, so Fleet and Anatomy cannot drift apart. Backend updatePreviewSignal gets the same per-image treatment via a new optional images parameter, threaded through from UpdateGuardService. * fix(image-updates): fail closed on platform-unavailable indexes and legacy previews, allow anonymous tag listing Four independent gaps from the same QA pass, all in the digest/tag verification path this PR introduced or touches: - compareLocalToRemoteTag now distinguishes a remote index with no descriptor at all for the local platform (including an empty or fully-filtered index) from a genuine mismatch: the former returns an error instead of reporting a speculative update. A node cannot pull a platform the index does not offer. - selectLocalRepoDigests no longer falls back to a sole unrelated-repository RepoDigest when nothing matches the configured repo; comparing against a registry state that has nothing to do with the declared image risks a false update. Returns unresolved instead of guessing. - isClearedUpdatePreview no longer treats a preview with verification_failed missing entirely (not merely false) as proof the stack is clean. The current backend always includes this field, so its absence identifies an older remote node's response, which cannot vouch for a clean result the way an explicit false can. - listRegistryTags (and the underlying listRegistryTagsResult) no longer short-circuits to an empty list whenever no registry credentials are configured. getAuthToken already resolves anonymous tokens for public repositories; skipping it meant tag-based update detection silently never fired for any public image without a stored credential. * fix(image-updates): correct platform-check overreach, add cache and advisory for prior fixes Addresses code-review findings on the previous commit: - The platform-unavailable check fired too eagerly: an index whose runnable descriptors legally omit platform (OCI-permitted, routed to exactDigests) has real pullable content, so it must not be confused with a genuinely empty or fully-filtered index. Now only errors when both platform-labeled descriptors and exactDigests are empty. - listRegistryTags is now cached (15 min TTL): anonymous listing has no other rate limiting, and Fleet fans this out across every image on every reload. - A legacy preview (kept rather than cleared) now also pushes a check-failure advisory entry explaining why, instead of rendering as an unexplained pending card. - Corrected docstrings that described the old sole-unmatched-digest fallback and inverted how anonymous registry auth actually resolves. Adds coverage for: nested-index and attestation-only-filtered platform unavailability, a platform-less-but-populated index staying a match, the unrelated-repo digest rejection wired through the real preview-computation path (not just the registry-api unit), and legacy-preview interaction with an actionable has_update:true. * fix(image-updates): fail closed on mixed platform indexes, stop caching tag-list failures Addresses a second review round on the previous commit, including an empirically-verified regression: - The exactDigests fallback was unconditional: an index mixing a platform-labeled descriptor for a DIFFERENT platform with an unlabeled leaf let that leaf stand in as this platform's content, reporting a speculative update for a genuinely incompatible platform. Now an unlabeled leaf is only trusted when it is the ONLY kind of descriptor in the index (nothing else claims a different platform); a mixed index errors instead. - listRegistryTags was caching failed lookups for the full 15-minute TTL (a 429, an unreachable registry, or credentials not yet configured all looked identical to a real empty tag list). The fetcher now throws on failure so only a success is ever cached; CacheService's existing stale-on-error fallback still serves the last good list when one exists. - The manual "Recheck" action now also drops the tag-list cache, so a newly published tag is visible immediately instead of waiting out the TTL. - The legacy-preview advisory no longer fires when the same preview is already actionable on its own terms (a remote's own confirmed has_update/rebuild_available): pairing a "could not be checked" banner with an enabled Apply button next to it contradicted itself. - Corrected docstrings and a self-contradictory inline comment left over from the prior fix. New coverage: the exact mixed-index regression this round found and fixed, cache hit/no-repeat-fetch and failure-not-cached behavior, and the legacy-preview-plus-already-actionable non-contradiction. * fix(image-updates): add digest_error unmasked field, reorder RiskBadge, fix actionability gates Add digest_error to UpdatePreviewImage as an always-populated field that is independent of check_status masking: a confirmed tag-based update on the same image resolves check_status to 'ok' and nulls check_error, but digest_error stays set since the image's current tag content was never verified. Switch hasUnverifiedOtherImage to read digest_error. Reorder RiskBadge precedence so reviewRequired is checked before uncertain (both derive from check_error, so uncertain was unreachable). Fix self-contradiction in test fixtures (digest_update:true + check_error). Add masked-tag regression test with two-image fixture. Simplify hasUnverifiedOtherImage per code review feedback.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* MobileReadinessCard is the one-up phone card for the Updates readiness board.
|
||||
* Its Apply button is disabled only when the update is blocked (major bump) or
|
||||
* Its Apply button is disabled when the update is blocked (major bump),
|
||||
* digest verification failed without a confirmed update, or an apply is
|
||||
* already in flight; manual apply works regardless of schedule. The Auto: Off
|
||||
* pill still reflects the absence of a covering auto-update schedule.
|
||||
*/
|
||||
@@ -26,9 +27,12 @@ vi.mock('@/context/DeployFeedbackContext', () => ({
|
||||
// loadReadiness useCallback identity and re-triggers its effect forever.
|
||||
const mockNodeMeta = new Map();
|
||||
const mockRefreshNodeMeta = vi.fn();
|
||||
const mockNodes: { id: number; name: string; type: 'local' | 'remote'; status: string }[] = [
|
||||
{ id: 1, name: 'Local', type: 'local', status: 'online' },
|
||||
];
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({
|
||||
nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }],
|
||||
nodes: mockNodes,
|
||||
nodeMeta: mockNodeMeta,
|
||||
refreshNodeMeta: mockRefreshNodeMeta,
|
||||
}),
|
||||
@@ -36,7 +40,17 @@ vi.mock('@/context/NodeContext', () => ({
|
||||
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { requestServiceUpdate } from '@/lib/serviceUpdate';
|
||||
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
|
||||
import AutoUpdateReadinessView, {
|
||||
MobileReadinessCard,
|
||||
CadenceStrip,
|
||||
type StackCard,
|
||||
} from '../AutoUpdateReadinessView';
|
||||
import {
|
||||
isActionableUpdatePreview,
|
||||
isClearedUpdatePreview,
|
||||
isReviewRequiredUpdatePreview,
|
||||
isVerificationOnlyPreview,
|
||||
} from '@/lib/updatePreviewActionability';
|
||||
import { isAuthoritativeNegativePreview } from '@/types/imageUpdates';
|
||||
|
||||
function card(over: Partial<StackCard> = {}): StackCard {
|
||||
@@ -74,6 +88,208 @@ function card(over: Partial<StackCard> = {}): StackCard {
|
||||
|
||||
const apply = () => screen.getByRole('button', { name: /Apply now/i });
|
||||
|
||||
function previewSummary(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
stack_name: 'redis',
|
||||
images: [],
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
summary: {
|
||||
has_update: false,
|
||||
primary_image: 'redis',
|
||||
current_tag: '8.8.0',
|
||||
next_tag: '8.8.0',
|
||||
semver_bump: 'none' as const,
|
||||
update_kind: 'none' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
rebuild_available: false,
|
||||
check_status: 'ok' as const,
|
||||
verification_failed: false,
|
||||
verification_error: null,
|
||||
...over,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('verification preview helpers', () => {
|
||||
it('treats verification failure without update/rebuild as verification-only', () => {
|
||||
const preview = previewSummary({ verification_failed: true });
|
||||
expect(isVerificationOnlyPreview(preview)).toBe(true);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('holds a verified update for review, not full-stack-actionable, when another image failed verification', () => {
|
||||
const preview = previewSummary({
|
||||
verification_failed: true,
|
||||
has_update: true,
|
||||
update_kind: 'tag',
|
||||
semver_bump: 'patch',
|
||||
next_tag: '8.8.1',
|
||||
});
|
||||
expect(isVerificationOnlyPreview(preview)).toBe(false);
|
||||
expect(isReviewRequiredUpdatePreview(preview)).toBe(true);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('holds a rebuild for review, not full-stack-actionable, when another image failed verification', () => {
|
||||
const preview = previewSummary({
|
||||
verification_failed: true,
|
||||
rebuild_available: true,
|
||||
update_kind: 'digest',
|
||||
});
|
||||
expect(isVerificationOnlyPreview(preview)).toBe(false);
|
||||
expect(isReviewRequiredUpdatePreview(preview)).toBe(true);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a review-required mixed state as cleared', () => {
|
||||
const preview = previewSummary({
|
||||
verification_failed: true,
|
||||
has_update: true,
|
||||
update_kind: 'tag',
|
||||
semver_bump: 'patch',
|
||||
next_tag: '8.8.1',
|
||||
});
|
||||
expect(isClearedUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a single image with a confirmed digest update actionable when there is no digest error anywhere in the stack', () => {
|
||||
// digest_update and digest_error are mutually exclusive for one image (both
|
||||
// derive from the same comparison), so a confirmed digest update is always
|
||||
// its own clean case, with nothing to hold it for review.
|
||||
const preview = {
|
||||
...previewSummary({ has_update: true, update_kind: 'digest', semver_bump: 'patch' }),
|
||||
images: [{ has_update: true, digest_update: true, digest_error: null }],
|
||||
};
|
||||
expect(isReviewRequiredUpdatePreview(preview)).toBe(false);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(true);
|
||||
});
|
||||
|
||||
it('holds a confirmed update for review when a genuinely different image failed digest verification', () => {
|
||||
const preview = {
|
||||
...previewSummary({ verification_failed: true, has_update: true, update_kind: 'digest', semver_bump: 'patch' }),
|
||||
images: [
|
||||
{ has_update: true, digest_update: true, digest_error: null },
|
||||
{ has_update: false, digest_update: false, digest_error: 'Registry unreachable' },
|
||||
],
|
||||
};
|
||||
expect(isReviewRequiredUpdatePreview(preview)).toBe(true);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('holds a confirmed update for review even when the other image\'s own tag update masks its digest error into an overall ok check_status', () => {
|
||||
// The second image's tag compare confirmed an update, so the backend masks
|
||||
// its digest failure into check_status 'ok' + check_error null. Only the
|
||||
// unmasked digest_error still reports that its content went unverified.
|
||||
const preview = {
|
||||
...previewSummary({ has_update: true, update_kind: 'digest', semver_bump: 'patch', check_status: 'ok' }),
|
||||
images: [
|
||||
{ has_update: true, digest_update: true, check_status: 'ok', check_error: null, digest_error: null },
|
||||
{
|
||||
has_update: true, digest_update: false, tag_update: true,
|
||||
check_status: 'ok', check_error: null, digest_error: 'Registry unreachable',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(isReviewRequiredUpdatePreview(preview)).toBe(true);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects blocked updates as actionable', () => {
|
||||
const preview = previewSummary({
|
||||
has_update: true,
|
||||
blocked: true,
|
||||
blocked_reason: 'Major version bump',
|
||||
semver_bump: 'major',
|
||||
update_kind: 'tag',
|
||||
});
|
||||
expect(isVerificationOnlyPreview(preview)).toBe(false);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a legacy preview that reports its own has_update:true fully actionable (trusts the remote\'s own confirmed update)', () => {
|
||||
// isLegacyPreview only guards isClearedUpdatePreview: a legacy remote that
|
||||
// itself confirms an update is not additionally gated by a verification
|
||||
// signal it never had. This is deliberate, not an oversight -- the
|
||||
// sticky/preview agreement (both say "update") is what matters here.
|
||||
const legacyPreview = {
|
||||
stack_name: 'redis',
|
||||
images: [],
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'redis',
|
||||
current_tag: '8.8.0',
|
||||
next_tag: '8.8.1',
|
||||
semver_bump: 'patch' as const,
|
||||
update_kind: 'digest' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
// check_status, verification_failed, and rebuild_available intentionally omitted.
|
||||
},
|
||||
};
|
||||
expect(isActionableUpdatePreview(legacyPreview)).toBe(true);
|
||||
expect(isClearedUpdatePreview(legacyPreview)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a legacy remote preview (verification_failed missing entirely) as cleared', () => {
|
||||
// The current backend always sends verification_failed (true or false);
|
||||
// its total absence means the response came from an older remote that
|
||||
// predates digest verification and cannot vouch for a clean result.
|
||||
const legacyPreview = {
|
||||
stack_name: 'redis',
|
||||
images: [],
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
summary: {
|
||||
has_update: false,
|
||||
primary_image: 'redis',
|
||||
current_tag: '8.8.0',
|
||||
next_tag: '8.8.0',
|
||||
semver_bump: 'none' as const,
|
||||
update_kind: 'none' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
// verification_failed and rebuild_available intentionally omitted.
|
||||
},
|
||||
};
|
||||
expect(isClearedUpdatePreview(legacyPreview)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined previews', () => {
|
||||
expect(isVerificationOnlyPreview(null)).toBe(false);
|
||||
expect(isVerificationOnlyPreview(undefined)).toBe(false);
|
||||
expect(isActionableUpdatePreview(null)).toBe(false);
|
||||
expect(isActionableUpdatePreview(undefined)).toBe(false);
|
||||
expect(isClearedUpdatePreview(null)).toBe(false);
|
||||
expect(isClearedUpdatePreview(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a successful no-update preview as cleared', () => {
|
||||
const preview = previewSummary({
|
||||
has_update: false,
|
||||
rebuild_available: false,
|
||||
verification_failed: false,
|
||||
});
|
||||
expect(isClearedUpdatePreview(preview)).toBe(true);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat blocked updates as cleared', () => {
|
||||
const preview = previewSummary({
|
||||
has_update: true,
|
||||
blocked: true,
|
||||
blocked_reason: 'Major version bump',
|
||||
semver_bump: 'major',
|
||||
update_kind: 'tag',
|
||||
});
|
||||
expect(isClearedUpdatePreview(preview)).toBe(false);
|
||||
expect(isActionableUpdatePreview(preview)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('enables Apply for a safe, non-blocked update', () => {
|
||||
render(<MobileReadinessCard card={card()} onApply={vi.fn()} />);
|
||||
expect(apply()).toBeEnabled();
|
||||
@@ -130,6 +346,96 @@ it('disables Apply when the update is blocked (major bump)', () => {
|
||||
expect(apply()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Apply for verification-only preview (no confirmed update)', () => {
|
||||
render(
|
||||
<MobileReadinessCard
|
||||
card={card({
|
||||
preview: {
|
||||
stack_name: 'redis', images: [], rollback_target: null, changelog: null,
|
||||
summary: {
|
||||
has_update: false,
|
||||
primary_image: 'redis',
|
||||
current_tag: '8.8.0',
|
||||
next_tag: '8.8.0',
|
||||
semver_bump: 'none',
|
||||
update_kind: 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
rebuild_available: false,
|
||||
verification_failed: true,
|
||||
verification_error: 'Could not verify digest',
|
||||
},
|
||||
},
|
||||
})}
|
||||
onApply={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(apply()).toBeDisabled();
|
||||
expect(screen.getByTestId('readiness-verification-failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('holds full-stack Apply for review when one image confirms an update and another fails verification, but keeps per-service Apply enabled', () => {
|
||||
const onApplyService = vi.fn();
|
||||
render(
|
||||
<MobileReadinessCard
|
||||
card={card({
|
||||
preview: {
|
||||
stack_name: 'mixed', rollback_target: null, changelog: null,
|
||||
images: [
|
||||
{ service: 'confirmed', image: 'alpine:latest', current_tag: 'latest', next_tag: 'latest', has_update: true, digest_update: true, semver_bump: 'patch', digest_error: null },
|
||||
{ service: 'failing', image: 'private.example/db:latest', current_tag: 'latest', next_tag: null, has_update: false, digest_update: false, semver_bump: 'none', digest_error: 'Registry unreachable' },
|
||||
],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'alpine:latest',
|
||||
current_tag: 'latest',
|
||||
next_tag: 'latest',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'digest',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
rebuild_available: false,
|
||||
verification_failed: true,
|
||||
verification_error: 'Registry unreachable',
|
||||
},
|
||||
},
|
||||
})}
|
||||
canServiceUpdate
|
||||
onApply={vi.fn()}
|
||||
onApplyService={onApplyService}
|
||||
/>,
|
||||
);
|
||||
expect(apply()).toBeDisabled();
|
||||
expect(screen.getByTestId('readiness-verification-warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Safe · patch/i)).toBeNull();
|
||||
expect(screen.getByText(/Review · unverified/i)).toBeInTheDocument();
|
||||
const serviceApply = screen.getByRole('button', { name: /^Apply$/i });
|
||||
expect(serviceApply).toBeEnabled();
|
||||
fireEvent.click(serviceApply);
|
||||
expect(onApplyService).toHaveBeenCalledWith('nextcloud', 1, 'confirmed');
|
||||
});
|
||||
|
||||
it('shows the blocked (major) badge, not the review-required badge, when both apply', () => {
|
||||
render(
|
||||
<MobileReadinessCard
|
||||
card={card({
|
||||
preview: {
|
||||
stack_name: 'gitea', images: [], rollback_target: null, changelog: null,
|
||||
summary: {
|
||||
has_update: true, primary_image: 'gitea', current_tag: '1.21', next_tag: '2.0',
|
||||
semver_bump: 'major', update_kind: 'tag', blocked: true, blocked_reason: 'Major version bump',
|
||||
rebuild_available: false, verification_failed: true, verification_error: 'Registry unreachable',
|
||||
},
|
||||
},
|
||||
})}
|
||||
onApply={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Blocked · major/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Review · unverified/i)).toBeNull();
|
||||
expect(apply()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Apply while an update is in flight', () => {
|
||||
render(<MobileReadinessCard card={card({ applying: true })} onApply={vi.fn()} />);
|
||||
// While applying the button label switches to "Applying...".
|
||||
@@ -326,6 +632,75 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
expect(mockedFetchForNode.mock.calls.filter((c) => String(c[0]).includes('/update-preview')).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('holds the desktop full-stack Apply for review, but keeps per-service Apply enabled, when a confirmed update sits alongside another image failing verification', async () => {
|
||||
mockNodeMeta.set(1, {
|
||||
version: '1.0.0',
|
||||
capabilities: ['service-scoped-update'],
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ '1': { mixed: true } }) });
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'mixed',
|
||||
images: [
|
||||
{ service: 'confirmed', image: 'alpine:latest', current_tag: 'latest', next_tag: 'latest', has_update: true, digest_update: true, semver_bump: 'patch', digest_error: null },
|
||||
{ service: 'failing', image: 'private.example/db:latest', current_tag: 'latest', next_tag: null, has_update: false, digest_update: false, semver_bump: 'none', digest_error: 'Registry unreachable' },
|
||||
],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'alpine:latest',
|
||||
current_tag: 'latest',
|
||||
next_tag: 'latest',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'digest',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
rebuild_available: false,
|
||||
verification_failed: true,
|
||||
verification_error: 'Registry unreachable',
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
}),
|
||||
});
|
||||
vi.mocked(requestServiceUpdate).mockResolvedValue({
|
||||
ok: true,
|
||||
mode: 'update',
|
||||
serviceName: 'confirmed',
|
||||
healthGateId: null,
|
||||
observing: false,
|
||||
recoveryId: null,
|
||||
recoveryAvailable: false,
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
const applyBtn = await screen.findByRole('button', { name: /Apply now/i });
|
||||
expect(applyBtn).toBeDisabled();
|
||||
expect(screen.queryByText(/Safe · patch/i)).toBeNull();
|
||||
expect(screen.getByText(/Review · unverified/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('readiness-verification-warning')).toBeInTheDocument();
|
||||
|
||||
const serviceApply = screen.getByRole('button', { name: /^Apply$/i });
|
||||
expect(serviceApply).toBeEnabled();
|
||||
await act(async () => { fireEvent.click(serviceApply); });
|
||||
await waitFor(() => {
|
||||
expect(requestServiceUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stackName: 'mixed',
|
||||
serviceName: 'confirmed',
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -341,6 +716,7 @@ describe('AutoUpdateReadinessView check-failed advisory', () => {
|
||||
afterEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedFetchForNode.mockReset();
|
||||
mockNodes.splice(0, mockNodes.length, { id: 1, name: 'Local', type: 'local', status: 'online' });
|
||||
});
|
||||
|
||||
it('lists local stacks whose check failed, with the reason', async () => {
|
||||
@@ -368,6 +744,434 @@ describe('AutoUpdateReadinessView check-failed advisory', () => {
|
||||
// An ok stack with no update must not appear in the advisory.
|
||||
expect(screen.queryByText('web')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps sticky has_update+failed stacks in the advisory, not the update card grid', async () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'1': { grafana: true, web: true },
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) return Promise.resolve({ ok: true, json: async () => [] });
|
||||
if (url === '/image-updates/detail') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
grafana: { hasUpdate: true, checkStatus: 'failed', lastError: 'Registry unreachable', checkedAt: 1 },
|
||||
web: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'web',
|
||||
images: [{ service: 'web', image: 'nginx:1', current_tag: '1', next_tag: '2', has_update: true, semver_bump: 'patch', check_error: null }],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nginx:1',
|
||||
current_tag: '1',
|
||||
next_tag: '2',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
verification_failed: false,
|
||||
verification_error: null,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => null });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
expect(await screen.findByText(/could not be checked/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('grafana')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Registry unreachable/)).toBeInTheDocument();
|
||||
// web remains a confirmed update card; grafana must not also render as a stack card heading.
|
||||
await waitFor(() => {
|
||||
const headings = screen.getAllByText('web');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('drops verification-only sticky stacks from the card grid into the advisory', async () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'1': { redis: true },
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ([{
|
||||
id: 1,
|
||||
enabled: true,
|
||||
action: 'update',
|
||||
target_type: 'stack',
|
||||
target_id: 'redis',
|
||||
node_id: 1,
|
||||
next_run_at: Date.now() + 60_000,
|
||||
}]),
|
||||
});
|
||||
}
|
||||
if (url === '/image-updates/detail') {
|
||||
// Sticky hasUpdate with a successful check still enters the fleet grid;
|
||||
// only the fresh preview can prove verification-only.
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'redis',
|
||||
images: [{
|
||||
service: 'redis',
|
||||
image: 'redis:8.8.0',
|
||||
current_tag: '8.8.0',
|
||||
next_tag: '8.8.0',
|
||||
has_update: false,
|
||||
semver_bump: 'none',
|
||||
check_error: 'Could not verify digest',
|
||||
}],
|
||||
summary: {
|
||||
has_update: false,
|
||||
primary_image: 'redis:8.8.0',
|
||||
current_tag: '8.8.0',
|
||||
next_tag: '8.8.0',
|
||||
semver_bump: 'none',
|
||||
update_kind: 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
rebuild_available: false,
|
||||
verification_failed: true,
|
||||
verification_error: 'Could not verify digest',
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => null });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
expect(await screen.findByText(/could not be checked/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('redis')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Could not verify digest/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Apply now/i })).toBeNull();
|
||||
expect(screen.getByText('No verified updates')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Everything is up to date/)).toBeNull();
|
||||
expect(screen.getByText('No verified updates pending')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ready to apply automatically/)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps actionable cards while dropping verification-only stacks to the advisory', async () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ '1': { web: true, redis: true } }),
|
||||
});
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ([
|
||||
{
|
||||
id: 1, enabled: true, action: 'update', target_type: 'stack',
|
||||
target_id: 'web', node_id: 1, next_run_at: Date.now() + 60_000,
|
||||
},
|
||||
{
|
||||
id: 2, enabled: true, action: 'update', target_type: 'stack',
|
||||
target_id: 'redis', node_id: 1, next_run_at: Date.now() + 60_000,
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
if (url === '/image-updates/detail') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
web: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/stacks/web/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'web',
|
||||
images: [{
|
||||
service: 'web', image: 'nginx:1', current_tag: '1', next_tag: '2',
|
||||
has_update: true, digest_update: true, semver_bump: 'patch', check_status: 'ok', check_error: null,
|
||||
}],
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx:1', current_tag: '1', next_tag: '2',
|
||||
semver_bump: 'patch', update_kind: 'digest', blocked: false, blocked_reason: null,
|
||||
check_status: 'ok', verification_failed: false, verification_error: null,
|
||||
},
|
||||
rollback_target: null, changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/stacks/redis/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'redis',
|
||||
images: [{ service: 'redis', image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0', has_update: false, semver_bump: 'none', check_error: 'verify failed' }],
|
||||
summary: {
|
||||
has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0',
|
||||
semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
rebuild_available: false, verification_failed: true, verification_error: 'verify failed',
|
||||
},
|
||||
rollback_target: null, changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => null });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Apply now/i })).toBeEnabled();
|
||||
expect(screen.getByText(/1 of 1 ready to apply automatically/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not be checked/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/verify failed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops a sticky cleared preview from the card grid without an advisory', async () => {
|
||||
// Sticky fleet still lists the stack, but a successful fresh preview proves
|
||||
// no update/rebuild. The false pending card must disappear.
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ '1': { redis: true } }) });
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ([{
|
||||
id: 1, enabled: true, action: 'update', target_type: 'stack',
|
||||
target_id: 'redis', node_id: 1, next_run_at: Date.now() + 60_000,
|
||||
}]),
|
||||
});
|
||||
}
|
||||
if (url === '/image-updates/detail') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'redis',
|
||||
images: [{
|
||||
service: 'redis', image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0',
|
||||
has_update: false, semver_bump: 'none', check_status: 'ok', check_error: null,
|
||||
}],
|
||||
summary: {
|
||||
has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0',
|
||||
semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null,
|
||||
},
|
||||
rollback_target: null, changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => null });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
expect(await screen.findByText(/Everything is up to date/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/All stacks on current builds/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Apply now/i })).toBeNull();
|
||||
expect(screen.queryByText(/could not be checked/i)).toBeNull();
|
||||
expect(screen.queryByText(/ready to apply automatically/)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a sticky card instead of silently clearing it when a remote sends a legacy preview missing verification_failed entirely', async () => {
|
||||
// Same sticky-fleet shape as the cleared-preview test above, but the
|
||||
// fresh preview response is missing verification_failed/rebuild_available
|
||||
// entirely (an older remote node's shape), so it must not be trusted as
|
||||
// proof the stack is clean.
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ '1': { redis: true } }) });
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url === '/image-updates/detail') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'redis',
|
||||
images: [],
|
||||
summary: {
|
||||
has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0',
|
||||
semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
// verification_failed and rebuild_available intentionally omitted (legacy remote shape).
|
||||
},
|
||||
rollback_target: null, changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => null });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
// Both the retained card and the advisory line name the stack.
|
||||
expect(await screen.findAllByText('redis')).toHaveLength(2);
|
||||
expect(screen.queryByText(/Everything is up to date/)).toBeNull();
|
||||
// The retained card alone doesn't explain itself; the advisory must.
|
||||
expect(screen.getByText(/predates digest verification/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not pair a legacy preview\'s own confirmed update with a contradictory "could not be checked" advisory', async () => {
|
||||
// The remote DID check and confirmed an update via its own (older) logic;
|
||||
// flagging it as unchecked right next to an enabled Apply button would
|
||||
// contradict the card sitting beside it.
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ '1': { redis: true } }) });
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
if (url === '/image-updates/detail') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
redis: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'redis',
|
||||
images: [],
|
||||
summary: {
|
||||
has_update: true, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.1',
|
||||
semver_bump: 'patch', update_kind: 'digest', blocked: false, blocked_reason: null,
|
||||
// check_status, verification_failed, and rebuild_available intentionally omitted (legacy remote shape).
|
||||
},
|
||||
rollback_target: null, changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => null });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Apply now/i })).toBeEnabled();
|
||||
expect(screen.queryByText(/predates digest verification/i)).toBeNull();
|
||||
expect(screen.queryByText(/could not be checked/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('labels remote verification-only stacks with the node name in the advisory', async () => {
|
||||
mockNodes.splice(0, mockNodes.length,
|
||||
{ id: 1, name: 'Local', type: 'local', status: 'online' },
|
||||
{ id: 2, name: 'Edge', type: 'remote', status: 'online' },
|
||||
);
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ '2': { redis: true } }),
|
||||
});
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
// Local-only detail cannot see remote sticky failures; preview must move them.
|
||||
if (url === '/image-updates/detail') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string, nodeId: number) => {
|
||||
if (nodeId === 2 && String(url).includes('/update-preview')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stack_name: 'redis',
|
||||
images: [{
|
||||
service: 'redis', image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0',
|
||||
has_update: false, semver_bump: 'none', check_error: 'Could not verify digest',
|
||||
}],
|
||||
summary: {
|
||||
has_update: false, primary_image: 'redis:8.8.0', current_tag: '8.8.0', next_tag: '8.8.0',
|
||||
semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
rebuild_available: false, verification_failed: true, verification_error: 'Could not verify digest',
|
||||
},
|
||||
rollback_target: null, changelog: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => null });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
expect(await screen.findByText(/could not be checked/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('redis (Edge)')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Apply now/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user