From ae0b9d166c63e6009c62645fc76c674a6bf375a3 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 2 Jun 2026 22:02:49 -0400 Subject: [PATCH] fix(git-sources): return 200 for stacks without a Git source (#1294) The dashboard probes GET /api/stacks//git-source for every stack to decide whether to show a Git badge. For a stack with no Git source attached the endpoint answered 404, so a fleet of unlinked stacks painted a red 404 per stack in the browser console. Return 200 { linked: false } when the stack exists but has no Git source, and reserve 404 for the genuine "stack does not exist" case (mirroring the existence guard the PUT handler already uses). The two consumers that read this endpoint now treat the { linked: false } sentinel as unlinked rather than as a configured source. --- .../src/__tests__/git-source-routes.test.ts | 35 +++++++ backend/src/routes/gitSources.ts | 16 +++- .../src/components/StackAnatomyPanel.test.tsx | 37 ++++++++ frontend/src/components/StackAnatomyPanel.tsx | 8 +- .../components/stack/GitSourcePanel.test.tsx | 94 +++++++++++++++++++ .../src/components/stack/GitSourcePanel.tsx | 45 +++++---- 6 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/stack/GitSourcePanel.test.tsx diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index 1d6b3bc8..6b683ace 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -253,6 +253,41 @@ describe('git-source routes — invalid stack names', () => { }); }); +describe('GET /api/stacks/:stackName/git-source', () => { + it('returns 200 { linked: false } when the stack exists but has no Git source', async () => { + const composeDir = process.env.COMPOSE_DIR!; + fs.mkdirSync(path.join(composeDir, 'unlinked-stack'), { recursive: true }); + fs.writeFileSync(path.join(composeDir, 'unlinked-stack', 'compose.yaml'), 'services:\n x:\n image: nginx\n'); + const res = await request(app) + .get('/api/stacks/unlinked-stack/git-source') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ linked: false }); + }); + + it('returns 404 when the stack does not exist on the active node', async () => { + const res = await request(app) + .get('/api/stacks/ghost-stack-get/git-source') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/stack not found/i); + }); + + it('returns 200 with the source object when a Git source is configured', async () => { + const composeDir = process.env.COMPOSE_DIR!; + fs.mkdirSync(path.join(composeDir, 'linked-stack'), { recursive: true }); + fs.writeFileSync(path.join(composeDir, 'linked-stack', 'compose.yaml'), 'services:\n x:\n image: nginx\n'); + seedGitSource('linked-stack'); + const res = await request(app) + .get('/api/stacks/linked-stack/git-source') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(res.body.stack_name).toBe('linked-stack'); + expect(res.body.repo_url).toBe('https://github.com/example/repo.git'); + expect(res.body.linked).toBeUndefined(); + }); +}); + describe('POST /api/stacks/from-git', () => { const validBody = { stack_name: 'route-from-git', diff --git a/backend/src/routes/gitSources.ts b/backend/src/routes/gitSources.ts index 9afae821..419bdb46 100644 --- a/backend/src/routes/gitSources.ts +++ b/backend/src/routes/gitSources.ts @@ -49,11 +49,21 @@ stackGitSourceRouter.get('/:stackName/git-source', async (req: Request, res: Res if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; try { const source = GitSourceService.getInstance().get(stackName); - if (!source) { - res.status(404).json({ error: 'No Git source configured for this stack' }); + if (source) { + res.json(source); return; } - res.json(source); + // No source row. A non-existent stack is a genuine 404, but an existing + // stack with no Git source attached is a normal, non-error state. The + // dashboard probes this endpoint for every stack, so returning 404 here + // would paint a console error for every unlinked stack; answer 200 with + // a discriminator instead and reserve 404 for the stack-not-found case. + const stacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + if (!stacks.includes(stackName)) { + res.status(404).json({ error: 'Stack not found' }); + return; + } + res.json({ linked: false }); } catch (error) { sendGitSourceError(res, error); } diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index e93d57c7..611f5d20 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -221,6 +221,43 @@ describe('StackAnatomyPanel update banner', () => { 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((r) => { resolveStale = r; }); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 4d62797b..0f541d09 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -265,7 +265,13 @@ export default function StackAnatomyPanel({ if (cancelled) return; if (res.ok) { const data = await res.json(); - setGitSource({ repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path }); + // An unlinked stack answers 200 { linked: false }; only render the + // badge when an actual source is attached. + if (data && data.linked === false) { + setGitSource(null); + } else { + setGitSource({ repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path }); + } } else { setGitSource(null); } diff --git a/frontend/src/components/stack/GitSourcePanel.test.tsx b/frontend/src/components/stack/GitSourcePanel.test.tsx new file mode 100644 index 00000000..60e92ed3 --- /dev/null +++ b/frontend/src/components/stack/GitSourcePanel.test.tsx @@ -0,0 +1,94 @@ +/** + * Covers the panel's load path for the unlinked-stack contract: when the + * backend answers 200 { linked: false } (an existing stack with no Git source + * attached), the form must land in the empty/unlinked state rather than + * treating the sentinel as a configured source. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/context/DeployFeedbackContext', () => ({ + useDeployFeedback: () => ({ runWithLog: vi.fn() }), +})); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + loading: vi.fn(() => 'toast-id'), + dismiss: vi.fn(), + }, +})); + +import { apiFetch } from '@/lib/api'; +import { GitSourcePanel } from './GitSourcePanel'; + +function jsonRes(body: unknown, ok = true, status = 200) { + return { ok, status, json: async () => body, text: async () => '' } as unknown as Response; +} + +const LINKED_SOURCE = { + id: 1, + stack_name: 'web', + repo_url: 'https://github.com/org/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + sync_env: false, + env_path: null, + auth_type: 'none' as const, + has_token: false, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + last_applied_commit_sha: null, + pending_commit_sha: null, + pending_fetched_at: null, + created_at: 0, + updated_at: 0, +}; + +function panel() { + return ( + + ); +} + +beforeEach(() => { + vi.mocked(apiFetch).mockReset(); +}); + +describe('GitSourcePanel load', () => { + it('treats a 200 { linked: false } response as the empty/unlinked state', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ linked: false })); + + render(panel()); + + // Save (not Update) and no Pull now / Remove affordances means the panel + // did not mistake the { linked: false } sentinel for a configured source. + await screen.findByRole('button', { name: /^save$/i }); + expect(screen.queryByRole('button', { name: /update/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /pull now/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /remove/i })).not.toBeInTheDocument(); + expect(screen.getByLabelText(/repository url/i)).toHaveValue(''); + }); + + it('renders the configured source when one is attached', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes(LINKED_SOURCE)); + + render(panel()); + + // A real source flips the primary action to Update and exposes Pull now / Remove. + await screen.findByRole('button', { name: /update/i }); + expect(screen.getByRole('button', { name: /pull now/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByLabelText(/repository url/i)).toHaveValue('https://github.com/org/repo.git'), + ); + }); +}); diff --git a/frontend/src/components/stack/GitSourcePanel.tsx b/frontend/src/components/stack/GitSourcePanel.tsx index 5b037c6e..3b00dbbb 100644 --- a/frontend/src/components/stack/GitSourcePanel.tsx +++ b/frontend/src/components/stack/GitSourcePanel.tsx @@ -76,29 +76,38 @@ export function GitSourcePanel({ const { runWithLog } = useDeployFeedback(); const applyMode = deriveApplyMode(source, applyModeOverride); + const resetToUnlinked = useCallback(() => { + setSource(null); + setRepoUrl(''); + setBranch('main'); + setComposePath('compose.yaml'); + setSyncEnv(false); + setAuthType('none'); + setToken(''); + setApplyModeOverride(null); + }, []); + const load = useCallback(async () => { setLoading(true); try { const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`); if (res.ok) { - const data: GitSource = await res.json(); - setSource(data); - setRepoUrl(data.repo_url); - setBranch(data.branch); - setComposePath(data.compose_path); - setSyncEnv(data.sync_env); - setAuthType(data.auth_type); - setToken(''); - setApplyModeOverride(null); + const data: GitSource | { linked: false } = await res.json(); + // An existing stack with no Git source attached answers 200 { linked: false }. + if ('linked' in data) { + resetToUnlinked(); + } else { + setSource(data); + setRepoUrl(data.repo_url); + setBranch(data.branch); + setComposePath(data.compose_path); + setSyncEnv(data.sync_env); + setAuthType(data.auth_type); + setToken(''); + setApplyModeOverride(null); + } } else if (res.status === 404) { - setSource(null); - setRepoUrl(''); - setBranch('main'); - setComposePath('compose.yaml'); - setSyncEnv(false); - setAuthType('none'); - setToken(''); - setApplyModeOverride(null); + resetToUnlinked(); } else if (res.status === 403) { setSource(null); toast.error('You do not have permission to view this stack\'s Git source.'); @@ -111,7 +120,7 @@ export function GitSourcePanel({ } finally { setLoading(false); } - }, [stackName]); + }, [stackName, resetToUnlinked]); useEffect(() => { if (open) {