Files
sencho/e2e/git-sources.spec.ts
T
Anso 00901cf5bf fix(git-sources): harden validation, RBAC, concurrency, and deploy recovery (#603)
* fix(git-sources): harden validation, RBAC, concurrency, and deploy recovery

Tightens the surface area around the Git source feature:

- Enforce HTTPS-only repo URLs server-side (regex was permissive).
- Add stack:read permission check on git-source reads and filter the
  list endpoint by callable permission.
- Validate stack names before permission checks on mutation routes so
  scoped lookups never see unvalidated input.
- Cap repo_url / branch / compose_path / env_path / token lengths and
  require the stack directory to exist before upsert.
- Wrap pull() in the per-stack mutex to eliminate the pull/delete race
  that could orphan pending data.
- Block .git/ path components in compose_path / env_path so a
  misconfigured clone cannot leak repo metadata.
- Return {applied, deployed, deployError?} on deploy failure instead of
  throwing, and surface deployError as a warning toast so the user can
  retry deploy without re-pulling.
- Always clean the stack_git_sources row on stack delete even when the
  file deletion step fails.
- Add shadow-card-bevel to the pending alert and metadata card per the
  design system.
- Handle the new 403 response on the panel fetch gracefully.
- Add diagnostic logging gated on developer_mode (isDebugEnabled) across
  fetch / pull / apply / webhook paths with credential scrubbing.

* test(git-sources): expand coverage for hardening and route validation

- New route-level suite covers HTTPS enforcement, required fields,
  max-length caps on repo_url / branch / compose_path / env_path /
  token, the stack-existence 404 guard, and GET authz.
- Service tests cover the .git metadata guard on compose and env
  paths (including nested and substring-containing "git"), pull and
  apply rejections when no source is configured or pending is
  cleared, the sha-mismatch branch, and the deploy-failure return
  shape that now carries deployError.
- E2E adds three server-side contract assertions: PUT against a
  missing stack returns 404, http:// is rejected with 400, and
  .git/config is rejected as compose_path.

* docs(git-sources): document deploy-failure recovery path

Adds a Troubleshooting entry explaining that when apply succeeds but
the subsequent deploy fails, the compose content is already on disk
and the user can retry deploy from the stack editor without
re-pulling.

* docs(git-sources): add configuration, diff, pending, and webhook screenshots
2026-04-14 22:32:42 -04:00

201 lines
7.7 KiB
TypeScript

/**
* Git Sources E2E - configure, save, pull, remove.
*
* These tests use a throwaway stack that is created via the browser's
* authenticated fetch (so cookies are carried) and cleaned up in afterAll.
* Pull tests use an unreachable URL on purpose so the suite does not depend
* on real network egress or a specific upstream repo being available.
*/
import { test, expect, Page } from '@playwright/test';
import { loginAs } from './helpers';
const TEST_STACK = 'e2e-git-source-stack';
async function createTestStackViaApi(page: Page) {
return page.evaluate(async (name) => {
const res = await fetch(`/api/stacks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
return res.status;
}, TEST_STACK);
}
async function deleteTestStackViaApi(page: Page) {
await page.evaluate(async (name) => {
// Drop any orphaned git-source row first (safe even if the stack is already gone).
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, TEST_STACK);
}
async function openGitSourcePanel(page: Page) {
await page.getByText(TEST_STACK).first().click();
const gitBtn = page.getByRole('button', { name: /Git Source/i });
await expect(gitBtn).toBeVisible({ timeout: 10_000 });
await gitBtn.click();
await expect(page.getByRole('dialog').getByText('Git Source', { exact: false })).toBeVisible({ timeout: 5_000 });
}
test.describe('Git Sources', () => {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await loginAs(page);
await deleteTestStackViaApi(page);
await createTestStackViaApi(page);
await page.close();
});
test.afterAll(async ({ browser }) => {
const page = await browser.newPage();
await loginAs(page);
await deleteTestStackViaApi(page);
await page.close();
});
test.beforeEach(async ({ page }) => {
await loginAs(page);
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
});
test('rejects non-HTTPS repository URLs client-side', async ({ page }) => {
await openGitSourcePanel(page);
await page.locator('#git-source-repo').fill('git@github.com:org/repo.git');
await page.locator('#git-source-branch').fill('main');
await page.locator('#git-source-path').fill('compose.yaml');
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
await expect(page.getByText(/Only HTTPS repository URLs are supported/i)).toBeVisible({ timeout: 5_000 });
});
test('surfaces reachability error on save with unreachable repo', async ({ page }) => {
await openGitSourcePanel(page);
// Use a URL that resolves but returns 404 for the git protocol so the dry-run
// fetch fails with a clean error. reserved-TLDs like .invalid trigger DNS failure
// which maps to NETWORK_TIMEOUT or REPO_NOT_FOUND.
await page.locator('#git-source-repo').fill('https://git.invalid.example/nope/nope.git');
await page.locator('#git-source-branch').fill('main');
await page.locator('#git-source-path').fill('compose.yaml');
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
// Any of the mapped error messages is acceptable; the key is that nothing
// persisted silently and the user sees a toast.
await expect(
page.getByText(/not found|unreachable|network|timeout|authentication failed/i).first(),
).toBeVisible({ timeout: 15_000 });
});
test('PUT against a non-existent stack returns 404', async ({ page }) => {
const status = await page.evaluate(async () => {
const res = await fetch(`/api/stacks/nonexistent-ghost-stack/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
});
expect(status).toBe(404);
});
test('backend rejects http:// URLs on PUT with 400', async ({ page }) => {
const status = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'http://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
}, TEST_STACK);
expect(status).toBe(400);
});
test('backend rejects .git/config as compose_path', async ({ page }) => {
const body = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: '.git/config',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return { status: res.status, body: await res.json().catch(() => ({})) };
}, TEST_STACK);
expect(body.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(body.body)).toMatch(/\.git|file/i);
});
test('configure, view pending-empty state, and remove via AlertDialog', async ({ page }) => {
// Seed a git source directly via API so we can exercise the remove-confirm
// flow without depending on a reachable upstream.
const putStatus = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'https://github.com/docker/awesome-compose.git',
branch: 'master',
compose_path: 'nginx-golang/compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
}, TEST_STACK);
// Either the dry-run succeeded (2xx) or the network blocked it (4xx/5xx).
// If it failed, skip the rest of the remove flow to keep the suite robust.
if (putStatus >= 400) {
test.skip(true, `Upstream dry-run returned ${putStatus}; skipping remove path`);
return;
}
await openGitSourcePanel(page);
// Source should render with the saved repo URL.
await expect(page.locator('#git-source-repo')).toHaveValue(/awesome-compose/);
// Click Remove → AlertDialog appears → confirm → source cleared.
await page.getByRole('dialog').getByRole('button', { name: /Remove/i }).click();
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 });
await page.getByRole('alertdialog').getByRole('button', { name: /^Remove$/ }).click();
// After removal, the "Remove" button is gone from the panel footer.
await expect(page.getByRole('dialog').getByRole('button', { name: /^Remove$/ })).not.toBeVisible({ timeout: 5_000 });
});
});