Files
sencho/frontend/src/components/StackAnatomyPanel.test.tsx
T
Anso 6688da97b1 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.
2026-07-25 21:57:30 -04:00

714 lines
30 KiB
TypeScript

/**
* Covers the "Update available" banner lifecycle: the apply button must reflect
* the in-flight update (disabled + progress label), and the banner must clear
* itself once the update lands (re-checking the preview) while staying put if
* the update did not take effect.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('./stack/StackActivityTimeline', () => ({
StackActivityTimeline: () => <div data-testid="activity-timeline" />,
}));
// This suite covers the update banner, not the Doctor tab; keep the capability
// off so the panel surface stays exactly what these tests assert against. The
// active node is mutable so the footer-link tests can simulate a remote node.
const { nodeState } = vi.hoisted(() => ({ nodeState: { activeNode: { id: 1 } as unknown } }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: nodeState.activeNode, hasCapability: () => false }) }));
import { apiFetch } from '@/lib/api';
import StackAnatomyPanel from './StackAnatomyPanel';
const COMPOSE = 'services:\n web:\n image: nginx:1.25\n';
function previewBody(
hasUpdate: boolean,
buildServices: string[] = [],
over: {
verification_failed?: boolean;
verification_error?: string | null;
check_error?: string | null;
blocked?: boolean;
blocked_reason?: string | null;
} = {},
) {
const hasBuild = buildServices.length > 0;
const verificationFailed = over.verification_failed ?? false;
return {
build_services: buildServices,
images: [
{
service: 'web',
image: 'nginx:1.25',
current_tag: '1.25',
next_tag: '1.25',
has_update: hasUpdate,
digest_update: hasUpdate,
tag_update: false,
semver_bump: hasUpdate ? 'patch' : 'none',
check_status: verificationFailed ? 'failed' : 'ok',
check_error: over.check_error ?? null,
},
],
summary: {
has_update: hasUpdate,
primary_image: 'nginx',
current_tag: '1.25',
next_tag: '1.25',
semver_bump: hasUpdate ? 'patch' : 'none',
update_kind: hasUpdate ? 'digest' : 'none',
blocked: over.blocked ?? false,
blocked_reason: over.blocked_reason ?? null,
has_build_services: hasBuild,
rebuild_available: hasBuild,
check_status: verificationFailed ? 'failed' : 'ok',
verification_failed: verificationFailed,
verification_error: over.verification_error ?? null,
},
changelog: null,
};
}
/** Two-service preview: `web` confirms a digest update, `db` fails digest verification. */
function mixedPreviewBody(over: { blocked?: boolean; blocked_reason?: string | null } = {}) {
return {
build_services: [],
images: [
{
service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25',
has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch',
check_status: 'ok', check_error: null,
},
{
service: 'db', image: 'private.example/db:latest', current_tag: 'latest', next_tag: null,
has_update: false, digest_update: false, tag_update: false, semver_bump: 'none',
check_status: 'failed', check_error: 'Registry unreachable', digest_error: 'Registry unreachable',
},
],
summary: {
has_update: true,
primary_image: 'nginx',
current_tag: '1.25',
next_tag: '1.25',
semver_bump: 'patch',
update_kind: 'digest',
blocked: over.blocked ?? false,
blocked_reason: over.blocked_reason ?? null,
has_build_services: false,
rebuild_available: false,
check_status: 'partial',
verification_failed: true,
verification_error: 'Registry unreachable',
},
changelog: null,
};
}
function jsonRes(body: unknown, ok = true) {
return { ok, status: ok ? 200 : 404, json: async () => body, text: async () => '' } as unknown as Response;
}
let hasUpdate = true;
const updatePreviewCalls = () =>
vi.mocked(apiFetch).mock.calls.filter(([input]) => String(input).includes('/update-preview')).length;
beforeEach(() => {
hasUpdate = true;
nodeState.activeNode = { id: 1 };
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(hasUpdate));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false); // git-source and anything else: nothing to show
});
});
function panel(applying: boolean, onApplyUpdate: () => void = vi.fn(), stackName = 'web') {
return (
<StackAnatomyPanel
stackName={stackName}
content={COMPOSE}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={onApplyUpdate}
canEdit
applying={applying}
/>
);
}
describe('StackAnatomyPanel edit affordance', () => {
it('renders Edit compose with a stable test id when canEdit is true', () => {
render(panel(false));
expect(screen.getByTestId('anatomy-edit-compose-btn')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit compose/i })).toBeInTheDocument();
});
it('hides Edit compose when canEdit is false', () => {
render(
<StackAnatomyPanel
stackName="web"
content={COMPOSE}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={vi.fn()}
canEdit={false}
applying={false}
/>,
);
expect(screen.queryByTestId('anatomy-edit-compose-btn')).not.toBeInTheDocument();
});
});
describe('StackAnatomyPanel update banner', () => {
it('hides apply when only a newer tag is available', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes({
build_services: [],
images: [{
service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26',
has_update: true, digest_update: false, tag_update: true, semver_bump: 'minor', check_status: 'ok',
}],
summary: {
has_update: true, primary_image: 'nginx', current_tag: '1.25', next_tag: '1.26',
semver_bump: 'minor', update_kind: 'tag', blocked: false, blocked_reason: null,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
changelog: null,
});
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
await waitFor(() => expect(screen.getByTestId('update-available-banner')).toBeInTheDocument());
expect(screen.getByText((t) => typeof t === 'string' && t.includes('newer tag') && t.includes('edit Compose pin'))).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'apply' })).not.toBeInTheDocument();
});
it('shows the apply button and fires onApplyUpdate when clicked', async () => {
const onApply = vi.fn();
render(panel(false, onApply));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText('nginx')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'apply' }));
expect(onApply).toHaveBeenCalledTimes(1);
});
it('names each updated image on multi-service stacks', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes({
build_services: [],
images: [
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.2', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, digest_update: false, tag_update: false, semver_bump: 'none', check_status: 'ok' },
],
summary: {
has_update: true,
primary_image: 'nginx:1.25',
current_tag: '1.25',
next_tag: '1.26',
semver_bump: 'minor',
update_kind: 'tag',
blocked: false,
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
},
changelog: null,
});
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText('nginx')).toBeInTheDocument();
expect(screen.getByText('redis')).toBeInTheDocument();
expect(screen.queryByText('postgres')).not.toBeInTheDocument();
});
it('shows Rebuild & Update for build-only stacks', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(false, ['app']));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText(/Rebuild available/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Rebuild & Update' })).toBeInTheDocument();
});
it('disables the apply button and shows progress while applying', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply));
const btn = screen.getByRole('button', { name: /applying/i });
expect(btn).toBeDisabled();
});
it('clears the banner after the update lands', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply)); // update in flight
hasUpdate = false; // backend now reports the stack is current
rerender(panel(false, onApply)); // apply finished
await waitFor(() =>
expect(screen.queryByTestId('update-available-banner')).not.toBeInTheDocument(),
);
});
it('keeps the banner if the update did not take effect', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(true, onApply));
rerender(panel(false, onApply)); // hasUpdate stays true: still an update pending
await waitFor(() => expect(updatePreviewCalls()).toBeGreaterThan(before));
expect(screen.getByTestId('update-available-banner')).toBeInTheDocument();
});
it('does not re-check the preview while applying stays false', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(false, onApply));
rerender(panel(false, onApply));
await Promise.resolve();
expect(updatePreviewCalls()).toBe(before); // re-check fires only on the true -> false edge
});
it('does not fire onApplyUpdate while the apply button is disabled', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply));
fireEvent.click(screen.getByRole('button', { name: /applying/i }));
expect(onApply).not.toHaveBeenCalled();
});
it('keeps the banner when the post-apply re-check returns a non-OK response', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
let updateCalls = 0;
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
updateCalls += 1;
return updateCalls >= 2 ? jsonRes(null, false) : jsonRes(previewBody(true));
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(true, onApply));
rerender(panel(false, onApply)); // re-check returns not-ok: keep the known banner
await waitFor(() => expect(updatePreviewCalls()).toBeGreaterThan(before));
expect(screen.getByTestId('update-available-banner')).toBeInTheDocument();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('keeps the banner when the post-apply re-check throws', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
let updateCalls = 0;
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
updateCalls += 1;
if (updateCalls >= 2) throw new Error('network down');
return jsonRes(previewBody(true));
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(true, onApply));
rerender(panel(false, onApply)); // re-check throws: keep the known banner
await waitFor(() => expect(updatePreviewCalls()).toBeGreaterThan(before));
expect(screen.getByTestId('update-available-banner')).toBeInTheDocument();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('does not re-check after switching stacks while the first is still applying', async () => {
const calls: string[] = [];
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
calls.push(url);
return jsonRes(previewBody(!url.includes('/stacks/other/'))); // web has an update, other does not
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
const otherCalls = () => calls.filter((u) => u.includes('/stacks/other/update-preview')).length;
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply, 'web'));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply, 'web')); // web applying
rerender(panel(false, onApply, 'other')); // switch stacks before web's apply finishes
await waitFor(() => expect(otherCalls()).toBe(1)); // only the stack-change mount fetch
await Promise.resolve();
expect(otherCalls()).toBe(1); // the apply-completion re-check must not fire for "other"
});
it('renders the git badge when a source is attached', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(false));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
if (url.includes('/git-source')) {
return jsonRes({ repo_url: 'https://github.com/org/repo.git', branch: 'main', compose_path: 'compose.yaml' });
}
return jsonRes(null, false);
});
render(panel(false));
// Positive control: a linked stack shows the "git · host/repo#branch" badge,
// which proves the matcher used by the unlinked test below is real.
await screen.findByText(/github\.com\/org\/repo#main/);
expect(screen.queryByText('local')).not.toBeInTheDocument();
});
it('treats a 200 { linked: false } git-source response as unlinked', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(true));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
if (url.includes('/git-source')) return jsonRes({ linked: false }); // 200, no source attached
return jsonRes(null, false);
});
render(panel(false));
// The git-source effect runs before the banner effect, so by the time the
// banner renders the git-source response has been applied. An unlinked
// stack must keep the "local" label, not flip to a git badge.
await screen.findByTestId('update-available-banner');
expect(screen.getByText('local')).toBeInTheDocument();
});
it('ignores a stale re-check that resolves after the stack changed', async () => {
let resolveStale!: (r: Response) => void;
const stale = new Promise<Response>((r) => { resolveStale = r; });
const webResponses: Response[] = [jsonRes(previewBody(true))]; // mount: web has an update
vi.mocked(apiFetch).mockImplementation((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/stacks/web/update-preview')) {
const next = webResponses.shift();
return next ? Promise.resolve(next) : stale; // post-apply re-check for web hangs
}
if (url.includes('/stacks/other/update-preview')) return Promise.resolve(jsonRes(previewBody(false)));
if (url.includes('/scan-status')) return Promise.resolve(jsonRes({ status: 'ok' }));
return Promise.resolve(jsonRes(null, false));
});
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply, 'web'));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply, 'web'));
rerender(panel(false, onApply, 'web')); // re-check fires and hangs
rerender(panel(false, onApply, 'other')); // switch stacks: cleanup cancels the hung re-check
await waitFor(() =>
expect(screen.queryByTestId('update-available-banner')).not.toBeInTheDocument(),
);
resolveStale(jsonRes(previewBody(true))); // late stale result for web must be dropped
await Promise.resolve();
expect(screen.queryByTestId('update-available-banner')).not.toBeInTheDocument();
});
});
describe('StackAnatomyPanel exposed footer', () => {
function renderWithPorts(content: string) {
return render(
<StackAnatomyPanel
stackName="web"
content={content}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={vi.fn()}
canEdit
applying={false}
/>,
);
}
it('renders the exposed port as a real link for a published port', async () => {
renderWithPorts('services:\n web:\n image: x\n ports:\n - "8989:8989"\n');
const link = await screen.findByRole('link', { name: /:8989/ });
expect(link).toHaveAttribute('href', 'http://localhost:8989');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('does not render a link for a container-only port', async () => {
renderWithPorts('services:\n web:\n image: x\n ports:\n - "80"\n');
await screen.findByText('exposed');
expect(screen.queryByRole('link', { name: /:\d+/ })).toBeNull();
});
it('shows the port as plain text (no link) on a remote node with no reachable host', async () => {
nodeState.activeNode = { id: 9, type: 'remote', api_url: '' };
renderWithPorts('services:\n web:\n image: x\n ports:\n - "8989:8989"\n');
expect(await screen.findByText(/:8989/)).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /:8989/ })).toBeNull();
});
});
describe('StackAnatomyPanel effective dossier (multi-file Git)', () => {
const ROOT_NO_PORTS = 'services:\n web:\n image: nginx:1.25\n';
function renderPanel(content = ROOT_NO_PORTS) {
return render(
<StackAnatomyPanel
stackName="web"
content={content}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={vi.fn()}
canEdit
applying={false}
/>,
);
}
it('reads override-published ports from the effective model, so the dossier shows them and doc-drift does not false-warn', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(false));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
// Multi-file source: two configured compose paths.
if (url.includes('/git-source')) return jsonRes({
repo_url: 'https://github.com/org/repo.git', branch: 'main',
compose_path: 'compose.yaml', compose_paths: ['compose.yaml', 'infra/override.yaml'],
});
// An override publishes :9000, absent from the root file above.
if (url.includes('/effective-anatomy')) return jsonRes({
renderable: true, services: ['web'],
ports: { web: [{ host: '9000', container: '9000', proto: 'tcp', published: true }] },
volumes: {}, restart: null, networks: ['default'],
});
// The operator documented the override's port.
if (url.includes('/dossier')) return jsonRes({ access_urls: 'http://192.168.1.5:9000' });
return jsonRes(null, false);
});
renderPanel();
await userEvent.click(await screen.findByRole('tab', { name: 'Dossier' }));
await screen.findByTestId('dossier-panel');
// The generated-facts ports row counts the override-published port, proving the
// dossier read the merged effective model rather than the port-less root file.
// (Scoped to the SPAN so it does not also match the access_urls value below.)
await screen.findByText((content, el) => el?.tagName === 'SPAN' && content.startsWith('1 published'));
// And doc-drift stays silent: the documented :9000 is published in the effective
// model, so a root-only parse would false-warn here but the effective view must not.
await waitFor(() => expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument());
expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/effective-anatomy'))).toBe(true);
});
it('does not fetch the effective model for a single-file Git stack', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(false));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
if (url.includes('/git-source')) return jsonRes({
repo_url: 'https://github.com/org/repo.git', branch: 'main',
compose_path: 'compose.yaml', compose_paths: ['compose.yaml'],
});
if (url.includes('/dossier')) return jsonRes({});
return jsonRes(null, false);
});
renderPanel();
await userEvent.click(await screen.findByRole('tab', { name: 'Dossier' }));
await screen.findByText(/github\.com\/org\/repo#main/);
// Give any (incorrect) effective fetch a chance to fire before asserting absence.
await waitFor(() => expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/git-source'))).toBe(true));
expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/effective-anatomy'))).toBe(false);
});
});
describe('StackAnatomyPanel capability gating (capability off)', () => {
it('hides the Networking, Doctor, and Storage tabs when the capabilities are absent', async () => {
render(panel(false));
// The always-on Anatomy tab confirms the panel mounted.
expect(await screen.findByRole('tab', { name: 'Anatomy' })).toBeInTheDocument();
expect(screen.queryByTestId('networking-tab')).not.toBeInTheDocument();
expect(screen.queryByTestId('doctor-tab')).not.toBeInTheDocument();
expect(screen.queryByTestId('storage-tab')).not.toBeInTheDocument();
});
});
describe('StackAnatomyPanel digest verification failure', () => {
it('shows an update-check-status banner instead of an update claim when digest check errors', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes(previewBody(false, [], {
verification_failed: true,
verification_error: 'Registry unreachable',
check_error: 'Registry unreachable',
}));
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByTestId('update-check-status-banner')).toBeInTheDocument();
expect(screen.queryByTestId('update-available-banner')).toBeNull();
expect(screen.getByText(/Registry unreachable/)).toBeInTheDocument();
});
it('does not claim "safe to apply" and withholds the full-stack Apply button when a confirmed update sits alongside a DIFFERENT image failing verification', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(mixedPreviewBody());
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
// A confirmed update alongside a different image's failure renders only
// the update banner (mutually exclusive with the check-status banner);
// the review-required hold is inline in that banner's lead-in text.
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.queryByTestId('update-check-status-banner')).toBeNull();
expect(screen.getByText(/review required/i)).toBeInTheDocument();
expect(screen.queryByText(/safe to apply/i)).toBeNull();
expect(screen.queryByRole('button', { name: /^apply$/i })).toBeNull();
});
it('keeps a single image with a confirmed digest update fully actionable when there is no digest error anywhere in the stack', async () => {
// 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.
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(true));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText(/same-tag digest rebuild/i)).toBeInTheDocument();
expect(screen.queryByText(/review required/i)).toBeNull();
expect(screen.getByRole('button', { name: /^apply$/i })).toBeEnabled();
});
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', async () => {
// The db 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.
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes({
build_services: [],
images: [
{
service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25',
has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch',
check_status: 'ok', check_error: null, digest_error: null,
},
{
service: 'db', image: 'private.example/db:latest', current_tag: '2.0', next_tag: '2.1',
has_update: true, digest_update: false, tag_update: true, semver_bump: 'minor',
check_status: 'ok', check_error: null, digest_error: 'Registry unreachable',
},
],
summary: {
has_update: true, primary_image: 'nginx', current_tag: '1.25', next_tag: '1.25',
semver_bump: 'patch', update_kind: 'digest', blocked: false, blocked_reason: null,
has_build_services: false, rebuild_available: false,
check_status: 'ok', verification_failed: false, verification_error: null,
},
changelog: null,
});
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText(/review required/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^apply$/i })).toBeNull();
});
it('keeps the blocked (major-bump policy) banner precedence over the verification-failure review-required banner', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes(mixedPreviewBody({ blocked: true, blocked_reason: 'Major version bump' }));
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByText(/review required/i)).toBeInTheDocument();
expect(screen.getByText(/Major version bump/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^apply$/i })).toBeNull();
});
});