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) {