chore: merge main into fix/sso-role-sync-granular

This commit is contained in:
Anso
2026-09-09 09:03:48 -04:00
255 changed files with 19080 additions and 1774 deletions
+6 -5
View File
@@ -44,6 +44,12 @@ API_POLLING_RATE_LIMIT=300
# trailing slash. When unset, enrollment falls back to the request Host.
SENCHO_PUBLIC_URL=
# Comma-separated CIDRs of reverse proxies trusted to set forwarding headers.
# Set a single IPv4 proxy as a /32, a single IPv6 proxy as a /128, or use the
# proxy network CIDR when Sencho is behind one (for example, 192.168.1.50/32).
# Unset or invalid values make Sencho ignore forwarded client and scheme data.
SENCHO_TRUSTED_PROXY_CIDRS=
# ─── Pilot agent (remote host only) ──────────────────────────────
# These three vars are required ONLY on a remote host running as a
# pilot-agent reverse-tunnel container. The primary instance does not
@@ -53,11 +59,6 @@ SENCHO_PUBLIC_URL=
# this unset.
SENCHO_MODE=
# Comma-separated CIDRs of reverse proxies trusted to set X-Forwarded-Proto
# for Pilot Agent TLS termination. Unset or invalid: non-TLS Pilot upgrades are
# treated as non-confidential and hub registry credential delivery is skipped.
SENCHO_TRUSTED_PROXY_CIDRS=
# WebSocket-capable URL of the controlling Sencho instance. Use https://
# scheme; the agent rewrites it to wss:// for the tunnel upgrade.
SENCHO_PRIMARY_URL=
+1
View File
@@ -77,6 +77,7 @@ runs:
COMPOSE_DIR: ${{ inputs.compose-dir }}
PORT: ${{ inputs.port }}
NODE_ENV: test
SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND: 'true'
- name: Start frontend dev server
shell: bash
+25
View File
@@ -20,6 +20,31 @@ paths-ignore:
# path scoping is ignored for that query, so the dedicated sink module is
# excluded from JS analysis instead (see codeql-config comment on e2e above).
- backend/src/services/git/sshCredentialFiles.ts
# redirectPreflight walks an HTTPS redirect chain to decide whether git may
# be re-run against the destination, so the operator's configured repository
# URL reaches an outbound https.get and CodeQL flags request forgery. A
# barrier model on the module's own origin-check function (declaring its
# return value clean, the same mechanism sanitizeForLog uses for
# log-injection) was tried first and did not clear this alert: the
# js/request-forgery query does not consult the general dataflow
# barrierModel the way log-injection does. Excluded from JS analysis
# instead, for the same reason as the two sinks below: every URL this
# module requests, including the first, is checked against the configured
# repository's origin (scheme, host, and port) before being requested, so
# the walk cannot reach a host the operator did not configure, and the
# probe carries no credentials. Sencho is single-tenant and self-hosted:
# the admin who sets the repository URL owns the server, the trust model
# already accepted for registry-api.ts and NotificationService.ts.
- backend/src/services/git/redirectPreflight.ts
# Per-fetch HTTPS CA bundle sink: combines the operator-supplied per-source
# PEM, the optional NODE_EXTRA_CA_CERTS file, and (on Windows) Git's
# bundled system bundle into one mode-0600 file the git child reads via
# http.sslCAInfo. Every input to the write has already been validated as
# PEM by the caller or comes from a known, operator-controlled path on the
# same host; the file lives under the per-fetch workspace, which the caller
# deletes in a finally block. Excluded from JS analysis for the same reason
# as the SSH sink above.
- backend/src/services/git/gitCaBundleSink.ts
query-filters:
# API tokens are 256-bit CSPRNG random; sha256 of the raw token is the
+61
View File
@@ -0,0 +1,61 @@
name: Catalog Drift Check
on:
pull_request:
branches: [main]
paths:
- 'docs/feature-catalog.yaml'
- 'scripts/website-catalog/**'
- '.github/workflows/catalog-drift.yml'
push:
branches: [main]
paths:
- 'docs/feature-catalog.yaml'
- 'scripts/website-catalog/**'
# Drift can also be introduced from the website side, which changes nothing
# in this repository and so triggers none of the filters above. Check daily
# so an edited or reverted snapshot cannot sit undetected.
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
# Mint a read-only installation token scoped to the website repository
# only. The default GITHUB_TOKEN cannot read Studio-Saelix/sencho-website.
- name: Generate GitHub App installation token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: sencho-website
permission-contents: read
# The root checkout must come first. actions/checkout cleans its
# destination, so running it after the nested one deletes website-checkout.
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: ${{ github.repository_owner }}/sencho-website
token: ${{ steps.app-token.outputs.token }}
path: website-checkout
- name: Install root dependencies
run: npm ci
- name: Canonical validation
run: node scripts/website-catalog/canonical-validate.mjs
# Compare the canonical catalog against the snapshot the website has
# actually committed. Do not run sync-feature-catalog here: it rewrites
# that snapshot from the canonical file, so the comparison would only
# ever read back what it just wrote and could never report drift.
- name: Compare against the committed website snapshot
run: node scripts/website-catalog/check-website-drift.mjs --website-dir website-checkout
- name: Verify no internal identifiers in canonical file
run: node scripts/website-catalog/test-catalog-no-leak.mjs
+1 -1
View File
@@ -127,7 +127,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so
# the `apk upgrade` layer rebuilds at least once per calendar day, even
+3 -3
View File
@@ -33,16 +33,16 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: ${{ matrix.language }}
queries: security-extended
config-file: .github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: /language:${{ matrix.language }}
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Feeds the Dockerfile's APK_CACHE_BUST arg so the `apk upgrade` layer
# rebuilds at least once per calendar day even when every other input is
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Compute daily apk cache bust value
id: apk-bust
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so
# the `apk upgrade` layer rebuilds at least once per calendar day, even
+3 -3
View File
@@ -50,7 +50,7 @@ jobs:
# scan-main job below keeps the two result sets distinct in the UI.
- name: Upload SARIF to code scanning
if: always()
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-published.sarif
category: trivy-published-image
@@ -67,7 +67,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Mirrors the daily-cache-bust logic from ci.yml / docker-publish.yml so
# the apk upgrade layer rebuilds at least once per calendar day.
@@ -98,7 +98,7 @@ jobs:
- name: Upload SARIF to code scanning
if: always()
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-main.sarif
category: trivy-main-head
+71 -55
View File
@@ -93,32 +93,37 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
# Stage 4a: Build Docker CLI from source against Go 1.26.3
#
# CLI v29.4.1 ships otel/sdk v1.43.0, resolving CVE-2026-39883 (BSD kenv) and
# CVE-2026-39882 (OTLP response OOM). It also carries the CVE-2025-15558 fix
# (Windows plugin search path LPE, fixed since v29.2.0). Building from source
# with Go 1.26.3 additionally eliminates Go stdlib CVEs present in the upstream
# static binary.
# CVE-2026-39882 (OTLP response OOM). Building against grpc v1.83.2 below
# transitively resolves otel to v1.44.0, which additionally clears
# CVE-2026-41178 (baggage header parsing dropped its raw-length cap, allowing
# resource exhaustion via an oversized header, present through v1.43.0). It
# also carries the CVE-2025-15558 fix (Windows plugin search path LPE, fixed
# since v29.2.0). Building from source with Go 1.26.3 additionally eliminates
# Go stdlib CVEs present in the upstream static binary.
#
# Runs on the BUILD platform; GOARCH cross-compiles the static binary for TARGET.
# The fetch pulls only the v29.4.1 commit, minimising transfer size.
# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid SemVer
# compliance requirements. We copy vendor.mod -> go.mod, drop the committed vendor
# tree, bump golang.org/x/net to v0.56.0, golang.org/x/text to v0.39.0,
# google.golang.org/grpc to v1.82.1, and github.com/moby/go-archive to v0.3.0,
# and build with -mod=mod so the patched modules are resolved from the module
# proxy. x/net v0.53.0 is flagged for six
# Runs on the BUILD platform; GOARCH cross-compiles the static binary for
# TARGET. The fetch pulls only the v29.4.1 commit, minimising transfer size.
# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid
# SemVer compliance requirements. We copy vendor.mod -> go.mod, drop the
# committed vendor tree, bump golang.org/x/net to v0.58.0, golang.org/x/text
# to v0.41.0, google.golang.org/grpc to v1.83.2, and
# github.com/moby/go-archive to v0.3.0, and build with -mod=mod so the patched
# modules are resolved from the module proxy. x/net v0.53.0 is flagged for six
# HIGH advisories (CVE-2026-25680, -25681, -27136, -39821, -42502, -42506;
# x/net/html parsing and x/net/idna). x/net v0.55.0 is flagged for
# CVE-2026-46600 (dnsmessage denial of service). x/text v0.37.0 is flagged for
# CVE-2026-56852 (norm.Iter infinite loop on crafted input). grpc v1.80.0 is
# flagged for GHSA-hrxh-6v49-42gf (xDS RBAC / HTTP/2). go-archive v0.2.0 is
# flagged for CVE-2026-17106 (HIGH), where a crafted tar archive can write
# outside the extraction directory. Removing vendor/ keeps
# -mod=mod from reading the stale copy, and avoids `go mod tidy` (which does
# not run cleanly against docker/cli's vendor.mod manifest). This stage now
# fetches modules at build time rather than building fully offline.
# flagged for GHSA-hrxh-6v49-42gf (xDS RBAC / HTTP/2), and grpc v1.82.1 for
# CVE-2026-84304 (unauthenticated peer OOM via fragmented HTTP/2 DATA frames
# buffered per-message). go-archive v0.2.0 is flagged for CVE-2026-17106
# (HIGH), where a crafted tar archive can write outside the extraction
# directory. Removing vendor/ keeps -mod=mod from reading the stale copy, and
# avoids `go mod tidy` (which does not run cleanly against docker/cli's
# vendor.mod manifest). This stage now fetches modules at build time rather
# than building fully offline.
# Base image pinned by digest so the Go toolchain that compiles the static
# Docker CLI binary cannot change without an explicit Dependabot bump.
FROM --platform=$BUILDPLATFORM golang:1.27rc3-alpine@sha256:c5aca77a4d16cb6688dbf3ccade67eff6f05ee208bc854d060e6947f5c27e23c AS cli-builder
FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS cli-builder
ARG TARGETARCH
@@ -139,9 +144,9 @@ RUN mkdir -p /build
RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
rm -rf vendor && \
go get golang.org/x/net@v0.56.0 \
golang.org/x/text@v0.39.0 \
google.golang.org/grpc@v1.82.1 \
go get golang.org/x/net@v0.58.0 \
golang.org/x/text@v0.41.0 \
google.golang.org/grpc@v1.83.2 \
github.com/moby/go-archive@v0.3.0 && \
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-mod=mod \
@@ -165,12 +170,15 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
# toolchain eliminates Go stdlib CVEs from the binary's SBOM.
#
# Compose v5.1.3 still bundles otel/sdk v1.42.0 transitively via buildkit
# v0.29.0. The go get step below bumps otel to v1.43.0 to resolve
# CVE-2026-39883 (BSD kenv) and CVE-2026-39882 (OTLP response OOM) so that
# the compose binary scans completely clean. It also bumps
# github.com/moby/go-archive to v0.3.0 for CVE-2026-17106 (HIGH), where a
# crafted tar archive can write outside the extraction directory; compose
# pulls the same archive code in transitively through buildkit.
# v0.29.0. The go get step below bumps otel to v1.44.0 to resolve
# CVE-2026-39883 (BSD kenv), CVE-2026-39882 (OTLP response OOM), and
# CVE-2026-41178 (baggage header parsing dropped its raw-length cap, allowing
# resource exhaustion via an oversized header, present through v1.43.0) so
# that the compose binary scans completely clean; v1.44.0 is also grpc
# v1.83.2's minimum below. It also bumps github.com/moby/go-archive to v0.3.0
# for CVE-2026-17106 (HIGH), where a crafted tar archive can write outside the
# extraction directory; compose pulls the same archive code in transitively
# through buildkit.
#
# Compose v5.1.3 also pins github.com/containerd/containerd/v2 v2.2.3, which
# carries CVE-2026-46680 (runAsNonRoot evasion in containerd's runtime
@@ -182,14 +190,18 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
# daemon-side (containerd's CRI service) and is not reached by compose at all,
# so this is defense-in-depth rather than a live exposure.
#
# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.82.1 to
# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues),
# golang.org/x/text from v0.38.0 to v0.39.0 to clear CVE-2026-56852
# (norm.Iter infinite loop on crafted input), and golang.org/x/net from
# v0.55.0 to v0.56.0 to clear CVE-2026-46600 (dnsmessage denial of service).
# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.83.2 to
# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues)
# and CVE-2026-84304 (unauthenticated peer OOM via fragmented HTTP/2 DATA
# frames buffered per-message), golang.org/x/text from v0.38.0 to v0.41.0 to
# clear CVE-2026-56852 (norm.Iter infinite loop on crafted input) and satisfy
# x/crypto's minimum, golang.org/x/net from v0.55.0 to v0.58.0 to clear
# CVE-2026-46600 (dnsmessage denial of service) and satisfy grpc v1.83.2's
# minimum version, and golang.org/x/crypto from v0.53.0 to v0.55.0 to clear
# CVE-2026-56854 (SSH host-key verification bypass).
# Base image pinned by digest (same image as cli-builder above) so both
# source builds share an identical, immutable Go toolchain.
FROM --platform=$BUILDPLATFORM golang:1.27rc3-alpine@sha256:c5aca77a4d16cb6688dbf3ccade67eff6f05ee208bc854d060e6947f5c27e23c AS compose-builder
FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS compose-builder
ARG TARGETARCH
@@ -208,28 +220,32 @@ WORKDIR /src/docker-compose
RUN mkdir -p /build
# Patch otel/sdk and exporters from v1.42.0 → v1.43.0 to clear CVE-2026-39883
# and CVE-2026-39882, bump containerd/v2 from v2.2.3 → v2.2.5 to clear
# CVE-2026-46680 plus the CVE-2026-53488 / 53489 / 53492 cluster, bump
# google.golang.org/grpc to v1.82.1 to clear GHSA-hrxh-6v49-42gf, and bump
# golang.org/x/net to v0.56.0 to clear CVE-2026-46600. The containerd bump is
# patch-level; the otel, grpc, and x/net bumps are minor security releases.
# None introduce breaking API changes.
# Patch otel/sdk and exporters from v1.42.0 → v1.44.0 to clear CVE-2026-39883,
# CVE-2026-39882, and CVE-2026-41178 (present through v1.43.0; v1.44.0 is also
# grpc v1.83.2's minimum below), bump containerd/v2 from v2.2.3 → v2.2.5 to
# clear CVE-2026-46680 plus the CVE-2026-53488 / 53489 / 53492 cluster, bump
# google.golang.org/grpc to v1.83.2 to clear GHSA-hrxh-6v49-42gf,
# CVE-2026-84304, and CVE-2026-84445, bump golang.org/x/net to v0.58.0 to clear
# CVE-2026-46600,
# and bump golang.org/x/crypto to v0.55.0 to clear CVE-2026-56854. The
# containerd bump is patch-level; the otel, grpc, x/net, and x/crypto bumps
# are minor security releases. None introduce breaking API changes.
RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \
go get go.opentelemetry.io/otel@v1.43.0 \
go.opentelemetry.io/otel/sdk@v1.43.0 \
go.opentelemetry.io/otel/sdk/metric@v1.43.0 \
go.opentelemetry.io/otel/metric@v1.43.0 \
go.opentelemetry.io/otel/trace@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0 \
go get go.opentelemetry.io/otel@v1.44.0 \
go.opentelemetry.io/otel/sdk@v1.44.0 \
go.opentelemetry.io/otel/sdk/metric@v1.44.0 \
go.opentelemetry.io/otel/metric@v1.44.0 \
go.opentelemetry.io/otel/trace@v1.44.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace@v1.44.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.44.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.44.0 \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.44.0 \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.44.0 \
github.com/containerd/containerd/v2@v2.2.5 \
google.golang.org/grpc@v1.82.1 \
golang.org/x/text@v0.39.0 \
golang.org/x/net@v0.56.0 \
google.golang.org/grpc@v1.83.2 \
golang.org/x/text@v0.41.0 \
golang.org/x/net@v0.58.0 \
golang.org/x/crypto@v0.55.0 \
github.com/moby/go-archive@v0.3.0 && \
go mod tidy
@@ -282,7 +298,7 @@ ARG APK_CACHE_BUST=unset
# removing it also eliminates CVE-2026-33671 (picomatch ReDoS in npm).
RUN echo "apk cache bust: ${APK_CACHE_BUST}" && \
apk upgrade --no-cache && \
apk add --no-cache bash su-exec git tini openssh-client && \
apk add --no-cache bash su-exec git tini 'openssh-client>=10.3_p1-r1' && \
mkdir -p /usr/local/lib/docker/cli-plugins
# Copy the source-built Docker CLI and Compose plugin from their builder stages.
+385 -360
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -13,6 +13,7 @@
"test:docker-integration": "vitest run --config vitest.docker-integration.config.ts",
"test": "vitest run",
"lint": "eslint src",
"matrix:render": "node scripts/git-support-matrix/render.js",
"reset-mfa": "node dist/cli/resetMfa.js",
"reset-password": "node dist/cli/resetPassword.js",
"create-emergency-admin": "node dist/cli/createEmergencyAdmin.js",
@@ -88,7 +89,8 @@
"otplib": "^13.4.0",
"semver": "^7.7.4",
"systeminformation": "^5.31.1",
"tar-stream": "^3.1.8",
"tar-stream": "3.2.0",
"undici": "^8.10.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
@@ -0,0 +1,30 @@
// Shared YAML loader for the Git transport support matrix.
//
// Used by both render.js (the CLI/build-time generator) and
// git-support-matrix.test.ts (the validator), so the two never parse the
// source files differently.
const fs = require('fs');
const path = require('path');
const { parse } = require('yaml');
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
const SUPPORT_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-support.yaml');
const ATTESTATIONS_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-attestations.yaml');
const MDX_PATH = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx');
function loadClaimSet() {
const supportRaw = fs.readFileSync(SUPPORT_YAML_PATH, 'utf8');
const attestationsRaw = fs.readFileSync(ATTESTATIONS_YAML_PATH, 'utf8');
return {
support: parse(supportRaw),
attestations: parse(attestationsRaw),
};
}
module.exports = {
REPO_ROOT,
SUPPORT_YAML_PATH,
ATTESTATIONS_YAML_PATH,
MDX_PATH,
loadClaimSet,
};
@@ -0,0 +1,199 @@
// Generates the tables in docs/features/git-transport-support.mdx from
// docs/git-transport-support.yaml.
//
// Everything between the GENERATED markers is produced here; the rest of the
// MDX file (intro prose, the "How these claims are verified" section) is
// hand-written and left untouched. The validator (git-support-matrix.test.ts)
// imports renderFullMdx and asserts the committed file is byte-identical to
// what it produces, so the page can never silently drift from the YAML.
const fs = require('fs');
const { loadClaimSet, MDX_PATH } = require('./loadClaimSet');
const MARKER_BEGIN = '<!-- GENERATED:BEGIN (run `npm run matrix:render` in backend/ to regenerate, do not edit by hand) -->';
const MARKER_END = '<!-- GENERATED:END -->';
const TRANSPORT_LABELS = { https: 'HTTPS', ssh: 'SSH' };
const TRANSPORT_NOTES = {
https: 'Personal Access Token for private repositories, or no credential at all for public ones. TLS verification uses the system trust store by default, or a per-source custom CA when configured.',
ssh: 'A read-only deploy key with strict host-key verification. Standard (22) and nonstandard ports are both supported.',
};
const REF_LABELS = { branch: 'Branch', tag: 'Tag', sha: 'Commit SHA' };
const REF_NOTES = {
branch: 'Tracks the head of a branch; each pull resolves and pins the exact commit.',
tag: 'Both annotated and lightweight tags resolve to their target commit.',
sha: 'A full commit SHA is pinned directly; the Git host must advertise the commit on some branch or tag.',
};
const AUTH_LABELS = { none: 'Public (no auth)', pat: 'Personal Access Token', 'deploy-key': 'SSH deploy key' };
const AUTH_NOTES = {
none: 'For public repositories.',
pat: 'Stored encrypted at rest, never returned after save.',
'deploy-key': 'Stored encrypted at rest; the server host key is verified on every fetch.',
};
const CA_LABELS = { system: 'System trust (default)', 'per-source': 'Per-source custom CA', 'not-applicable': 'Not applicable' };
const CA_NOTES = {
system: 'The host running the fetch trusts its system certificate store.',
'per-source': "Combined with the system trust anchors, so public hosts keep validating normally. Redirects are re-resolved and only followed when they stay on the source's own host.",
'not-applicable': 'SSH uses host-key verification instead of TLS certificate trust.',
};
const HOST_LABELS = {
generic: 'Generic (self-hosted or any Git server)',
github: 'GitHub',
gitlab: 'GitLab',
gitea: 'Gitea',
forgejo: 'Forgejo',
bitbucket: 'Bitbucket',
};
const HOST_ORDER = ['generic', 'github', 'gitlab', 'gitea', 'forgejo', 'bitbucket'];
const STATUS_LABELS = { supported: 'Supported', unsupported: 'Not supported', unverified: 'Not yet verified' };
function aggregateStatus(claims) {
if (claims.length === 0) return 'unverified';
if (claims.some((c) => c.support === 'unsupported')) return 'unsupported';
if (claims.some((c) => c.support === 'supported')) return 'supported';
return 'unverified';
}
function evidenceSummary(claims, attestationsById) {
const kinds = new Set();
let latestDate = null;
for (const c of claims) {
if (c.support !== 'supported' || !c.evidence) continue;
if (c.evidence.kind === 'automated') kinds.add('automated');
if (c.evidence.kind === 'live') {
kinds.add('live');
const att = attestationsById.get(c.evidence.attestation);
if (att && (!latestDate || att.date > latestDate)) latestDate = att.date;
}
}
if (kinds.size === 0) return 'Pending';
if (kinds.has('automated') && kinds.has('live')) return `Automated, every change; live as of ${latestDate}`;
if (kinds.has('automated')) return 'Automated, every change';
return `Live, ${latestDate}`;
}
function table(headers, rows) {
const sep = headers.map(() => '---');
return [
`| ${headers.join(' | ')} |`,
`| ${sep.join(' | ')} |`,
...rows.map((r) => `| ${r.join(' | ')} |`),
].join('\n');
}
function renderTransports(claims) {
const rows = Object.keys(TRANSPORT_LABELS).map((t) => {
const group = claims.filter((c) => c.transport === t);
return [TRANSPORT_LABELS[t], STATUS_LABELS[aggregateStatus(group)], TRANSPORT_NOTES[t]];
});
return ['## Transports', '', table(['Transport', 'Status', 'Notes'], rows)].join('\n');
}
function renderRefs(claims) {
const rows = Object.keys(REF_LABELS).map((r) => {
const group = claims.filter((c) => c.ref === r);
return [REF_LABELS[r], STATUS_LABELS[aggregateStatus(group)], REF_NOTES[r]];
});
return ['## Reference types', '', table(['Reference type', 'Status', 'Notes'], rows)].join('\n');
}
function renderAuth(claims) {
const rows = Object.keys(AUTH_LABELS).map((a) => {
const group = claims.filter((c) => c.auth === a);
return [AUTH_LABELS[a], STATUS_LABELS[aggregateStatus(group)], AUTH_NOTES[a]];
});
return ['## Authentication', '', table(['Method', 'Status', 'Notes'], rows)].join('\n');
}
const CA_TABLE_MODES = ['system', 'per-source'];
function renderCa(claims) {
const rows = CA_TABLE_MODES.map((c) => {
const group = claims.filter((claim) => claim.ca === c);
return [CA_LABELS[c], STATUS_LABELS[aggregateStatus(group)], CA_NOTES[c]];
});
return ['## TLS and certificate authorities', '', table(['Mode', 'Status', 'Notes'], rows)].join('\n');
}
function renderHosts(claims, attestationsById) {
const rows = HOST_ORDER.map((host) => {
const group = claims.filter((c) => c.host === host);
const httpsGroup = group.filter((c) => c.transport === 'https');
const sshGroup = group.filter((c) => c.transport === 'ssh');
const branchGroup = group.filter((c) => c.ref === 'branch');
const tagGroup = group.filter((c) => c.ref === 'tag');
const shaGroup = group.filter((c) => c.ref === 'sha');
return [
HOST_LABELS[host],
STATUS_LABELS[aggregateStatus(httpsGroup)],
STATUS_LABELS[aggregateStatus(sshGroup)],
STATUS_LABELS[aggregateStatus(branchGroup)],
STATUS_LABELS[aggregateStatus(tagGroup)],
STATUS_LABELS[aggregateStatus(shaGroup)],
evidenceSummary(group, attestationsById),
];
});
return [
'## Git hosts',
'',
table(['Host', 'HTTPS', 'SSH', 'Branch', 'Tag', 'Commit SHA', 'Evidence'], rows),
].join('\n');
}
function renderLimitations(limitations) {
const bullets = limitations.map((l) => `- **${l.title}.** ${l.statement}`);
return ['## Not supported', '', ...bullets].join('\n');
}
function renderGeneratedBlock(data) {
const { support, attestations } = data;
const attestationsById = new Map((attestations.attestations || []).map((a) => [a.id, a]));
const claims = support.claims;
return [
renderTransports(claims),
'',
renderRefs(claims),
'',
renderAuth(claims),
'',
renderHosts(claims, attestationsById),
'',
renderCa(claims),
'',
renderLimitations(support.limitations),
].join('\n');
}
function renderFullMdx() {
const data = loadClaimSet();
const generated = renderGeneratedBlock(data);
const current = fs.readFileSync(MDX_PATH, 'utf8');
const beginIdx = current.indexOf(MARKER_BEGIN);
const endIdx = current.indexOf(MARKER_END);
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
throw new Error(`${MDX_PATH} is missing the GENERATED markers, or they are out of order.`);
}
const before = current.slice(0, beginIdx + MARKER_BEGIN.length);
const after = current.slice(endIdx);
return `${before}\n\n${generated}\n\n${after}`;
}
if (require.main === module) {
const rendered = renderFullMdx();
fs.writeFileSync(MDX_PATH, rendered, 'utf8');
console.log(`[matrix:render] Wrote ${MDX_PATH}`);
}
module.exports = {
MARKER_BEGIN,
MARKER_END,
renderGeneratedBlock,
renderFullMdx,
aggregateStatus,
};
@@ -399,6 +399,7 @@ describe('AutoHealService.evaluate', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'http://remote:1852',
apiToken: 'tok',
trustedLoopback: false,
});
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response);
@@ -434,6 +435,7 @@ describe('AutoHealService.evaluate', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'http://remote2:1852',
apiToken: 'tok2',
trustedLoopback: false,
});
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response);
@@ -0,0 +1,47 @@
/**
* Shared availability probes for the real-git and real-sshd integration
* suites.
*
* Every suite used to carry its own copy of `gitAvailable()`/`sshdAvailable()`
* and pass the result straight to `describe.skipIf`, so a missing dependency
* in CI silently skipped the suite instead of failing the build: proof of a
* combination could stop running with nothing in the test output to say so.
* These wrappers keep the same local-dev behavior (skip when the dependency
* is absent) but throw under CI, where the dependency is expected to be
* present and a skip would be a false claim of coverage.
*/
import { spawnSync } from 'child_process';
export type DependencyProbe = () => boolean;
export const defaultGitProbe: DependencyProbe = () =>
spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
export const defaultSshdProbe: DependencyProbe = () =>
spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0;
function requireDependency(name: string, hint: string, probe: DependencyProbe): boolean {
const present = probe();
if (!present && process.env.CI) {
throw new Error(`${name} is required in CI but was not found. ${hint}`);
}
return present;
}
/** True when the system `git` binary is available; throws under CI if not. */
export function requireGitBinary(probe: DependencyProbe = defaultGitProbe): boolean {
return requireDependency(
'git',
'Ensure the CI image installs the git CLI before running the backend suite.',
probe,
);
}
/** True when a local `sshd` binary is available; throws under CI if not. */
export function requireSshd(probe: DependencyProbe = defaultSshdProbe): boolean {
return requireDependency(
'sshd',
'Ensure the CI image installs openssh-server and frees loopback port 22 (see .github/workflows/ci.yml).',
probe,
);
}
@@ -0,0 +1,47 @@
/**
* Shared fixture helpers for tests that need a real local git repository
* served over smart HTTPS. Two tests in this directory build the same shape
* of bare repo (init source, write compose, commit, bare-clone) for their own
* TLS servers; this helper keeps that logic in one place.
*/
import { spawnSync } from 'child_process';
import { mkdtempSync, writeFileSync } from 'fs';
import os from 'os';
import path from 'path';
export interface BuildBareRepoOptions {
/** Tmpdir prefix for the working source repo. */
srcPrefix?: string;
/** Tmpdir prefix for the bare clone. */
barePrefix?: string;
/** Git user.email for the fixture commit. */
userEmail?: string;
/** Git user.name for the fixture commit. */
userName?: string;
/** Branch name; defaults to 'main'. */
branch?: string;
}
export function buildBareRepo(opts: BuildBareRepoOptions = {}): string {
const srcPrefix = opts.srcPrefix ?? 'sencho-git-src-';
const barePrefix = opts.barePrefix ?? 'sencho-git-bare-';
const userEmail = opts.userEmail ?? 'git-fixture@sencho.test';
const userName = opts.userName ?? 'Sencho Git Fixture';
const branch = opts.branch ?? 'main';
const srcDir = mkdtempSync(path.join(os.tmpdir(), srcPrefix));
const run = (args: string[], cwd: string, label: string) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
if (r.status !== 0) throw new Error(`git ${label} failed: ${r.stderr}`);
};
run(['init', '-b', branch], srcDir, 'init');
run(['config', 'user.email', userEmail], srcDir, 'config email');
run(['config', 'user.name', userName], srcDir, 'config name');
writeFileSync(path.join(srcDir, 'compose.yaml'), 'services:\n x:\n image: nginx\n');
run(['add', '-A'], srcDir, 'add');
run(['commit', '-m', 'fixture'], srcDir, 'commit');
const bareRoot = mkdtempSync(path.join(os.tmpdir(), barePrefix));
const bareDir = path.join(bareRoot, 'repo.git');
const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' });
if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`);
return bareDir;
}
@@ -0,0 +1,150 @@
/**
* Resolves a (file, exact test title) proof handle against real vitest
* source, using the TypeScript compiler API rather than a string search.
*
* A string search accepts a commented-out test, a `.skip`-ed test, or a
* duplicate title landing on the wrong declaration. This walks the actual
* AST: it finds every `it`/`test` declaration with that literal title,
* rejects any declaration carrying a skip-shaped modifier (`.skip`, `.todo`,
* `.failing`, `.only`, `.each`, `.skipIf`, `.runIf`) directly, and rejects
* any declaration nested under an enclosing `describe` that is unconditionally
* skipped or conditionally skipped by anything other than
* `describe.skipIf(...)` whose predicate calls one of the approved hardened
* dependency probes (`requireGitBinary`, `requireSshd` from
* `./externalDeps`). `describe.skip` and `describe.runIf` are never
* approved, at any nesting depth.
*/
import ts from 'typescript';
export const APPROVED_GUARD_HELPERS = ['requireGitBinary', 'requireSshd'];
const SKIP_SHAPED_MODIFIERS = new Set(['skip', 'todo', 'failing', 'only', 'each', 'skipIf', 'runIf']);
export type HandleResolution =
| { ok: true }
| { ok: false; reason: 'not-found' | 'duplicate' | 'skipped-directly' | 'unapproved-ancestor-skip' };
interface CallShape {
kind: string;
modifier: string | null;
/** For a curried modifier (`X.skipIf(pred)(title, fn)`), the inner call's first argument. */
predicateArg?: ts.Node;
}
const CURRIED_MODIFIERS = new Set(['skipIf', 'runIf', 'each']);
function classifyCall(node: ts.CallExpression): CallShape | null {
const expr = node.expression;
// Direct form: describe('x', fn) / describe.skip('x', fn) / it.todo('x').
if (ts.isIdentifier(expr)) {
return { kind: expr.text, modifier: null };
}
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
return { kind: expr.expression.text, modifier: expr.name.text };
}
// Curried form: X.skipIf(pred)(title, fn) / X.runIf(pred)(title, fn) /
// X.each(cases)(title, fn): the outer call's expression is itself a
// CallExpression whose own expression is the X.modifier access.
if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression) && ts.isIdentifier(expr.expression.expression)) {
const modifier = expr.expression.name.text;
if (CURRIED_MODIFIERS.has(modifier)) {
return { kind: expr.expression.expression.text, modifier, predicateArg: expr.arguments[0] };
}
}
return null;
}
function predicateCallsApprovedHelper(argNode: ts.Node): boolean {
let found = false;
const visit = (n: ts.Node): void => {
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && APPROVED_GUARD_HELPERS.includes(n.expression.text)) {
found = true;
}
ts.forEachChild(n, visit);
};
visit(argNode);
return found;
}
function stringLiteralText(node: ts.Node | undefined): string | null {
if (node && ts.isStringLiteralLike(node)) return node.text;
return null;
}
function functionBodyArgument(node: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | undefined {
return node.arguments.find((a): a is ts.ArrowFunction | ts.FunctionExpression =>
ts.isArrowFunction(a) || ts.isFunctionExpression(a));
}
type AncestorState = 'none' | 'approved' | 'unapproved';
interface FoundDeclaration {
title: string;
ownModifier: string | null;
ancestorState: AncestorState;
}
function collectDeclarations(sourceFile: ts.SourceFile): FoundDeclaration[] {
const found: FoundDeclaration[] = [];
function walk(node: ts.Node, ancestorState: AncestorState): void {
if (ts.isCallExpression(node)) {
const classified = classifyCall(node);
if (classified?.kind === 'describe') {
let nextState: AncestorState = ancestorState;
if (ancestorState !== 'unapproved') {
if (classified.modifier === 'skip' || classified.modifier === 'runIf') {
nextState = 'unapproved';
} else if (classified.modifier === 'skipIf') {
const predicate = classified.predicateArg;
nextState = predicate && predicateCallsApprovedHelper(predicate) ? 'approved' : 'unapproved';
} else if (classified.modifier === 'only' || classified.modifier === 'each' || classified.modifier === 'todo') {
// Scoping/parameterization modifiers on describe don't skip
// this suite's tests; leave ancestorState unchanged.
nextState = ancestorState;
}
}
const callback = functionBodyArgument(node);
if (callback?.body) {
ts.forEachChild(callback.body, (child) => walk(child, nextState));
}
return;
}
if (classified?.kind === 'it' || classified?.kind === 'test') {
const title = stringLiteralText(node.arguments[0]);
if (title !== null) {
found.push({ title, ownModifier: classified.modifier, ancestorState });
}
// Do not descend further into an it/test call's own arguments;
// its callback body is the test implementation, not more
// declarations.
return;
}
}
ts.forEachChild(node, (child) => walk(child, ancestorState));
}
walk(sourceFile, 'none');
return found;
}
export function resolveTestHandle(filePath: string, sourceText: string, title: string): HandleResolution {
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
const declarations = collectDeclarations(sourceFile).filter((d) => d.title === title);
if (declarations.length === 0) return { ok: false, reason: 'not-found' };
const runnable = declarations.filter((d) => d.ownModifier === null && d.ancestorState !== 'unapproved');
if (runnable.length > 1) return { ok: false, reason: 'duplicate' };
if (runnable.length === 1) return { ok: true };
// Every matching declaration is skipped some way; report the most
// specific reason from the first match.
const first = declarations[0];
if (first.ownModifier !== null && SKIP_SHAPED_MODIFIERS.has(first.ownModifier)) {
return { ok: false, reason: 'skipped-directly' };
}
return { ok: false, reason: 'unapproved-ancestor-skip' };
}
@@ -50,6 +50,7 @@ function seedSource(stackName: string, composePaths: string[]): void {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -63,6 +63,7 @@ beforeEach(() => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM blueprint_deployments').run();
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let runGitOpsSourceRecovery: typeof import('../bootstrap/startup').runGitOpsSourceRecovery;
let GitSourceService: typeof import('../services/GitSourceService').GitSourceService;
let gitSourceServiceModule: typeof import('../services/GitSourceService');
beforeAll(async () => {
tmpDir = await setupTestDb();
({ runGitOpsSourceRecovery } = await import('../bootstrap/startup'));
gitSourceServiceModule = await import('../services/GitSourceService');
({ GitSourceService } = gitSourceServiceModule);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('runGitOpsSourceRecovery', () => {
it('recovers unsettled reconcile attempts before sweeping the managed area', async () => {
const order: string[] = [];
// Recovery yields before recording itself: if the sweep were ever
// started concurrently instead of strictly after recovery resolves,
// the sweep's own synchronous push would land first and this would
// catch it, rather than merely proving call order at invocation
// time.
vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts')
.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
order.push('recover');
});
vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans')
.mockImplementation(async () => { order.push('sweep'); });
await runGitOpsSourceRecovery();
expect(order).toEqual(['recover', 'sweep']);
});
it('still runs the sweep when recovery itself throws, tolerating the failure', async () => {
const order: string[] = [];
vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts')
.mockImplementation(async () => { throw new Error('simulated recovery failure'); });
vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans')
.mockImplementation(async () => { order.push('sweep'); });
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(runGitOpsSourceRecovery()).resolves.toBeUndefined();
expect(order).toEqual(['sweep']);
expect(errorSpy).toHaveBeenCalled();
});
it('still resolves when the sweep itself throws, tolerating the failure rather than aborting startup', async () => {
const recoverSpy = vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts')
.mockImplementation(async () => {});
vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans')
.mockImplementation(async () => { throw new Error('simulated sweep failure'); });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(runGitOpsSourceRecovery()).resolves.toBeUndefined();
expect(recoverSpy).toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalled();
});
});
describe('startServer source ordering', () => {
it('calls runGitOpsSourceRecovery before starting SourceController, so recovery and the sweep always precede the controller poll loop', () => {
// A structural check, not a behavioral one: startServer drives ~25
// unrelated services and isn't practical to run end to end in a
// test. What matters here is the one property a future edit could
// silently break: SourceController must never start before
// recovery and the sweep have both awaited to completion. Comments
// are stripped first so a mention of either symbol in prose can't
// satisfy the match, and matching is whitespace/chaining-tolerant
// so a harmless reformat (line-wrapped method chain, a `const`
// extracted for the controller instance) doesn't false-fail this.
const source = fs.readFileSync(path.join(__dirname, '../bootstrap/startup.ts'), 'utf-8');
const startServerStart = source.indexOf('export async function startServer');
// startServer is the last top-level declaration in this file today;
// if that ever changes, bound this slice to its closing brace
// instead of running to end of file.
const startServerBody = source.slice(startServerStart);
const code = startServerBody.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
const reconcileCallIndex = code.search(/await\s+runGitOpsSourceRecovery\s*\(/);
const controllerStartIndex = code.search(/SourceController\s*\.\s*getInstance\s*\(\s*\)\s*\.\s*start\s*\(/);
expect(reconcileCallIndex).toBeGreaterThan(-1);
expect(controllerStartIndex).toBeGreaterThan(-1);
expect(reconcileCallIndex).toBeLessThan(controllerStartIndex);
});
});
@@ -0,0 +1,225 @@
/**
* Route coverage for the canonical build identity:
*
* - GET /api/build-info is proxy-exempt (always served by the control
* instance), requires a signed-in human session (rejects machine / API-token
* credentials), redacts hardened image references to non-admins via
* `restricted: true`, and never mislabels a redacted field "Unknown".
* - GET /api/meta exposes only the bounded `buildChannel` enum on the public
* surface and never leaks the running image reference.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
let tmpDir: string;
let app: import('express').Express;
let adminAuth: string;
let viewerAuth: string;
let machineAuth: string;
let remoteNodeId: number;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let SelfIdentityService: typeof import('../services/SelfIdentityService').default;
const IMAGE_ID = 'b'.repeat(64);
const DIGEST = 'a'.repeat(64);
function mockBuildInfo(over: Record<string, unknown> = {}) {
const svc = SelfIdentityService.getInstance();
vi.spyOn(svc, 'getBuildInfo').mockReturnValue({
version: '0.97.1',
channel: 'dev',
imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234',
imageId: IMAGE_ID,
revision: 'dev-abc1234',
...over,
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ DatabaseService } = await import('../services/DatabaseService'));
SelfIdentityService = (await import('../services/SelfIdentityService')).default;
const db = DatabaseService.getInstance();
remoteNodeId = db.addNode({
name: 'build-info-remote',
type: 'remote',
compose_dir: '/tmp',
is_default: false,
api_url: 'http://127.0.0.1:1',
api_token: 'build-info-remote-token',
});
// A signed-in non-admin human session (role resolved from the DB row).
db.addUser({
username: 'build-info-viewer',
password_hash: await bcrypt.hash('pw', 1),
role: 'viewer',
});
adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
viewerAuth = `Bearer ${jwt.sign({ username: 'build-info-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
// node_proxy machine credential: authMiddleware maps it to role admin with
// userId 0, so requireUserSession must reject it as not a human session.
machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
describe('GET /api/build-info auth', () => {
it('requires authentication', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info');
expect(res.status).toBe(401);
});
it('rejects node_proxy machine credentials (not a human session)', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info').set('Authorization', machineAuth);
expect(res.status).toBe(403);
expect(res.body.code).toBe('SESSION_REQUIRED');
});
});
describe('GET /api/build-info is proxy-exempt', () => {
it('serves locally even when x-node-id targets a remote node', async () => {
mockBuildInfo();
// The remote node's api_url is a closed loopback port. A 502 would mean the
// proxy intercepted the request; anything else proves the local handler
// matched, exactly as the existing /api/nodes proxy-exempt test asserts.
const res = await withLoopbackTargetProtection(() => request(app)
.get('/api/build-info')
.set('Authorization', adminAuth)
.set('x-node-id', String(remoteNodeId)));
expect(res.status).not.toBe(502);
expect(res.body.channel).toBe('dev');
});
});
describe('GET /api/build-info as admin', () => {
it('returns the full dev identity for a dev image (regression: semver still previous stable)', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info').set('Authorization', adminAuth);
expect(res.status).toBe(200);
expect(res.body.version).toBe('0.97.1');
expect(res.body.channel).toBe('dev');
expect(res.body.imageChannel).toBe('community');
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
expect(res.body.imageId).toBe(IMAGE_ID);
expect(res.body.revision).toBe('dev-abc1234');
expect(res.body.restricted).toBe(false);
});
it('returns the bounded imageChannel for a hardened image', async () => {
mockBuildInfo({
channel: 'stable',
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1',
});
const res = await request(app).get('/api/build-info').set('Authorization', adminAuth);
expect(res.status).toBe(200);
expect(res.body.channel).toBe('stable');
expect(res.body.imageChannel).toBe('hardened');
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-hardened:0.97.1');
expect(res.body.restricted).toBe(false);
});
});
describe('GET /api/build-info as a non-admin', () => {
it('redacts a hardened image reference and revision to a non-admin via restricted:true', async () => {
mockBuildInfo({
channel: 'stable',
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1',
revision: `sha256:${DIGEST}`,
});
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
expect(res.status).toBe(200);
// The build channel stays stable; the procurement channel is what gates
// redaction. Both reference fields are nulled with restricted:true so the
// UI can label them "Restricted", never "Unknown".
expect(res.body.channel).toBe('stable');
expect(res.body.imageChannel).toBe('hardened');
expect(res.body.imageRef).toBeNull();
expect(res.body.revision).toBeNull();
expect(res.body.restricted).toBe(true);
// The image ID is not a registry reference and is always returned.
expect(res.body.imageId).toBe(IMAGE_ID);
});
it('does not redact a community image for a non-admin', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
expect(res.status).toBe(200);
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
expect(res.body.revision).toBe('dev-abc1234');
expect(res.body.restricted).toBe(false);
});
it('reports unknown procurement channel on bare metal without a running reference', async () => {
mockBuildInfo({ channel: 'unknown', imageRef: null, revision: null });
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
expect(res.status).toBe(200);
// No reference means no classification, and no redaction can apply.
expect(res.body.channel).toBe('unknown');
expect(res.body.imageChannel).toBe('unknown');
expect(res.body.imageRef).toBeNull();
expect(res.body.revision).toBeNull();
expect(res.body.restricted).toBe(false);
});
});
describe('GET /api/build-info awaits revision enrichment', () => {
it('blocks the response until the enrichment settle promise resolves', async () => {
mockBuildInfo();
const svc = SelfIdentityService.getInstance();
let release!: () => void;
vi.spyOn(svc, 'whenRevisionResolved').mockImplementation(
() => new Promise<void>((res) => { release = res; }),
);
let settled = false;
const pending = request(app)
.get('/api/build-info')
.set('Authorization', adminAuth)
.then((res) => { settled = true; return res; });
// Give the route a tick to reach the await. It must not have responded yet,
// proving a transient null is never the settled value of a success.
await new Promise((r) => setTimeout(r, 10));
expect(settled).toBe(false);
release();
const res = await pending;
expect(res.status).toBe(200);
expect(res.body.revision).toBe('dev-abc1234');
});
});
describe('GET /api/meta buildChannel', () => {
it('exposes the bounded build channel on the public endpoint', async () => {
mockBuildInfo();
const res = await request(app).get('/api/meta');
expect(res.status).toBe(200);
expect(res.body.buildChannel).toBe('dev');
});
it('never leaks the running image reference on the public endpoint', async () => {
mockBuildInfo();
const res = await request(app).get('/api/meta');
const body = JSON.stringify(res.body);
expect(body).not.toContain('ghcr.io/studio-saelix/sencho-dev');
expect(body).not.toContain('dev-abc1234');
});
it('omits buildChannel when the running image reference is unknown', async () => {
mockBuildInfo({ imageRef: null, channel: 'unknown', revision: null });
const res = await request(app).get('/api/meta');
expect(res.status).toBe(200);
expect(res.body.buildChannel).toBeUndefined();
});
});
@@ -359,6 +359,7 @@ describe('GET /api/stacks/statuses caching', () => {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -33,8 +33,16 @@ describe('fetchRemoteMeta Authorization header', () => {
await fetchRemoteMeta('https://remote.example.com:1852', 'real-token');
expect(getSpy).toHaveBeenCalledTimes(1);
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
const init = getSpy.mock.calls[0][1] as {
headers: Record<string, string>;
proxy?: boolean;
httpAgent?: unknown;
httpsAgent?: unknown;
};
expect(init.headers).toEqual({ Authorization: 'Bearer real-token' });
expect(init.proxy).toBe(false);
expect(init.httpAgent).toBeDefined();
expect(init.httpsAgent).toBeDefined();
});
it('omits Authorization entirely when token is empty (pilot-agent loopback)', async () => {
@@ -42,18 +50,21 @@ describe('fetchRemoteMeta Authorization header', () => {
data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null },
});
await fetchRemoteMeta('http://127.0.0.1:54321', '');
await fetchRemoteMeta('http://127.0.0.1:54321', '', true);
expect(getSpy).toHaveBeenCalledTimes(1);
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string>; proxy?: boolean };
expect(init.headers).toEqual({});
expect(init.headers).not.toHaveProperty('Authorization');
expect(init.proxy).toBe(false);
expect(init).not.toHaveProperty('httpAgent');
expect(init).not.toHaveProperty('httpsAgent');
});
it('returns OFFLINE_META shape on transport failure', async () => {
vi.spyOn(axios, 'get').mockRejectedValue(new Error('connect ECONNREFUSED'));
const meta = await fetchRemoteMeta('http://127.0.0.1:54321', '');
const meta = await fetchRemoteMeta('http://127.0.0.1:54321', '', true);
expect(meta).toEqual({
version: null,
@@ -0,0 +1,42 @@
/**
* Truth table for classifyBuildChannel, the canonical build-identity classifier.
* Answers "is this a dev, preview, or stable build" from the image reference
* alone, independent of the packaged semver.
*/
import { describe, it, expect } from 'vitest';
import { classifyBuildChannel } from '../helpers/selfUpdateCompose';
describe('classifyBuildChannel', () => {
it('classifies the dev repository as dev regardless of tag', () => {
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev')).toBe('dev');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev-abc1234')).toBe('dev');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:latest')).toBe('dev');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev@sha256:abc')).toBe('dev');
});
it('classifies stable-repo preview tags as preview', () => {
expect(classifyBuildChannel('saelix/sencho:pr-42')).toBe('preview');
expect(classifyBuildChannel('saelix/sencho:preview-abc1234')).toBe('preview');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:pr-7')).toBe('preview');
});
it('classifies stable-repo release/floating tags as stable', () => {
expect(classifyBuildChannel('saelix/sencho:0.97.1')).toBe('stable');
expect(classifyBuildChannel('saelix/sencho:latest')).toBe('stable');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:v1.2.3')).toBe('stable');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe('stable');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:latest')).toBe('stable');
});
it('treats a dev-<sha> tag as preview-matching-tolerant (still dev repo wins)', () => {
// dev repo takes precedence over the preview/stable tag classification
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:pr-42')).toBe('dev');
});
it('classifies unknown repositories as unknown', () => {
expect(classifyBuildChannel('ubuntu:22.04')).toBe('unknown');
expect(classifyBuildChannel('registry.example.com/private/app:1.0')).toBe('unknown');
expect(classifyBuildChannel('')).toBe('unknown');
expect(classifyBuildChannel(' ')).toBe('unknown');
});
});
@@ -4,6 +4,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { NotificationHistory } from '../services/DatabaseService';
let tmpDir: string;
let DatabaseService: any;
@@ -183,6 +184,39 @@ describe('DatabaseService - cleanupOldNotifications', () => {
});
});
describe('DatabaseService - notification history GitOps dedupe', () => {
it('inserts once and returns the existing row on a repeated dedupe_key', () => {
const first = db.addNotificationHistory(0, {
level: 'info',
message: 'source reconciled',
timestamp: Date.now(),
gitops_operation_id: 'op-1',
dedupe_key: 'gitops:app-1:op-1',
});
const second = db.addNotificationHistory(0, {
level: 'info',
message: 'source reconciled (retry repair)',
timestamp: Date.now(),
gitops_operation_id: 'op-1',
dedupe_key: 'gitops:app-1:op-1',
});
expect(second.id).toBe(first.id);
const history: NotificationHistory[] = db.getNotificationHistory(0, 200);
const matches = history.filter((n) => n.dedupe_key === 'gitops:app-1:op-1');
expect(matches).toHaveLength(1);
expect(matches[0].message).toBe('source reconciled');
});
it('allows two rows with no dedupe_key, matching existing notification behavior', () => {
db.addNotificationHistory(0, { level: 'info', message: 'plain a', timestamp: Date.now() });
db.addNotificationHistory(0, { level: 'info', message: 'plain b', timestamp: Date.now() });
const history: NotificationHistory[] = db.getNotificationHistory(0, 200);
expect(history.filter((n) => n.message === 'plain a' || n.message === 'plain b')).toHaveLength(2);
});
});
describe('DatabaseService - cleanupOldAuditLogs', () => {
it('deletes audit logs older than specified days and retains recent ones', () => {
const oldTimestamp = Date.now() - 120 * 24 * 60 * 60 * 1000; // 120 days ago
@@ -7,6 +7,7 @@ import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs';
let tmpDir: string;
let app: import('express').Express;
@@ -100,4 +101,35 @@ describe('GET /api/diagnostics/environment', () => {
});
}
});
it('ignores a forwarded HTTPS scheme from an untrusted peer', async () => {
const res = await request(app)
.get('/api/diagnostics/environment')
.set('Authorization', adminAuthHeader)
.set('Host', 'sencho.example.com')
.set('X-Forwarded-Proto', 'https');
expect(res.status).toBe(200);
const tls = (res.body.checks as Array<{ id: string; status: string }>).find(check => check.id === 'tls');
expect(tls?.status).toBe('warn');
});
it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => {
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
resetTrustedProxyBlockListCache();
try {
const res = await request(app)
.get('/api/diagnostics/environment')
.set('Authorization', adminAuthHeader)
.set('Host', 'sencho.example.com')
.set('X-Forwarded-Proto', 'https');
expect(res.status).toBe(200);
const tls = (res.body.checks as Array<{ id: string; status: string }>).find(check => check.id === 'tls');
expect(tls?.status).toBe('pass');
} finally {
delete process.env.SENCHO_TRUSTED_PROXY_CIDRS;
resetTrustedProxyBlockListCache();
}
});
});
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it } from 'vitest';
import { requireGitBinary, requireSshd } from './__helpers__/externalDeps';
describe('external dependency probes', () => {
const originalCi = process.env.CI;
afterEach(() => {
if (originalCi === undefined) delete process.env.CI;
else process.env.CI = originalCi;
});
describe('requireGitBinary', () => {
it('returns true when git is present, locally or in CI', () => {
delete process.env.CI;
expect(requireGitBinary(() => true)).toBe(true);
process.env.CI = '1';
expect(requireGitBinary(() => true)).toBe(true);
});
it('returns false when git is absent locally', () => {
delete process.env.CI;
expect(requireGitBinary(() => false)).toBe(false);
});
it('throws when git is absent under CI', () => {
process.env.CI = '1';
expect(() => requireGitBinary(() => false)).toThrow(/git is required in CI/);
});
});
describe('requireSshd', () => {
it('returns true when sshd is present, locally or in CI', () => {
delete process.env.CI;
expect(requireSshd(() => true)).toBe(true);
process.env.CI = '1';
expect(requireSshd(() => true)).toBe(true);
});
it('returns false when sshd is absent locally', () => {
delete process.env.CI;
expect(requireSshd(() => false)).toBe(false);
});
it('throws when sshd is absent under CI', () => {
process.env.CI = '1';
expect(() => requireSshd(() => false)).toThrow(/sshd is required in CI/);
});
});
});
+7 -7
View File
@@ -786,7 +786,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
it('fans out to the remote local-assign receiver with Bearer auth and the template body', async () => {
const remoteId = addRemote('assign-remote-ok');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
@@ -814,7 +814,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
it('omits the Authorization header for a pilot-agent remote with an empty token', async () => {
const remoteId = addRemote('assign-remote-pilot', 'pilot_agent');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '', trustedLoopback: true });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: false, results: [{ stackName: 'p1', success: true }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
@@ -850,7 +850,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
it('treats a mixed-version remote (404 on local-assign) as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-404');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 }));
const res = await request(app)
@@ -867,7 +867,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
it('reports a transport failure as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-transport');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
const res = await request(app)
@@ -883,7 +883,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
it('reports a malformed 200 body as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-malformed');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: true, results: 'not-an-array' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
@@ -903,7 +903,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
it('fails a node whose 200 body returns an empty results array for a non-empty request', async () => {
const remoteId = addRemote('assign-remote-empty');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
// A well-shaped { created, results } body whose results are empty used to
// pass the bare Array.isArray check and read as a successful zero-stack
// assign. Membership validation must fail the node instead.
@@ -926,7 +926,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
it('fails a node whose results omit one of the requested stacks', async () => {
const remoteId = addRemote('assign-remote-partial');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
// Two stacks requested, only one row returned: a partial body the control
// must not accept as a clean assign of the covered stack alone.
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
@@ -66,7 +66,7 @@ afterEach(() => {
function mockTargetActive() {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' };
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '', trustedLoopback: true };
return null;
});
}
@@ -90,8 +90,8 @@ afterEach(() => {
function mockTargets(opts: { pilotReachable: boolean; proxyReachable: boolean } = { pilotReachable: true, proxyReachable: true }) {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
if (id === pilotNodeId) return opts.pilotReachable ? { apiUrl: PILOT_LOOPBACK, apiToken: '' } : null;
if (id === proxyNodeId) return opts.proxyReachable ? { apiUrl: PROXY_URL, apiToken: PROXY_TOKEN } : null;
if (id === pilotNodeId) return opts.pilotReachable ? { apiUrl: PILOT_LOOPBACK, apiToken: '', trustedLoopback: true } : null;
if (id === proxyNodeId) return opts.proxyReachable ? { apiUrl: PROXY_URL, apiToken: PROXY_TOKEN, trustedLoopback: false } : null;
return null;
});
}
@@ -104,8 +104,8 @@ afterEach(() => {
function mockTargetForPilot() {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' };
if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' };
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '', trustedLoopback: true };
if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token', trustedLoopback: false };
return null;
});
}
@@ -627,7 +627,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
it('writes notes to a remote node via the proxy dossier PUT when opted in', async () => {
const { id, remoteId } = remoteDocSnapshot('rweb', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false });
const calls: Array<{ url: string; method?: string }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, opts?: { method?: string }) => {
calls.push({ url, method: opts?.method });
@@ -647,7 +647,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
it('reports a non-fatal notesError when the remote dossier PUT fails but files restored', async () => {
const { id, remoteId } = remoteDocSnapshot('rweb2', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false });
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
@@ -668,7 +668,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
// restore-all is driven by snapshot id; the target node is resolved from
// the snapshot's stored files, so the returned remoteId is not needed here.
const { id } = remoteDocSnapshot('rweb3', 'documented');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false });
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
@@ -928,6 +928,7 @@ describe('Snapshot restore: recovery generation contract', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'http://remote:1852',
apiToken: 'tok',
trustedLoopback: false,
});
}
@@ -51,7 +51,7 @@ function mockMeta(meta: RemoteMeta) {
function mockTarget() {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) =>
id === proxyNodeId ? { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' } : null,
id === proxyNodeId ? { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token', trustedLoopback: false } : null,
);
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { validateCaBundlePem, credentialScopeHost } from '../services/git/caBundle';
import { classifyGitFailure } from '../services/git/errors';
describe('validateCaBundlePem', () => {
it('accepts a PEM certificate block', () => {
const pem = '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv\n-----END CERTIFICATE-----\n';
expect(validateCaBundlePem(pem)).toBe(pem.trim());
});
it('rejects empty and non-PEM input', () => {
expect(validateCaBundlePem('')).toBeNull();
expect(validateCaBundlePem('not a cert')).toBeNull();
});
});
describe('credentialScopeHost', () => {
it('normalizes host and non-default port', () => {
expect(credentialScopeHost('Git.Example.COM')).toBe('git.example.com');
expect(credentialScopeHost('git.example.com', 8443)).toBe('git.example.com:8443');
expect(credentialScopeHost('git.example.com:8443')).toBe('git.example.com:8443');
});
});
describe('classifyGitFailure TLS and redirect nuance', () => {
it('maps hostname mismatch to a clear TLS message', () => {
const result = classifyGitFailure({
transportFailure: true,
reason: 'exit',
host: 'git.example.com',
hasToken: false,
stderr: 'SSL: certificate subject name does not match target host name',
});
expect(result.message).toContain('hostname does not match');
});
it('maps curl\'s IP-address SAN mismatch wording to the same clear TLS message', () => {
const result = classifyGitFailure({
transportFailure: true,
reason: 'exit',
host: 'git.example.com',
hasToken: false,
stderr: "SSL: no alternative certificate subject name matches target ipv4 address '172.18.0.1'",
});
expect(result.message).toContain('hostname does not match');
});
it('maps expired certificates distinctly', () => {
const result = classifyGitFailure({
transportFailure: true,
reason: 'exit',
host: 'git.example.com',
hasToken: false,
stderr: 'certificate has expired',
});
expect(result.message).toContain('expired');
});
it('maps unknown CA to private-CA guidance', () => {
const result = classifyGitFailure({
transportFailure: true,
reason: 'exit',
host: 'git.example.com',
hasToken: false,
stderr: 'SSL certificate problem: unable to get local issuer certificate',
});
expect(result.message).toContain('private CA');
});
it('maps redirect-scope and redirect stderr to credential-scope guidance', () => {
const scoped = classifyGitFailure({
transportFailure: true,
reason: 'redirect-scope',
host: 'git.example.com',
hasToken: true,
});
expect(scoped.message).toContain('redirected');
const stderr = classifyGitFailure({
transportFailure: true,
reason: 'exit',
host: 'git.example.com',
hasToken: true,
stderr: 'The requested URL returned error: 302',
});
expect(stderr.message).toContain('redirected');
});
});
@@ -0,0 +1,127 @@
/**
* Proves per-source CA bundles work without the process-wide NODE_EXTRA_CA_CERTS bridge.
*/
import { spawn } from 'child_process';
import { promises as fs, readFileSync } from 'fs';
import https from 'https';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { nativeGitTransport } from '../services/git/nativeGitTransport';
import { buildBareRepo } from './__helpers__/gitFixture';
import { requireGitBinary } from './__helpers__/externalDeps';
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
function serveRepo(bareDir: string): Promise<{ url: string; close: () => void }> {
return new Promise((resolve, reject) => {
const server = https.createServer(
{
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
},
(req, res) => {
const url = req.url ?? '/';
if (!url.startsWith('/repo.git/')) {
res.statusCode = 404;
res.end('unknown repo');
return;
}
const pathname = url.slice('/repo.git'.length).split('?')[0];
if (pathname === '/info/refs' && (req.method === 'GET' || req.method === 'POST')) {
const ps = spawn('git', ['upload-pack', '--stateless-rpc', '--advertise-refs', bareDir]);
let out = Buffer.alloc(0);
ps.stdout.on('data', (d: Buffer) => { out = Buffer.concat([out, d]); });
ps.on('close', (code) => {
if (code !== 0) {
res.statusCode = 500;
res.end('git upload-pack failed');
return;
}
res.setHeader('content-type', 'application/x-git-upload-pack-advertisement');
res.end(Buffer.concat([Buffer.from('001e# service=git-upload-pack\n0000'), out]));
});
return;
}
if (pathname === '/git-upload-pack' && req.method === 'POST') {
const ps = spawn('git', ['upload-pack', '--stateless-rpc', bareDir]);
res.setHeader('content-type', 'application/x-git-upload-pack-result');
ps.stdout.pipe(res);
req.pipe(ps.stdin);
return;
}
res.statusCode = 404;
res.end('unsupported');
},
);
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('server did not bind'));
return;
}
resolve({ url: `https://127.0.0.1:${address.port}/repo.git`, close: () => server.close() });
});
});
}
describe.skipIf(!requireGitBinary())('per-source private CA transport (real git)', () => {
let repoUrl: string;
let closeServer: () => void;
let prevExtraCaCerts: string | undefined;
const workspaces: string[] = [];
beforeAll(async () => {
const bareDir = buildBareRepo({ srcPrefix: 'sencho-ca-src-', barePrefix: 'sencho-ca-bare-', userEmail: 'ca-test@sencho.test', userName: 'Sencho CA Test' });
const served = await serveRepo(bareDir);
repoUrl = served.url;
closeServer = served.close;
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
delete process.env.NODE_EXTRA_CA_CERTS;
});
afterAll(() => {
closeServer?.();
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
});
afterEach(async () => {
await Promise.all(workspaces.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
});
it('clones a private-CA HTTPS repo when the per-source CA PEM is supplied', async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ca-ws-'));
workspaces.push(workspaceRoot);
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: 'main',
caBundlePem: CA_PEM,
workspaceRoot,
timeoutMs: 30_000,
});
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: 'main',
refKind: resolved.kind,
commitSha: resolved.commitSha,
caBundlePem: CA_PEM,
workspaceRoot,
maxBytes: 50 * 1024 * 1024,
});
expect(fetched.commitSha).toMatch(/^[0-9a-f]{40}$/);
});
it('fails TLS verification without a matching per-source CA when NODE_EXTRA_CA_CERTS is unset', async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ca-ws-'));
workspaces.push(workspaceRoot);
await expect(nativeGitTransport.resolveRef({
repoUrl,
ref: 'main',
workspaceRoot,
timeoutMs: 30_000,
})).rejects.toMatchObject({ transportFailure: true });
});
});
@@ -92,6 +92,7 @@ function makeClone(files: Record<string, string>): string {
}
const REPO = { repo_url: 'https://github.com/example/repo.git', branch: 'main' };
const NO_CANDIDATE_CLAIMS = { complete: true as const, dirs: new Set<string>() };
function seedGitSource(stackName: string): void {
DatabaseService.getInstance().upsertGitSource({
@@ -105,6 +106,7 @@ function seedGitSource(stackName: string): void {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -271,6 +273,7 @@ describe('promoteGeneration', () => {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -729,6 +732,59 @@ describe('promoteGeneration', () => {
});
describe('sweepManagedArea (crash recovery)', () => {
it('does not delete a candidate when its completion marker cannot be inspected', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'sweep-candidate-marker-io';
const candidateAbs = path.join(tmpDir, 'git-managed', '1', stackName, 'generations', 'candidate-marker-io');
const markerPath = path.join(candidateAbs, CANDIDATE_COMPLETE_MARKER);
fs.mkdirSync(candidateAbs, { recursive: true });
fs.writeFileSync(markerPath, 'complete');
const originalAccess = fs.promises.access.bind(fs.promises);
const accessSpy = vi.spyOn(fs.promises, 'access').mockImplementation(async (...args: Parameters<typeof fs.promises.access>) => {
if (String(args[0]) === markerPath) {
throw Object.assign(new Error('candidate marker permission denied'), { code: 'EACCES' });
}
return originalAccess(...args);
});
try {
await expect(svc.sweepManagedArea(stackName, {
repoUrl: REPO.repo_url,
branch: REPO.branch,
stackExists: true,
candidateClaims: NO_CANDIDATE_CLAIMS,
})).rejects.toThrow(/candidate marker permission denied/);
expect(fs.existsSync(candidateAbs)).toBe(true);
} finally {
accessSpy.mockRestore();
}
});
it('surfaces a generations-directory read failure', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'sweep-generations-read-io';
const generationsDir = path.join(tmpDir, 'git-managed', '1', stackName, 'generations');
fs.mkdirSync(generationsDir, { recursive: true });
const originalReaddir = fs.promises.readdir.bind(fs.promises);
const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (...args: Parameters<typeof fs.promises.readdir>) => {
if (String(args[0]) === generationsDir) {
throw Object.assign(new Error('generations directory unavailable'), { code: 'EIO' });
}
return originalReaddir(...args);
});
try {
await expect(svc.sweepManagedArea(stackName, {
repoUrl: REPO.repo_url,
branch: REPO.branch,
stackExists: true,
candidateClaims: NO_CANDIDATE_CLAIMS,
})).rejects.toThrow(/generations directory unavailable/);
} finally {
readdirSpy.mockRestore();
}
});
it('restores the previous applied generation when the marker matches the stack dir', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'sweep-restore';
@@ -761,7 +817,7 @@ describe('sweepManagedArea (crash recovery)', () => {
affected: ['app.env', 'compose.yaml'],
});
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n');
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
@@ -781,6 +837,7 @@ describe('sweepManagedArea (crash recovery)', () => {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -814,7 +871,7 @@ describe('sweepManagedArea (crash recovery)', () => {
affected: ['compose.yaml'],
});
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(readStackFile(stackName, 'compose.yaml')).toBe('OPERATOR FIXED ME\n');
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
@@ -849,7 +906,7 @@ describe('sweepManagedArea (crash recovery)', () => {
affected: ['compose.yaml'],
});
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n');
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
@@ -884,7 +941,7 @@ describe('sweepManagedArea (crash recovery)', () => {
affected: ['compose.yaml'],
});
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(readStackFile(stackName, 'compose.yaml')).toBe('NEW\n');
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
@@ -924,7 +981,7 @@ describe('sweepManagedArea (crash recovery)', () => {
affected: ['compose.yaml'],
});
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n');
const row = DatabaseService.getInstance().getGitSource(stackName);
@@ -966,7 +1023,7 @@ describe('sweepManagedArea (crash recovery)', () => {
affected: ['app.env', 'compose.yaml'],
});
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(readStackFile(stackName, 'compose.yaml')).toBe('NEW\n');
expect(readStackFile(stackName, 'app.env')).toBe('OPERATOR\n');
@@ -978,7 +1035,7 @@ describe('sweepManagedArea (crash recovery)', () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'sweep-orphan';
await svc.writeManifest(stackName, buildManifest(stackName, [managedEntry({ materializedPath: 'compose.yaml' })]));
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: false });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: false, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(await svc.readManifest(stackName, REPO.repo_url, REPO.branch)).toBeNull();
});
});
@@ -1056,7 +1113,7 @@ describe('detach crash recovery', () => {
writeStackFile(stackName, 'compose.yaml', 'services:\n web:\n image: nginx:new\n');
expect(await svc.stageManagedAreaForDetach(stackName)).toBe(true);
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(readStackFile(stackName, 'compose.yaml')).toBe(original.toString('utf8'));
const restored = await svc.readManifest(stackName, REPO.repo_url, REPO.branch);
@@ -1287,6 +1344,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -1299,7 +1357,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
});
fs.mkdirSync(path.join(tmpDir, 'git-managed', '1', stackName), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER), '{"v":3 torn', 'utf8');
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
expect(DatabaseService.getInstance().getGitSource(stackName)?.manifest_state).toBe('migration_required');
});
@@ -1321,7 +1379,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
}), 'utf8');
const stateSpy = vi.spyOn(DatabaseService.getInstance(), 'setGitSourceManifestState');
try {
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).resolves.toBeUndefined();
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).resolves.toBeUndefined();
expect(stateSpy).toHaveBeenCalledWith(stackName, null, 'migration_required', null);
} finally {
stateSpy.mockRestore();
@@ -1339,7 +1397,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
throw new Error('database unavailable');
});
try {
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).rejects.toThrow(/database unavailable/);
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).rejects.toThrow(/database unavailable/);
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
stateSpy.mockRestore();
@@ -1367,7 +1425,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
return originalAccess(...args);
});
try {
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).rejects.toThrow(/permission denied/);
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).rejects.toThrow(/permission denied/);
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
accessSpy.mockRestore();
@@ -0,0 +1,281 @@
/**
* Redirect policy against real HTTPS servers.
*
* These run in-process (no git subprocess), so they exercise the preflight
* walk itself: which destinations are approved, which are refused, and
* crucially whether a refused destination is contacted at all. Each fixture
* counts its own requests, so "rejected before contact" is asserted as an
* observed request count rather than inferred from the thrown error.
*/
import { readFileSync } from 'fs';
import https from 'https';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { looksLikeRedirectFailure, resolveRedirectedRepoUrl } from '../services/git/redirectPreflight';
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
const TLS_OPTS = {
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
};
interface Fixture {
port: number;
origin: string;
/** Every path this server was asked for, in order. */
hits: string[];
close: () => void;
}
const open: Fixture[] = [];
/** Start a TLS server whose handler may redirect; records every request path. */
function serve(handler: (url: string, res: import('http').ServerResponse) => void): Promise<Fixture> {
return new Promise((resolve, reject) => {
const hits: string[] = [];
const server = https.createServer(TLS_OPTS, (req, res) => {
hits.push(req.url ?? '');
handler(req.url ?? '', res);
});
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('fixture did not bind'));
return;
}
const fixture: Fixture = {
port: address.port,
origin: `https://127.0.0.1:${address.port}`,
hits,
close: () => server.close(),
};
open.push(fixture);
resolve(fixture);
});
});
}
/** Answers the ref-advertise with a 200 so a chain can terminate successfully. */
function ok(res: import('http').ServerResponse): void {
res.statusCode = 200;
res.end('refs');
}
afterEach(() => {
open.splice(0).forEach((f) => f.close());
});
describe('redirect preflight', () => {
it('approves a same-origin redirect and returns the relocated repository URL', async () => {
const server = await serve((url, res) => {
if (url.startsWith('/old.git/')) {
res.statusCode = 302;
res.setHeader('location', url.replace('/old.git/', '/new.git/'));
res.end();
return;
}
ok(res);
});
const resolved = await resolveRedirectedRepoUrl({
repoUrl: `${server.origin}/old.git`,
hasToken: true,
reportHost: '127.0.0.1',
caPem: CA_PEM,
});
expect(resolved).toBe(`${server.origin}/new.git`);
});
it('approves a same-origin redirect expressed as an absolute Location', async () => {
// The case above sends a relative Location; this one sends the fully
// qualified form, so both branches of the Location parser are covered.
let origin = '';
const server = await serve((url, res) => {
if (url.startsWith('/old.git/')) {
res.statusCode = 302;
res.setHeader('location', `${origin}${url.replace('/old.git/', '/moved.git/')}`);
res.end();
return;
}
ok(res);
});
origin = server.origin;
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${server.origin}/old.git`,
hasToken: false,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).resolves.toBe(`${server.origin}/moved.git`);
});
it('inserts the ref-advertise suffix into the path, not after an existing query string', async () => {
// A repoUrl of `/repo.git?temp=1` (a signed URL, say) must not turn
// into `/repo.git?temp=1/info/refs?service=...`: the query has to be
// replaced, not appended to.
const server = await serve((_url, res) => ok(res));
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${server.origin}/repo.git?temp=1`,
hasToken: true,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).resolves.toBeNull();
expect(server.hits).toEqual(['/repo.git/info/refs?service=git-upload-pack']);
});
it('refuses a cross-origin redirect without ever contacting the destination', async () => {
// The destination is a fully working server: if the policy leaked, the
// chain would resolve successfully rather than merely failing, so a
// rejection here cannot be an accident of the target being broken.
const destination = await serve((_url, res) => ok(res));
const source = await serve((url, res) => {
res.statusCode = 302;
res.setHeader('location', `${destination.origin}${url}`);
res.end();
});
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${source.origin}/repo.git`,
hasToken: true,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).rejects.toMatchObject({ transportFailure: true, reason: 'redirect-scope' });
expect(source.hits).toHaveLength(1);
expect(destination.hits).toHaveLength(0);
});
it('refuses a redirect that only changes the port, destination uncontacted', async () => {
const destination = await serve((_url, res) => ok(res));
const source = await serve((url, res) => {
res.statusCode = 302;
res.setHeader('location', `https://127.0.0.1:${destination.port}${url}`);
res.end();
});
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${source.origin}/repo.git`,
hasToken: false,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).rejects.toMatchObject({ reason: 'redirect-scope' });
expect(destination.hits).toHaveLength(0);
});
it('refuses a downgrade to plain http', async () => {
const source = await serve((_url, res) => {
res.statusCode = 302;
res.setHeader('location', 'http://127.0.0.1:9/repo.git/info/refs?service=git-upload-pack');
res.end();
});
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${source.origin}/repo.git`,
hasToken: true,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).rejects.toMatchObject({ reason: 'redirect-scope' });
});
it('refuses a chain longer than the hop cap instead of following it forever', async () => {
const source = await serve((url, res) => {
res.statusCode = 302;
res.setHeader('location', `${url}x`);
res.end();
});
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${source.origin}/repo.git`,
hasToken: false,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).rejects.toMatchObject({ reason: 'redirect-scope' });
});
it('refuses a same-origin redirect that leaves the ref-advertise endpoint', async () => {
// Only the path prefix may move. A destination that no longer ends in
// /info/refs is not this repository relocating.
const source = await serve((_url, res) => {
res.statusCode = 302;
res.setHeader('location', '/somewhere/else');
res.end();
});
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${source.origin}/repo.git`,
hasToken: true,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).rejects.toMatchObject({ reason: 'redirect-scope' });
});
it('returns null when the source does not redirect, leaving git\'s own error intact', async () => {
const server = await serve((_url, res) => {
res.statusCode = 404;
res.end('nope');
});
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${server.origin}/repo.git`,
hasToken: true,
reportHost: '127.0.0.1',
caPem: CA_PEM,
}),
).resolves.toBeNull();
});
it('recognises the stderr git actually emits for a refused redirect', () => {
// Pinned to git's real wording. git-remote-http reports a refused
// redirect as an HTTP error and never prints a Location header, which
// is why the destination has to be resolved by probing rather than by
// reading it out of stderr. If a git upgrade rephrases these, this
// fails here instead of silently making relocated repositories
// unreachable in production.
expect(looksLikeRedirectFailure(
"fatal: unable to access 'https://git.example.com/repo.git/': The requested URL returned error: 302",
)).toBe(true);
expect(looksLikeRedirectFailure('warning: redirecting to https://git.example.com/new.git/')).toBe(true);
expect(looksLikeRedirectFailure(
"fatal: unable to access 'https://git.example.com/repo.git/': The requested URL returned error: 404",
)).toBe(false);
expect(looksLikeRedirectFailure('fatal: Authentication failed')).toBe(false);
});
it('returns null when the probe itself cannot complete', async () => {
// Untrusted certificate: the chain cannot be proven safe, so no retry
// is authorised and the caller keeps git's original failure.
const server = await serve((_url, res) => {
res.statusCode = 302;
res.setHeader('location', '/other.git/info/refs?service=git-upload-pack');
res.end();
});
await expect(
resolveRedirectedRepoUrl({
repoUrl: `${server.origin}/repo.git`,
hasToken: true,
reportHost: '127.0.0.1',
}),
).resolves.toBeNull();
});
});
@@ -0,0 +1,256 @@
/**
* Redirect behaviour end to end through real git against real TLS servers.
*
* The matrix pins both halves of the contract that the transport has to hold
* at once: a repository that relocates on its own host stays usable, and a
* redirect that leaves that host is refused without the destination being
* contacted or a credential being offered to it.
*
* Every fixture records the requests it receives, including the Authorization
* header, so credential scope and "never contacted" are asserted from what the
* servers actually observed rather than inferred from the thrown error.
*/
import { spawn, spawnSync } from 'child_process';
import { promises as fs, readFileSync } from 'fs';
import https from 'https';
import os from 'os';
import path from 'path';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { nativeGitTransport } from '../services/git/nativeGitTransport';
import { buildBareRepo } from './__helpers__/gitFixture';
import { requireGitBinary } from './__helpers__/externalDeps';
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
const TLS_OPTS = {
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
};
const GOOD_TOKEN = 'correct-horse-battery-staple';
interface Request { url: string; authorization: string | null }
interface Fixture {
origin: string;
requests: Request[];
close: () => void;
}
const openFixtures: Fixture[] = [];
const workspaces: string[] = [];
/**
* A TLS git server. Paths under `/old.git/` are 302-redirected by `redirect`
* (returning an absolute URL, or null to serve normally); paths under
* `/new.git/` are served from the bare repo, behind Basic auth when
* `requireAuth` is set.
*/
function serveGit(opts: {
bareDir: string;
redirect?: (fixture: Fixture, url: string) => string | null;
requireAuth?: boolean;
}): Promise<Fixture> {
return new Promise((resolve, reject) => {
const requests: Request[] = [];
let self: Fixture;
const server = https.createServer(TLS_OPTS, (req, res) => {
const url = req.url ?? '/';
requests.push({ url, authorization: req.headers.authorization ?? null });
if (url.startsWith('/old.git/') && opts.redirect) {
const target = opts.redirect(self, url);
if (target) {
res.statusCode = 302;
res.setHeader('location', target);
res.end();
return;
}
}
if (!url.startsWith('/new.git/')) {
res.statusCode = 404;
res.end('unknown repo');
return;
}
if (opts.requireAuth) {
const expected = `Basic ${Buffer.from(`x-access-token:${GOOD_TOKEN}`).toString('base64')}`;
if (req.headers.authorization !== expected) {
res.statusCode = 401;
res.setHeader('www-authenticate', 'Basic realm="git"');
res.end('unauthorized');
return;
}
}
const pathname = url.slice('/new.git'.length).split('?')[0];
if (pathname === '/info/refs') {
const ps = spawn('git', ['upload-pack', '--stateless-rpc', '--advertise-refs', opts.bareDir]);
let out = Buffer.alloc(0);
ps.stdout.on('data', (d: Buffer) => { out = Buffer.concat([out, d]); });
ps.on('close', (code) => {
if (code !== 0) {
res.statusCode = 500;
res.end('git upload-pack failed');
return;
}
res.setHeader('content-type', 'application/x-git-upload-pack-advertisement');
res.end(Buffer.concat([Buffer.from('001e# service=git-upload-pack\n0000'), out]));
});
return;
}
if (pathname === '/git-upload-pack') {
const ps = spawn('git', ['upload-pack', '--stateless-rpc', opts.bareDir]);
res.setHeader('content-type', 'application/x-git-upload-pack-result');
ps.stdout.pipe(res);
req.pipe(ps.stdin);
return;
}
res.statusCode = 404;
res.end('unsupported');
});
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('fixture did not bind'));
return;
}
self = {
origin: `https://127.0.0.1:${address.port}`,
requests,
close: () => server.close(),
};
openFixtures.push(self);
resolve(self);
});
});
}
async function workspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-redir-ws-'));
workspaces.push(dir);
return dir;
}
describe.skipIf(!requireGitBinary())('redirect destination revalidation (real git)', () => {
let bareDir: string;
let headSha: string;
let prevExtraCaCerts: string | undefined;
beforeAll(async () => {
bareDir = buildBareRepo({
srcPrefix: 'sencho-redir-src-',
barePrefix: 'sencho-redir-bare-',
userEmail: 'redir-test@sencho.test',
userName: 'Sencho Redirect Test',
});
headSha = spawnSync('git', ['-C', bareDir, 'rev-parse', 'main'], { encoding: 'utf8' })
.stdout.trim().toLowerCase();
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
delete process.env.NODE_EXTRA_CA_CERTS;
});
afterEach(async () => {
openFixtures.splice(0).forEach((f) => f.close());
await Promise.all(workspaces.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
});
it('resolves a ref through an unauthenticated same-host redirect', async () => {
const server = await serveGit({
bareDir,
redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`,
});
const resolved = await nativeGitTransport.resolveRef({
repoUrl: `${server.origin}/old.git`,
ref: 'main',
caBundlePem: CA_PEM,
workspaceRoot: await workspace(),
timeoutMs: 30_000,
});
expect(resolved).toEqual({ commitSha: headSha, kind: 'branch' });
});
it('resolves a ref through an authenticated same-host redirect and sends the token to the relocated path', async () => {
const server = await serveGit({
bareDir,
requireAuth: true,
redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`,
});
const resolved = await nativeGitTransport.resolveRef({
repoUrl: `${server.origin}/old.git`,
ref: 'main',
token: GOOD_TOKEN,
caBundlePem: CA_PEM,
workspaceRoot: await workspace(),
timeoutMs: 30_000,
});
expect(resolved).toEqual({ commitSha: headSha, kind: 'branch' });
// The credential is scoped to the host, not to the original path, so
// the relocated endpoint on that same host must have received it.
const authorized = server.requests.filter(
(r) => r.url.startsWith('/new.git/') && r.authorization !== null,
);
expect(authorized.length).toBeGreaterThan(0);
});
it('reports an authentication failure, not a redirect failure, when the token is wrong behind a same-host redirect', async () => {
const server = await serveGit({
bareDir,
requireAuth: true,
redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`,
});
await expect(
nativeGitTransport.resolveRef({
repoUrl: `${server.origin}/old.git`,
ref: 'main',
token: 'not-the-right-token',
caBundlePem: CA_PEM,
workspaceRoot: await workspace(),
timeoutMs: 30_000,
}),
).rejects.toMatchObject({ transportFailure: true, reason: 'exit' });
});
it('refuses a cross-host redirect without contacting the destination or offering it the token', async () => {
// The destination is a fully working repository server. If the policy
// leaked, this fetch would SUCCEED, so the rejection below cannot be an
// artefact of a target that was broken anyway.
const destination = await serveGit({ bareDir });
const source = await serveGit({
bareDir,
redirect: (_self, url) => `${destination.origin}${url.replace('/old.git/', '/new.git/')}`,
});
await expect(
nativeGitTransport.resolveRef({
repoUrl: `${source.origin}/old.git`,
ref: 'main',
token: 'sensitive-pat-do-not-leak',
caBundlePem: CA_PEM,
workspaceRoot: await workspace(),
timeoutMs: 30_000,
}),
).rejects.toMatchObject({ transportFailure: true, reason: 'redirect-scope' });
// Settle, so a late request would still be counted rather than raced past.
await new Promise((r) => setTimeout(r, 250));
expect(destination.requests).toHaveLength(0);
});
it('proves the cross-host destination would otherwise serve the same ref', async () => {
const destination = await serveGit({ bareDir });
const resolved = await nativeGitTransport.resolveRef({
repoUrl: `${destination.origin}/new.git`,
ref: 'main',
caBundlePem: CA_PEM,
workspaceRoot: await workspace(),
timeoutMs: 30_000,
});
expect(resolved.commitSha).toBe(headSha);
});
});
@@ -13,6 +13,38 @@ const mockMarkReconciling = vi.fn().mockReturnValue(true);
const mockMarkImmediateVerified = vi.fn().mockReturnValue(true);
const mockGet = vi.fn();
const mockCompensate = vi.fn();
const mockGitOpsApplication = {
id: 'gitops-app',
lifecycle_status: 'active',
stack_name: 'app',
candidate_generation_id: null,
};
const mockGitOpsStore = {
getLiveDirectApplication: vi.fn().mockReturnValue(mockGitOpsApplication),
getApplication: vi.fn().mockReturnValue(mockGitOpsApplication),
getGeneration: vi.fn().mockReturnValue(undefined),
getSettledAttempt: vi.fn().mockReturnValue(undefined),
};
const mockGitOpsTransitions = {
allocateReconcileAttempt: vi.fn().mockReturnValue({ operationId: 'gitops-app:attempt:1', reserved: true }),
settleReconcileAttempt: vi.fn().mockReturnValue({ settled: true }),
};
vi.mock('../services/gitops/store', () => ({
GitOpsStore: {
getInstance: () => mockGitOpsStore,
},
}));
vi.mock('../services/gitops/transitions', async () => {
const actual = await vi.importActual<typeof import('../services/gitops/transitions')>('../services/gitops/transitions');
return {
...actual,
GitOpsTransitions: {
getInstance: () => mockGitOpsTransitions,
},
};
});
vi.mock('../services/StackUpdateRecoveryService', () => ({
StackUpdateRecoveryService: {
@@ -129,10 +161,6 @@ vi.mock('../services/DatabaseService', () => ({
setGitSourceLastPlan: mockSetGitSourceLastPlan,
addNotificationHistory: mockAddNotificationHistory,
getStackProjectEnvFiles: vi.fn().mockReturnValue([]),
// The apply path now asks whether this stack has a GitOps application.
// These fixtures predate the revision-state model, so the lookup finds
// nothing and every GitOps producer stays a no-op, which is exactly the
// behavior an install with pre-existing Git stacks gets.
getDb: () => ({
prepare: () => ({ get: () => undefined, all: () => [], run: () => ({ changes: 0 }) }),
transaction: (fn: () => unknown) => () => fn(),
@@ -197,7 +225,7 @@ describe('git-source apply recovery (R1)', () => {
v: 4,
files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' },
contextDir: null,
candidateRelPath: 'generations/cand',
candidateRelPath: 'generations/candidate-abc1234deadbeef',
inventory: {
inputs: [],
refusals: [],
@@ -246,7 +274,7 @@ describe('git-source apply recovery (R1)', () => {
version: 4,
files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }],
contextDir: null,
candidateRelPath: 'generations/cand',
candidateRelPath: 'generations/candidate-abc1234deadbeef',
inventory: { inputs: [], refusals: [], buildContexts: [] },
planFingerprint: 'fp-test',
planSchemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION,
+22 -2
View File
@@ -10,7 +10,7 @@
import { describe, it, expect, vi } from 'vitest';
import type { Response } from 'express';
import { gitSourceStatus, sendGitSourceError, webhookPullStatus } from '../utils/gitSourceHttp';
import { GitSourceError } from '../services/GitSourceService';
import { GitSourceError, type GitSourceErrorCode } from '../services/GitSourceService';
describe('gitSourceStatus', () => {
it('maps AUTH_FAILED to 400, never 401', () => {
@@ -36,6 +36,10 @@ describe('gitSourceStatus', () => {
expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504);
});
it('maps RATE_LIMITED to 429, not to the auth or generic status', () => {
expect(gitSourceStatus('RATE_LIMITED')).toBe(429);
});
it('maps PLAN_FINGERPRINT_REQUIRED to 400', () => {
expect(gitSourceStatus('PLAN_FINGERPRINT_REQUIRED')).toBe(400);
});
@@ -47,9 +51,25 @@ describe('gitSourceStatus', () => {
expect(gitSourceStatus('PLAN_UNAVAILABLE')).toBe(409);
});
it('maps unknown codes to 400', () => {
it('maps GIT_ERROR to 400', () => {
expect(gitSourceStatus('GIT_ERROR')).toBe(400);
});
it('maps OPERATION_IN_FLIGHT to 409', () => {
expect(gitSourceStatus('OPERATION_IN_FLIGHT')).toBe(409);
});
it('has exactly one explicit mapping for every GitSourceErrorCode', () => {
const codes: GitSourceErrorCode[] = [
'REPO_NOT_FOUND', 'AUTH_FAILED', 'REF_NOT_FOUND', 'REF_DELETED', 'UNSUPPORTED_REF',
'SSH_HOST_KEY_FAILED', 'FILE_NOT_FOUND', 'RATE_LIMITED', 'NETWORK_TIMEOUT', 'GIT_ERROR', 'STALE_PLAN',
'PLAN_FINGERPRINT_REQUIRED', 'PLAN_BLOCKED', 'LEGACY_PENDING', 'PLAN_UNAVAILABLE',
'OPERATION_IN_FLIGHT',
];
for (const code of codes) {
expect(typeof gitSourceStatus(code)).toBe('number');
}
});
});
describe('webhookPullStatus', () => {
+377 -65
View File
@@ -16,7 +16,7 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import fs from 'fs';
import fs, { readFileSync } from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { REF_MAX_LEN } from '../services/git/nativeGitTransport';
@@ -26,70 +26,13 @@ import { ComposeService } from '../services/ComposeService';
import { GitSourceService, GitSourceError } from '../services/GitSourceService';
import { GitOpsStore } from '../services/gitops/store';
import { GitOpsTransitions } from '../services/gitops/transitions';
import { deliveryKey } from '../services/gitops/triggers';
import { insertHistory } from '../services/gitops/history';
import type { GitOpsApplicationRow } from '../services/gitops/types';
import { PROXY_DEPLOY_ACTOR_HEADER, PROXY_DEPLOY_SOURCE_HEADER } from '../services/license-headers';
/** A minimal live Direct application row for GitOps read-path fixtures. */
function directApplicationFixture(id: string, stackName: string): GitOpsApplicationRow {
return {
id,
lifecycle_key: `direct:${stackName}`,
lifecycle_status: 'active',
target_mode: 'direct',
stack_name: stackName,
blueprint_id: null,
configured_repo_url: 'https://github.com/example/repo.git',
repo_identity_json: '{"host":"github.com","pathname":"/example/repo.git"}',
configured_ref: 'main',
compose_paths_json: '["compose.yaml"]',
context_dir: null,
sync_env: 0,
env_path: null,
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
review_required: 0,
artifact_set_id: null,
latest_artifact_set_id: null,
intent_revision_id: null,
rollout_candidate_id: null,
rollout_generation_id: null,
source_acceptance_ref: null,
placement_approval_ref: null,
rollout_authorization_ref: null,
legacy_combined_approval_ref: null,
preflight_fingerprint: null,
latest_operation_id: null,
active_operation_id: null,
active_operation_stage: null,
active_operation_at: null,
active_generation_id: null,
pause_at: null,
pause_reason: null,
partial_json: null,
failure_stage: null,
failure_class: null,
failure_at: null,
retry_at: null,
retry_count: 0,
suspended_at: null,
recovery_ref: null,
recovery_phase: null,
interruption_stage: null,
interruption_at: null,
interruption_operation_id: null,
interruption_generation_id: null,
evidence_fresh_at: null,
evidence_limitations_json: null,
created_at: 1,
updated_at: 1,
};
}
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
import { directApplicationFixture } from './helpers/gitopsFixtures';
import { ROLE_PERMISSIONS } from '../middleware/permissions';
// ── Hoisted mocks (must come before importing the app) ─────────────────
@@ -114,6 +57,7 @@ function seedGitSource(stackName: string): void {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -133,8 +77,17 @@ function adminToken(): string {
return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
function viewerToken(): string {
return jwt.sign({ username: 'viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
function nodeAdminToken(): string {
return jwt.sign({ username: 'node-admin', role: 'node-admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
beforeAll(async () => {
tmpDir = await setupTestDb();
DatabaseService.getInstance().addUser({ username: 'node-admin', password_hash: 'test', role: 'node-admin' });
({ app } = await import('../index'));
// Seed a real stack directory so the PUT handler's existence guard is satisfied
@@ -218,6 +171,20 @@ describe('POST /api/git-sources/browse: URL validation', () => {
expect(listRepoTree).not.toHaveBeenCalled();
listRepoTree.mockRestore();
});
it('rejects repository hosts that resolve to an unsafe address', async () => {
const res = await withLoopbackTargetProtection(() => request(app)
.post('/api/git-sources/browse')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://127.0.0.1:9999/repo.git',
branch: 'main',
auth_type: 'none',
}));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not allowed/i);
});
});
describe('PUT /api/stacks/:stackName/git-source — max-length caps', () => {
@@ -690,6 +657,253 @@ describe('POST /api/stacks/:stackName/git-source/webhook-pull status codes', ()
expect(res.status).toBe(200);
pullSpy.mockRestore();
});
it('passes a remote webhook delivery id into the durable pull path', async () => {
seedGitSource('webhook-delivery-id');
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull')
.mockResolvedValue({ status: 'success', message: 'Pending update ready at abc1234.' });
const res = await request(app)
.post('/api/stacks/webhook-delivery-id/git-source/webhook-pull')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ deliveryId: 'webhook:42:provider-delivery-1' });
expect(res.status).toBe(200);
expect(pullSpy).toHaveBeenCalledWith('webhook-delivery-id', true, 'webhook:42:provider-delivery-1');
pullSpy.mockRestore();
});
it('requires stack:deploy when a redelivery carries a persisted deploy intent', async () => {
const stackName = 'webhook-delivery-deploy-auth';
const applicationId = 'webhook-delivery-deploy-auth-app';
const deliveryId = 'webhook:control:42:provider-delivery-deploy';
seedGitSource(stackName);
GitOpsStore.getInstance().insertApplication(directApplicationFixture(applicationId, stackName));
GitOpsTransitions.getInstance().reserveReconcileAttempt(
applicationId,
{
operationId: deliveryKey('webhook', 'fetch', deliveryId),
actor: 'system:webhook',
trigger: 'webhook',
at: Date.now(),
},
undefined,
{ autoApply: true, deploy: true },
);
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull');
const originalPermissions = ROLE_PERMISSIONS['node-admin'];
ROLE_PERMISSIONS['node-admin'] = originalPermissions.filter((permission) => permission !== 'stack:deploy');
try {
const res = await request(app)
.post(`/api/stacks/${stackName}/git-source/webhook-pull`)
.set('Authorization', `Bearer ${nodeAdminToken()}`)
.send({ deliveryId });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
expect(pullSpy).not.toHaveBeenCalled();
} finally {
ROLE_PERMISSIONS['node-admin'] = originalPermissions;
pullSpy.mockRestore();
}
});
it('requires stack:deploy when a first delivery is configured to auto-apply and deploy', async () => {
const stackName = 'webhook-first-delivery-deploy-auth';
seedGitSource(stackName);
DatabaseService.getInstance().getDb()
.prepare('UPDATE stack_git_sources SET auto_apply_on_webhook = 1, auto_deploy_on_apply = 1 WHERE stack_name = ?')
.run(stackName);
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull');
const originalPermissions = ROLE_PERMISSIONS['node-admin'];
ROLE_PERMISSIONS['node-admin'] = originalPermissions.filter((permission) => permission !== 'stack:deploy');
try {
const res = await request(app)
.post(`/api/stacks/${stackName}/git-source/webhook-pull`)
.set('Authorization', `Bearer ${nodeAdminToken()}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
expect(pullSpy).not.toHaveBeenCalled();
} finally {
ROLE_PERMISSIONS['node-admin'] = originalPermissions;
pullSpy.mockRestore();
}
});
it.each([
['an object', { nested: true }],
['a blank string', ' '],
['a string over 512 characters', 'x'.repeat(513)],
])('rejects %s as a remote webhook delivery id', async (_caseName, deliveryId) => {
seedGitSource('webhook-delivery-id-invalid');
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull');
try {
const res = await request(app)
.post('/api/stacks/webhook-delivery-id-invalid/git-source/webhook-pull')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ deliveryId });
expect(res.status).toBe(400);
expect(pullSpy).not.toHaveBeenCalled();
} finally {
pullSpy.mockRestore();
}
});
});
describe('POST /api/stacks/:stackName/git-source/suspend', () => {
it('returns 401 without auth', async () => {
const res = await request(app).post('/api/stacks/existing-stack/git-source/suspend');
expect(res.status).toBe(401);
});
it('returns 400 for an invalid stack name', async () => {
const res = await request(app)
.post('/api/stacks/..%2fescape/git-source/suspend')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect([400, 404]).toContain(res.status);
});
it('passes the reason through and returns the normalized result', async () => {
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend')
.mockResolvedValue({ outcome: 'suspended', reason: 'Reconciliation is suspended: maintenance', nextAction: 'resume' });
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/suspend')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ reason: 'maintenance' });
expect(res.status).toBe(200);
expect(res.body.outcome).toBe('suspended');
expect(suspendSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ reason: 'maintenance' }));
suspendSpy.mockRestore();
});
it('omits reason when none is given in the body', async () => {
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend')
.mockResolvedValue({ outcome: 'suspended', reason: 'Reconciliation is suspended.', nextAction: 'resume' });
await request(app)
.post('/api/stacks/existing-stack/git-source/suspend')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect(suspendSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ reason: undefined }));
suspendSpy.mockRestore();
});
it('maps a refused suspend to 409', async () => {
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend')
.mockRejectedValue(new GitSourceError('OPERATION_IN_FLIGHT', 'Cannot suspend existing-stack: source is not live'));
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/suspend')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect(res.status).toBe(409);
suspendSpy.mockRestore();
});
it('denies without the stack:edit permission', async () => {
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/suspend')
.set('Authorization', `Bearer ${viewerToken()}`)
.send({});
expect([401, 403]).toContain(res.status);
});
it('rejects an oversized reason with 400', async () => {
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend');
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/suspend')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ reason: 'x'.repeat(513) });
expect(res.status).toBe(400);
expect(suspendSpy).not.toHaveBeenCalled();
suspendSpy.mockRestore();
});
});
describe('POST /api/stacks/:stackName/git-source/resume', () => {
it('returns 401 without auth', async () => {
const res = await request(app).post('/api/stacks/existing-stack/git-source/resume');
expect(res.status).toBe(401);
});
it('returns 400 for an invalid stack name', async () => {
const res = await request(app)
.post('/api/stacks/..%2fescape/git-source/resume')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect([400, 404]).toContain(res.status);
});
it('returns the normalized result', async () => {
const resumeSpy = vi.spyOn(GitSourceService.getInstance(), 'resume')
.mockResolvedValue({ outcome: 'no_source_change', reason: 'ok', nextAction: 'none' });
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/resume')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect(res.status).toBe(200);
expect(res.body.outcome).toBe('no_source_change');
expect(resumeSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ actor: expect.any(String) }));
resumeSpy.mockRestore();
});
it('denies without the stack:edit permission', async () => {
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/resume')
.set('Authorization', `Bearer ${viewerToken()}`)
.send({});
expect([401, 403]).toContain(res.status);
});
});
describe('POST /api/stacks/:stackName/git-source/retry', () => {
it('returns 401 without auth', async () => {
const res = await request(app).post('/api/stacks/existing-stack/git-source/retry');
expect(res.status).toBe(401);
});
it('returns 400 for an invalid stack name', async () => {
const res = await request(app)
.post('/api/stacks/..%2fescape/git-source/retry')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect([400, 404]).toContain(res.status);
});
it('returns the normalized result', async () => {
const retrySpy = vi.spyOn(GitSourceService.getInstance(), 'retry')
.mockResolvedValue({ outcome: 'candidate_already_fetched', reason: 'ok', nextAction: 'none' });
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/retry')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect(res.status).toBe(200);
expect(res.body.outcome).toBe('candidate_already_fetched');
expect(retrySpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ actor: expect.any(String) }));
retrySpy.mockRestore();
});
it('maps an unexpected failure to 500', async () => {
const retrySpy = vi.spyOn(GitSourceService.getInstance(), 'retry')
.mockRejectedValue(new Error('unexpected'));
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/retry')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect(res.status).toBe(500);
retrySpy.mockRestore();
});
it('denies without the stack:edit permission', async () => {
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/retry')
.set('Authorization', `Bearer ${viewerToken()}`)
.send({});
expect([401, 403]).toContain(res.status);
});
});
describe('DELETE /api/stacks/:stackName/git-source, detach/export contract', () => {
@@ -1162,6 +1376,7 @@ describe('stack_git_sources manifest cache columns', () => {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -1755,6 +1970,15 @@ describe('POST /api/git-sources/ssh-host-key', () => {
expect(res.body.error).toMatch(/SSH repository URL/i);
});
it('rejects host-key probes to an unsafe address', async () => {
const res = await withLoopbackTargetProtection(() => request(app)
.post('/api/git-sources/ssh-host-key')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ repo_url: 'ssh://git@127.0.0.1:22/example/repo.git' }));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not allowed/i);
});
it('returns scanned host keys for an SSH repository URL', async () => {
const scanHostKeys = vi.spyOn(
await import('../services/git/sshTrust'),
@@ -1769,12 +1993,13 @@ describe('POST /api/git-sources/ssh-host-key', () => {
const res = await request(app)
.post('/api/git-sources/ssh-host-key')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ repo_url: 'git@github.com:example/repo.git' });
.send({ repo_url: 'git@pinned.example:example/repo.git' });
expect(res.status).toBe(200);
expect(res.body.host).toBe('github.com');
expect(res.body.host).toBe('pinned.example');
expect(res.body.port).toBe(22);
expect(res.body.keys).toHaveLength(1);
expect(res.body.keys[0].fingerprint).toBe('SHA256:fixtureFingerprint');
expect(scanHostKeys).toHaveBeenCalledWith('pinned.example', 22, '93.184.216.34');
scanHostKeys.mockRestore();
});
@@ -1820,7 +2045,7 @@ describe('POST /api/git-sources/ssh-host-key', () => {
const res = await request(app)
.post('/api/git-sources/ssh-host-key')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ repo_url: 'ssh://git@git.example.com:2222/org/repo.git' });
.send({ repo_url: 'ssh://git@github.com:2222/org/repo.git' });
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/Git source operation failed/i);
scanHostKeys.mockRestore();
@@ -1952,6 +2177,7 @@ describe('SSH deploy-key route validation', () => {
encrypted_deploy_key: CryptoService.getInstance().encrypt(deployKey),
ssh_known_hosts_entry: knownHosts,
ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint',
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -1974,4 +2200,90 @@ describe('SSH deploy-key route validation', () => {
expect(serialized).not.toContain(deployKey);
expect(serialized).not.toContain('encrypted_deploy_key');
});
it('PUT stores a custom CA bundle and GET exposes has_ca_bundle without returning PEM', async () => {
const stackName = 'https-ca-stack';
const composeDir = process.env.COMPOSE_DIR!;
fs.mkdirSync(path.join(composeDir, stackName), { recursive: true });
fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n');
const pem = readFileSync(path.join(process.cwd(), '..', 'e2e', 'fixtures', 'git-ca.pem'), 'utf8');
const fetchFromGit = vi.spyOn(GitSourceService.getInstance(), 'fetchFromGit')
.mockResolvedValue({
composeFiles: [{ path: 'compose.yaml', content: 'services:\n x:\n image: nginx\n' }],
envContent: null,
commitSha: 'a'.repeat(40),
resolvedRefKind: 'branch',
warnings: [],
});
const res = await request(app)
.put(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://git.example.com/org/repo.git',
branch: 'main',
compose_paths: ['compose.yaml'],
auth_type: 'none',
ca_bundle: pem,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
});
expect(res.status).toBe(200);
expect(res.body.has_ca_bundle).toBe(true);
expect(JSON.stringify(res.body)).not.toContain('BEGIN CERTIFICATE');
fetchFromGit.mockRestore();
});
it('PUT with remove_ca_bundle=true clears a previously stored CA bundle', async () => {
const stackName = 'https-ca-revoke-stack';
const composeDir = process.env.COMPOSE_DIR!;
fs.mkdirSync(path.join(composeDir, stackName), { recursive: true });
fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n');
const pem = readFileSync(path.join(process.cwd(), '..', 'e2e', 'fixtures', 'git-ca.pem'), 'utf8');
const fetchFromGit = vi.spyOn(GitSourceService.getInstance(), 'fetchFromGit')
.mockResolvedValue({
composeFiles: [{ path: 'compose.yaml', content: 'services:\n x:\n image: nginx\n' }],
envContent: null,
commitSha: 'b'.repeat(40),
resolvedRefKind: 'branch',
warnings: [],
});
// Step 1: store a CA bundle.
const storeRes = await request(app)
.put(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://git.example.com/org/repo.git',
branch: 'main',
compose_paths: ['compose.yaml'],
auth_type: 'none',
ca_bundle: pem,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
});
expect(storeRes.status).toBe(200);
expect(storeRes.body.has_ca_bundle).toBe(true);
// Step 2: explicit removal (textarea left empty, UI sets the flag).
const revokeRes = await request(app)
.put(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://git.example.com/org/repo.git',
branch: 'main',
compose_paths: ['compose.yaml'],
auth_type: 'none',
remove_ca_bundle: true,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
});
expect(revokeRes.status).toBe(200);
expect(revokeRes.body.has_ca_bundle).toBe(false);
expect(JSON.stringify(revokeRes.body)).not.toContain('BEGIN CERTIFICATE');
// Step 3: GET should confirm the row no longer carries a CA bundle.
const getRes = await request(app)
.get(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(getRes.status).toBe(200);
expect(getRes.body.has_ca_bundle).toBe(false);
fetchFromGit.mockRestore();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
/**
* Validates the Git transport support matrix (docs/git-transport-support.yaml)
* against the reality it claims to describe, so a published claim can never
* silently outrun its evidence.
*
* The renderer this test imports (backend/scripts/git-support-matrix/*)
* lives outside backend's tsconfig `rootDir` (pinned to `src`), the same
* constraint git-transport-auth.integration.test.ts documents for its own
* cross-directory fixture reuse. A static `import` there would fail
* `tsc --noEmit`; `require()` at runtime does not, since TS never has to
* resolve or type-check a file it was not statically asked to include.
*/
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import ts from 'typescript';
import { gitSourceStatus } from '../utils/gitSourceHttp';
import type { GitSourceErrorCode } from '../services/GitSourceService';
import { resolveTestHandle } from './__helpers__/testHandleResolver';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const matrixRenderer = require('../../scripts/git-support-matrix/render');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { loadClaimSet, REPO_ROOT } = require('../../scripts/git-support-matrix/loadClaimSet');
const CLOSED_ENUMS = {
transport: ['https', 'ssh'],
ref: ['branch', 'tag', 'sha'],
auth: ['none', 'pat', 'deploy-key'],
host: ['github', 'gitlab', 'gitea', 'forgejo', 'bitbucket', 'generic'],
ca: ['system', 'per-source', 'not-applicable'],
node_path: ['local', 'direct-proxy', 'pilot'],
port: ['default', 'nonstandard'],
support: ['supported', 'unsupported', 'unverified'],
};
const REQUIRED_CLAIM_KEYS = ['id', 'transport', 'ref', 'auth', 'host', 'ca', 'node_path', 'support', 'qualifiers'];
const OPTIONAL_CLAIM_KEYS = ['port', 'evidence'];
const ALLOWED_CLAIM_KEYS = new Set([...REQUIRED_CLAIM_KEYS, ...OPTIONAL_CLAIM_KEYS]);
interface Claim {
id: string;
transport: string;
ref: string;
auth: string;
host: string;
ca: string;
node_path: string;
port?: string;
support: string;
qualifiers: string[];
limitations?: string[];
evidence?: {
kind: 'automated' | 'live';
outcome: 'success' | 'rejected';
handles?: { file: string; title: string }[];
attestation?: string;
};
}
interface Attestation {
id: string;
date: string;
source_commit: string;
sencho_image_digest?: string;
host: string;
node_path: string;
transport?: string;
ref?: string;
auth?: string;
ca?: string;
result: 'success' | 'rejected';
}
/** Pure schema validation: closed enums, required keys, no unknown fields, resolvable limitation refs. */
function validateClaimSchema(claim: Record<string, unknown>, limitationIds: Set<string>): string[] {
const errors: string[] = [];
const keys = Object.keys(claim);
for (const key of REQUIRED_CLAIM_KEYS) {
if (!(key in claim)) errors.push(`missing required key "${key}"`);
}
for (const key of keys) {
if (!ALLOWED_CLAIM_KEYS.has(key)) errors.push(`unknown key "${key}"`);
}
for (const [field, allowed] of Object.entries(CLOSED_ENUMS)) {
if (field === 'support' && typeof claim.support === 'string' && !allowed.includes(claim.support)) {
errors.push(`invalid support "${String(claim.support)}"`);
} else if (field in claim && field !== 'support') {
const value = (claim as Record<string, unknown>)[field];
if (typeof value === 'string' && !allowed.includes(value)) {
errors.push(`invalid ${field} "${value}"`);
}
}
}
if (Array.isArray(claim.limitations)) {
for (const id of claim.limitations as string[]) {
if (!limitationIds.has(id)) errors.push(`unknown limitation id "${id}"`);
}
}
return errors;
}
/** Pure evidence-semantics validation, independent of file/AST resolution. */
function validateClaimEvidence(claim: Claim): string[] {
const errors: string[] = [];
const { support, evidence } = claim;
if (support === 'unverified') {
if (evidence) errors.push('unverified claim must not carry evidence');
return errors;
}
if (!evidence) {
errors.push(`${support} claim requires evidence`);
return errors;
}
const expectedOutcome = support === 'supported' ? 'success' : 'rejected';
if (evidence.outcome !== expectedOutcome) {
errors.push(`${support} claim requires evidence.outcome "${expectedOutcome}", got "${evidence.outcome}"`);
}
if (evidence.kind === 'automated' && (!evidence.handles || evidence.handles.length === 0)) {
errors.push('automated evidence requires at least one handle');
}
if (evidence.kind === 'live' && !evidence.attestation) {
errors.push('live evidence requires an attestation id');
}
return errors;
}
/** Cross-checks a live claim's dimensions and baseline against its attestation. */
function validateLiveEvidenceIntegrity(claim: Claim, attestationsById: Map<string, Attestation>, expectedBaseline: string): string[] {
if (claim.support === 'unverified' || claim.evidence?.kind !== 'live') return [];
const errors: string[] = [];
const attestation = claim.evidence.attestation ? attestationsById.get(claim.evidence.attestation) : undefined;
if (!attestation) {
errors.push(`claim "${claim.id}" references missing attestation "${String(claim.evidence.attestation)}"`);
return errors;
}
if (attestation.source_commit !== expectedBaseline) {
errors.push(`claim "${claim.id}"'s attestation is stale: source_commit "${attestation.source_commit}" != implementation_baseline "${expectedBaseline}"`);
}
if (attestation.host !== claim.host) errors.push(`claim "${claim.id}" host mismatch with its attestation`);
if (attestation.node_path !== claim.node_path) errors.push(`claim "${claim.id}" node_path mismatch with its attestation`);
if (attestation.transport !== undefined && attestation.transport !== claim.transport) errors.push(`claim "${claim.id}" transport mismatch with its attestation`);
if (attestation.ref !== undefined && attestation.ref !== claim.ref) errors.push(`claim "${claim.id}" ref mismatch with its attestation`);
if (attestation.auth !== undefined && attestation.auth !== claim.auth) errors.push(`claim "${claim.id}" auth mismatch with its attestation`);
if (attestation.ca !== undefined && attestation.ca !== claim.ca) errors.push(`claim "${claim.id}" ca mismatch with its attestation`);
const expectedOutcome = claim.support === 'supported' ? 'success' : 'rejected';
if (attestation.result !== expectedOutcome) errors.push(`claim "${claim.id}" attestation result "${attestation.result}" contradicts claim support "${claim.support}"`);
return errors;
}
function extractStringUnionMembers(filePath: string, typeName: string): string[] {
const sourceText = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
const members: string[] = [];
const collect = (typeNode: ts.TypeNode): void => {
if (ts.isUnionTypeNode(typeNode)) {
typeNode.types.forEach(collect);
} else if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) {
members.push(typeNode.literal.text);
}
};
const visit = (node: ts.Node): void => {
if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) {
collect(node.type);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
if (members.length === 0) throw new Error(`Type alias ${typeName} not found (or has no string-literal members) in ${filePath}`);
return members;
}
describe('git transport support matrix', () => {
const { support, attestations } = loadClaimSet() as { support: { claims: Claim[]; limitations: { id: string; title: string; statement: string }[]; error_model: { code: string; label: string; status: number; meaning: string }[]; reconciliation_only_codes: string[]; implementation_baseline: string }; attestations: { attestations: Attestation[] } };
const limitationIds = new Set(support.limitations.map((l) => l.id));
const attestationsById = new Map(attestations.attestations.map((a) => [a.id, a]));
describe('schema', () => {
it('every real claim is schema-valid', () => {
for (const claim of support.claims) {
expect(validateClaimSchema(claim as unknown as Record<string, unknown>, limitationIds), claim.id).toEqual([]);
}
});
it('every claim id is unique', () => {
const ids = support.claims.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('rejects an invalid enum value', () => {
const bad = { ...support.claims[0], transport: 'ftp' };
expect(validateClaimSchema(bad, limitationIds)).toContain('invalid transport "ftp"');
});
it('rejects an unknown field', () => {
const bad = { ...support.claims[0], bogusField: true };
expect(validateClaimSchema(bad, limitationIds)).toContain('unknown key "bogusField"');
});
it('rejects a dangling limitation reference', () => {
const bad = { ...support.claims[0], limitations: ['does-not-exist'] };
expect(validateClaimSchema(bad, limitationIds)).toContain('unknown limitation id "does-not-exist"');
});
it('rejects a claim missing a required key', () => {
const bad = { ...support.claims[0] } as Record<string, unknown>;
delete bad.host;
expect(validateClaimSchema(bad, limitationIds)).toContain('missing required key "host"');
});
});
describe('evidence semantics', () => {
it('every real claim satisfies evidence semantics', () => {
for (const claim of support.claims) {
expect(validateClaimEvidence(claim), claim.id).toEqual([]);
}
});
it('rejects a supported claim with no evidence', () => {
const bad: Claim = { ...support.claims.find((c) => c.support === 'supported')!, evidence: undefined };
expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence');
});
it('rejects an unverified claim that carries evidence', () => {
const template = support.claims.find((c) => c.support === 'supported')!;
const bad: Claim = { ...template, support: 'unverified' };
expect(validateClaimEvidence(bad)).toContain('unverified claim must not carry evidence');
});
it('rejects a supported claim whose evidence outcome is "rejected"', () => {
const template = support.claims.find((c) => c.support === 'supported' && c.evidence)!;
const bad: Claim = { ...template, evidence: { ...template.evidence!, outcome: 'rejected' } };
expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence.outcome "success", got "rejected"');
});
it('rejects automated evidence with no handles', () => {
const template = support.claims.find((c) => c.evidence?.kind === 'automated')!;
const bad: Claim = { ...template, evidence: { ...template.evidence!, handles: [] } };
expect(validateClaimEvidence(bad)).toContain('automated evidence requires at least one handle');
});
it('requires an unsupported claim to carry rejection evidence, not silence', () => {
const template = support.claims.find((c) => c.support === 'supported')!;
const bad: Claim = { ...template, support: 'unsupported', evidence: undefined };
expect(validateClaimEvidence(bad)).toContain('unsupported claim requires evidence');
});
});
describe('page binding', () => {
it('the committed MDX is byte-identical to a fresh render of the YAML', () => {
const mdxPath = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx');
expect(matrixRenderer.renderFullMdx()).toBe(fs.readFileSync(mdxPath, 'utf8'));
});
it('re-rendering with a mutated claim produces different generated output', () => {
const mutated = {
support: {
...support,
claims: support.claims.map((c, i) => (i === 0 ? { ...c, support: 'unsupported' } : c)),
},
attestations,
};
const original = matrixRenderer.renderGeneratedBlock({ support, attestations });
const changed = matrixRenderer.renderGeneratedBlock(mutated);
expect(changed).not.toBe(original);
});
});
describe('proof handles', () => {
const automatedClaims = support.claims.filter((c) => c.evidence?.kind === 'automated');
it('has at least one automated claim to check', () => {
expect(automatedClaims.length).toBeGreaterThan(0);
});
for (const claim of automatedClaims) {
for (const handle of claim.evidence!.handles ?? []) {
it(`resolves "${handle.title}" in ${handle.file} (claim ${claim.id})`, () => {
const absPath = path.join(REPO_ROOT, handle.file);
const sourceText = fs.readFileSync(absPath, 'utf8');
const result = resolveTestHandle(absPath, sourceText, handle.title);
expect(result, JSON.stringify(result)).toEqual({ ok: true });
});
}
}
// Mutation coverage for the resolver's found/duplicate/skip/ancestor
// logic itself lives in testHandleResolver.test.ts; this suite only
// needs to prove every real handle actually resolves.
});
describe('live evidence integrity', () => {
it('every real live claim (if any) is internally consistent', () => {
for (const claim of support.claims) {
expect(validateLiveEvidenceIntegrity(claim, attestationsById, support.implementation_baseline), claim.id).toEqual([]);
}
});
it('rejects a live claim referencing a missing attestation', () => {
const bad: Claim = {
id: 'synthetic-missing-attestation', transport: 'https', ref: 'branch', auth: 'pat',
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
evidence: { kind: 'live', outcome: 'success', attestation: 'does-not-exist' },
};
expect(validateLiveEvidenceIntegrity(bad, attestationsById, support.implementation_baseline).length).toBeGreaterThan(0);
});
it('rejects a live claim whose attestation baseline is stale', () => {
const fakeAttestations = new Map<string, Attestation>([
['att-stale', { id: 'att-stale', date: '2020-01-01', source_commit: 'deadbeef', host: 'github', node_path: 'local', result: 'success' }],
]);
const bad: Claim = {
id: 'synthetic-stale', transport: 'https', ref: 'branch', auth: 'pat',
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
evidence: { kind: 'live', outcome: 'success', attestation: 'att-stale' },
};
const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline);
expect(errors.some((e) => e.includes('stale'))).toBe(true);
});
it('rejects a live claim whose node_path does not match its attestation', () => {
const fakeAttestations = new Map<string, Attestation>([
['att-mismatch', { id: 'att-mismatch', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'direct-proxy', result: 'success' }],
]);
const bad: Claim = {
id: 'synthetic-mismatch', transport: 'https', ref: 'branch', auth: 'pat',
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
evidence: { kind: 'live', outcome: 'success', attestation: 'att-mismatch' },
};
const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline);
expect(errors.some((e) => e.includes('node_path mismatch'))).toBe(true);
});
it('rejects an attestation whose result contradicts the claim support', () => {
const fakeAttestations = new Map<string, Attestation>([
['att-contradict', { id: 'att-contradict', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'local', result: 'rejected' }],
]);
const bad: Claim = {
id: 'synthetic-contradict', transport: 'https', ref: 'branch', auth: 'pat',
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
evidence: { kind: 'live', outcome: 'success', attestation: 'att-contradict' },
};
const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline);
expect(errors.some((e) => e.includes('contradicts'))).toBe(true);
});
});
describe('error model partition', () => {
const transportFacingCodes = extractStringUnionMembers(
path.join(REPO_ROOT, 'backend', 'src', 'services', 'git', 'errors.ts'),
'TransportFacingCode',
);
const gitSourceErrorCodes = extractStringUnionMembers(
path.join(REPO_ROOT, 'backend', 'src', 'services', 'GitSourceService.ts'),
'GitSourceErrorCode',
);
it('the matrix error_model is exactly TransportFacingCode plus REF_DELETED and FILE_NOT_FOUND', () => {
const expected = new Set([...transportFacingCodes, 'REF_DELETED', 'FILE_NOT_FOUND']);
const actual = new Set(support.error_model.map((e) => e.code));
expect(actual).toEqual(expected);
});
it('reconciliation_only_codes plus the matrix error_model partitions all of GitSourceErrorCode exactly once', () => {
const matrixCodes = support.error_model.map((e) => e.code);
const reconciliationCodes: string[] = support.reconciliation_only_codes;
const combined = [...matrixCodes, ...reconciliationCodes];
expect(new Set(combined).size).toBe(combined.length); // no code in both sets
expect(new Set(combined)).toEqual(new Set(gitSourceErrorCodes)); // covers every code
});
it('every published status matches the real gitSourceStatus mapping', () => {
for (const entry of support.error_model) {
expect(gitSourceStatus(entry.code as GitSourceErrorCode), entry.code).toBe(entry.status);
}
});
it('rejects a matrix that leaves a code unclassified', () => {
const incomplete = support.error_model.filter((e) => e.code !== 'GIT_ERROR').map((e) => e.code);
const reconciliationCodes: string[] = support.reconciliation_only_codes;
const combined = [...incomplete, ...reconciliationCodes];
expect(new Set(combined)).not.toEqual(new Set(gitSourceErrorCodes));
});
});
describe('rate limiting', () => {
it('is classified in the error model, not left as a documented limitation', () => {
expect(limitationIds.has('no-rate-limit-classification')).toBe(false);
expect(support.error_model.some((e) => e.code === 'RATE_LIMITED')).toBe(true);
});
});
});
@@ -26,10 +26,7 @@ import path from 'path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { classifyGitFailure, isTransportFailure } from '../services/git/errors';
import { nativeGitTransport, verifyFastForward } from '../services/git/nativeGitTransport';
function gitAvailable(): boolean {
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
}
import { requireGitBinary } from './__helpers__/externalDeps';
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
const VALID_TOKEN = 'sencho-integration-test-token-do-not-leak';
@@ -174,7 +171,7 @@ function serveAuthedRepo(bareDir: string): Promise<{ url: string; close: () => v
});
}
describe.skipIf(!gitAvailable())('authenticated native git transport (real git, real TLS, real auth)', () => {
describe.skipIf(!requireGitBinary())('authenticated native git transport (real git, real TLS, real auth)', () => {
let repoUrl: string;
let closeServer: () => void;
let prevExtraCaCerts: string | undefined;
@@ -0,0 +1,109 @@
/**
* Real end-to-end proof that a Git host throttle response classifies as
* RATE_LIMITED, not AUTH_FAILED or GIT_ERROR.
*
* The other classifier fixtures (git-transport.test.ts) construct stderr
* strings by hand; this file proves the string a real git binary actually
* produces, since git does not surface an HTTP response body over the
* smart-HTTP protocol; only the status line reaches stderr. A server that
* intercepts every request before touching a real repository is enough:
* resolveRef fails at ls-remote, before any fetch would need real
* repository content.
*
* Soft-skips when the system git binary is unavailable, mirroring the other
* native-git integration suites (see __helpers__/externalDeps.ts).
*/
import { promises as fs, readFileSync } from 'fs';
import https from 'https';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { classifyGitFailure, isTransportFailure } from '../services/git/errors';
import { nativeGitTransport } from '../services/git/nativeGitTransport';
import { requireGitBinary } from './__helpers__/externalDeps';
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
/** Throttle wording a real host would put in the response body, which git never shows. */
const THROTTLE_BODY = 'You have exceeded a secondary rate limit. Please wait a few minutes before you try again.';
/** Serve a fixed HTTP status to every request, regardless of path or method. */
function serveStatus(status: number): Promise<{ url: string; close: () => void }> {
return new Promise((resolve, reject) => {
const server = https.createServer(
{
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
},
(req, res) => {
res.statusCode = status;
res.end(THROTTLE_BODY);
},
);
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('server did not bind'));
return;
}
resolve({ url: `https://127.0.0.1:${address.port}/repo.git`, close: () => server.close() });
});
});
}
describe.skipIf(!requireGitBinary())('native git transport rate-limit classification (real git, real TLS)', () => {
let prevExtraCaCerts: string | undefined;
const workspaces: string[] = [];
beforeAll(() => {
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
process.env.NODE_EXTRA_CA_CERTS = path.join(FIXTURES_DIR, 'git-ca.pem');
});
afterAll(() => {
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
});
afterEach(async () => {
await Promise.all(workspaces.splice(0).map((w) => fs.rm(w, { recursive: true, force: true })));
});
async function makeWorkspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-git-ratelimit-ws-'));
workspaces.push(dir);
return dir;
}
// Both statuses are served with the same throttle body, which is the
// point of the pair: git shows only the status line, so a host that
// signals a throttle as a bare 403 is indistinguishable from a rejected
// credential and must still classify as AUTH_FAILED.
it.each([
[429, 'RATE_LIMITED'],
[403, 'AUTH_FAILED'],
] as const)('classifies a real %d response from the host as %s', async (status, code) => {
const served = await serveStatus(status);
try {
const workspaceRoot = await makeWorkspace();
const failure = await nativeGitTransport
.resolveRef({ repoUrl: served.url, ref: 'main', token: 'irrelevant-token', timeoutMs: 15_000, workspaceRoot })
.then(() => null, (e: unknown) => e);
expect(isTransportFailure(failure)).toBe(true);
if (!isTransportFailure(failure)) throw new Error('unreachable');
expect(failure.reason).toBe('exit');
if (failure.reason === 'exit') {
// Pins the real stderr shape the classifier's hand-written
// fixtures (git-transport.test.ts) assume: the status line
// reaches stderr, the served body never does.
expect(failure.stderr).toMatch(new RegExp(`requested url returned error:\\s*${status}\\b`, 'i'));
expect(failure.stderr).not.toMatch(/secondary rate limit/i);
}
expect(classifyGitFailure(failure).code).toBe(code);
} finally {
served.close();
}
});
});
@@ -13,14 +13,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { classifyGitFailure, isTransportFailure } from '../services/git/errors';
import { nativeGitTransport } from '../services/git/nativeGitTransport';
import { scanHostKeys } from '../services/git/sshTrust';
function gitAvailable(): boolean {
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
}
function sshdAvailable(): boolean {
return spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0;
}
import { requireGitBinary, requireSshd } from './__helpers__/externalDeps';
const FILE_CONTENT = 'hello from the ssh fixture repo\n';
@@ -165,7 +158,7 @@ async function startSshGitServer(bareDir: string, port: number): Promise<Omit<Ss
throw new Error(`sshd did not come up on port ${port}: ${stderr.trim() || '(no stderr)'}`, { cause: e });
}
const scanned = await scanHostKeys('127.0.0.1', port);
const scanned = await scanHostKeys('127.0.0.1', port, '127.0.0.1');
assertFixtureHostKey(port, hostKey.publicLine, scanned);
const knownHostsEntry = scanned.map((k) => k.line).join('\n');
@@ -182,7 +175,7 @@ async function startSshGitServer(bareDir: string, port: number): Promise<Omit<Ss
};
}
describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => {
describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => {
let fixture: SshGitFixture;
const workspaces: string[] = [];
let scratchDirs: string[] = [];
@@ -296,7 +289,7 @@ describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git
});
});
describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key transport on the default SSH port', () => {
describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key transport on the default SSH port', () => {
let fixture: SshGitFixture;
const workspaces: string[] = [];
+256 -7
View File
@@ -40,6 +40,8 @@ import {
} from '../services/git/credentialHelper';
import * as gitBinary from '../services/git/gitBinary';
import { nativeGitTransport, REF_MAX_LEN, startSizeWatchdog, verifyFastForward } from '../services/git/nativeGitTransport';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
import { GIT_ALLOWED_HOST_ENV_VAR } from '../services/git/credentialHelper';
const GIT_EXEC_PATH_STUB = 'C:/Program Files/Git/mingw64/libexec/git-core';
@@ -186,6 +188,7 @@ describe('classifyGitFailure (native git stderr corpus)', () => {
['size', { transportFailure: true as const, reason: 'size', maxBytes: 5 * 1024 * 1024, host: 'h', hasToken: false }, 'Repository exceeds the maximum clone size of 5 MB.'],
['tip-changed', { transportFailure: true as const, reason: 'tip-changed', host: 'h', hasToken: false }, 'Repository tip changed during fetch; retry the pull.'],
['ref-not-found', { transportFailure: true as const, reason: 'ref-not-found', host: 'h', hasToken: false }, 'The configured branch, tag, or commit was not found in the repository.'],
['ssh-auth-required', { transportFailure: true as const, reason: 'ssh-auth-required', host: 'h', hasToken: false }, 'SSH repository URLs require a deploy key.'],
['unsupported-ref', { transportFailure: true as const, reason: 'unsupported-ref', host: 'h', hasToken: false }, 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.'],
['timeout', { transportFailure: true as const, reason: 'timeout', host: 'github.com', hasToken: false }, 'Timed out reaching github.com.'],
] as const)('maps structured reason %s verbatim', (_label, failure, message) => {
@@ -216,6 +219,122 @@ describe('classifyGitFailure (native git stderr corpus)', () => {
expect(c.code).toBe('UNSUPPORTED_REF');
});
it.each([
// The HTTP shapes are verified against a real git binary talking to a
// fixture server (git-transport-ratelimit.integration.test.ts): git
// reports the status line only, never the response body.
['bare 429 from the host', "fatal: unable to access 'https://h/x.git/': The requested URL returned error: 429"],
['429 with trailing text', "fatal: unable to access 'https://h/x.git/': The requested URL returned error: 429 Too Many Requests"],
// Text a host sends through the pack stream does reach stderr as a
// remote: line, unlike an HTTP response body.
['remote sideband rate-limit message', "remote: You have exceeded a secondary rate limit. Please wait a few minutes before you try again.\nfatal: the remote end hung up unexpectedly"],
['remote sideband abuse-detection message', "remote: You have triggered an abuse detection mechanism.\nfatal: the remote end hung up unexpectedly"],
])('classifies %s as RATE_LIMITED', (_label, stderr) => {
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr,
exitCode: 128,
host: 'github.com',
hasToken: true,
});
expect(c.code).toBe('RATE_LIMITED');
expect(c.message).toMatch(/rate limited/i);
});
it('classifies an unambiguous rate limit as RATE_LIMITED even without a token', () => {
// Rule 3 (see the module header) takes precedence over rule 2's
// no-token private-repo masking: a throttle leaks nothing about repo
// existence, so it should not be reported as REPO_NOT_FOUND.
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: "remote: You have exceeded a secondary rate limit.\nfatal: the remote end hung up unexpectedly",
exitCode: 128,
host: 'github.com',
hasToken: false,
});
expect(c.code).toBe('RATE_LIMITED');
});
it('does not send a rate-limited operator to rotate a working credential', () => {
// A sideband throttle message can arrive alongside a 403 fatal line,
// which the auth branch below would otherwise claim. Both the code
// and the message are asserted: reporting RATE_LIMITED while still
// saying "check your token" would leave the operator with the same
// wrong action.
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: "remote: You have exceeded a secondary rate limit.\nfatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403",
exitCode: 128,
host: 'github.com',
hasToken: true,
});
expect(c.code).toBe('RATE_LIMITED');
expect(c.message).not.toMatch(/check your token/i);
});
it('leaves a bare 403 with no rate-limit wording as an auth failure', () => {
// Guards the other direction, and pins a real constraint: git does
// not surface an HTTP response body, so a host that signals a
// throttle as a bare 403 is indistinguishable from a rejected
// credential. Widening the rate-limit branch to cover every 403
// would make a genuinely bad token read as a throttle to wait out.
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: "fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403",
exitCode: 128,
host: 'github.com',
hasToken: true,
});
expect(c.code).toBe('AUTH_FAILED');
});
it('does not mistake a repository named "rate-limiter" for a rate-limit signal', () => {
// git's fatal line echoes the full repo URL verbatim, so an
// unscoped rate-limit word match would fire on the path itself. A
// genuinely bad token against a repo whose name happens to contain
// rate-limit wording must still classify as an auth failure.
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: "fatal: unable to access 'https://github.com/acme/rate-limiter.git/': The requested URL returned error: 403",
exitCode: 128,
host: 'github.com',
hasToken: true,
});
expect(c.code).toBe('AUTH_FAILED');
});
it('does not mistake an upload-pack progress counter for a 429 status', () => {
// Progress lines like "Counting objects: 100% (429/429)" reach
// stderr from the server sideband and can contain the literal digits
// 429 with no connection to an HTTP status at all.
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: "remote: Counting objects: 100% (429/429), done.\nfatal: the remote end hung up unexpectedly",
exitCode: 128,
host: 'github.com',
hasToken: false,
});
expect(c.code).toBe('NETWORK_TIMEOUT');
});
it('does not mistake an unrelated transient-error sideband for a rate limit', () => {
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: "remote: Internal server error, please retry later\nfatal: the remote end hung up unexpectedly",
exitCode: 128,
host: 'github.com',
hasToken: false,
});
expect(c.code).toBe('NETWORK_TIMEOUT');
});
it('scrubs credentials from the generic fallback tail', () => {
const c = classifyGitFailure({
transportFailure: true as const,
@@ -410,6 +529,41 @@ describe('transport argv hardening', () => {
}
});
it('exports the configured HTTPS host[:port] to the credential helper so a cross-host redirect cannot match', async () => {
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
const root = await makeWorkspace();
try {
await nativeGitTransport.resolveRef({
repoUrl: 'https://git.example.com/example/repo.git',
ref: 'main',
token: 'sekrit',
timeoutMs: 5000,
workspaceRoot: root,
});
const env = spawnEnv(0);
expect(env[GIT_ALLOWED_HOST_ENV_VAR]).toBe('git.example.com');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('does not export the allowed host when no token is supplied (no credentials to scope)', async () => {
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
const root = await makeWorkspace();
try {
await nativeGitTransport.resolveRef({
repoUrl: 'https://git.example.com/example/repo.git',
ref: 'main',
timeoutMs: 5000,
workspaceRoot: root,
});
const env = spawnEnv(0);
expect(env[GIT_ALLOWED_HOST_ENV_VAR]).toBeUndefined();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('combines NODE_EXTRA_CA_CERTS with platform defaults into http.sslCAInfo when set (dev/E2E bridge)', async () => {
const caPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-git-ca-test-')), 'ca.pem');
await fs.writeFile(caPath, '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----\n');
@@ -482,6 +636,40 @@ describe('transport argv hardening', () => {
}
});
it('combines a per-source CA PEM into http.sslCAInfo without NODE_EXTRA_CA_CERTS', async () => {
const prev = process.env.NODE_EXTRA_CA_CERTS;
delete process.env.NODE_EXTRA_CA_CERTS;
try {
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
const root = await makeWorkspace();
const perSourcePem = '-----BEGIN CERTIFICATE-----\nPER-SOURCE-CA-MARKER\n-----END CERTIFICATE-----\n';
await nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
caBundlePem: perSourcePem,
timeoutMs: 5000,
workspaceRoot: root,
});
const setArgs = spawnArgs(0);
// Git never follows a redirect itself; an approved destination is
// resolved by the preflight and retried explicitly.
expect(setArgs).toContain('http.followRedirects=false');
const combined = setArgs.find((a) => a.startsWith('http.sslCAInfo='));
expect(combined).toBeDefined();
if (process.platform !== 'win32') {
const combinedBody = await fs.readFile(
(combined as string).slice('http.sslCAInfo='.length).replace(/\//g, path.sep),
'utf8',
);
expect(combinedBody).toContain('PER-SOURCE-CA-MARKER');
}
await fs.rm(root, { recursive: true, force: true });
} finally {
if (prev === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
else process.env.NODE_EXTRA_CA_CERTS = prev;
}
});
it.each([
['plain http', 'http://github.com/example/repo.git'],
['embedded userinfo', 'https://user:pass@github.com/example/repo.git'],
@@ -490,6 +678,15 @@ describe('transport argv hardening', () => {
expect(mockSpawn).not.toHaveBeenCalled();
});
it('rejects SSH repository URLs without deploy-key authentication before spawning git', async () => {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'ssh://git@ssh.example/org/repo.git',
ref: 'main',
workspaceRoot: os.tmpdir(),
})).rejects.toMatchObject({ transportFailure: true as const, reason: 'ssh-auth-required' });
expect(mockSpawn).not.toHaveBeenCalled();
});
it('rejects option-injecting ref names', async () => {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
@@ -729,20 +926,53 @@ describe('resolve/fetch/verify flow', () => {
}
});
it('resolves an annotated tag through the peeled ^{} commit', async () => {
it('pins HTTPS to the validated address while resolving an annotated tag', async () => {
scriptSpawn([{
stdout: `${SHA_B}\trefs/tags/v1\n${SHA_A}\trefs/tags/v1^{}\n`,
}]);
const root = await makeWorkspace();
try {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
repoUrl: 'https://pinned.example:8443/example/repo.git',
ref: 'v1',
timeoutMs: 5000,
workspaceRoot: root,
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'tag' });
const lsRemoteArgs = mockSpawn.mock.calls[0][1] as string[];
expect(lsRemoteArgs).toContain('refs/tags/v1^{}');
expect(lsRemoteArgs).toContain('http.followRedirects=false');
expect(lsRemoteArgs).toContain('http.proxy=');
expect(lsRemoteArgs).toContain('http.curloptResolve=pinned.example:8443:93.184.216.34');
const env = spawnEnv(0);
expect(env.HTTP_PROXY).toBe('');
expect(env.HTTPS_PROXY).toBe('');
expect(env.ALL_PROXY).toBe('');
expect(env.http_proxy).toBe('');
expect(env.https_proxy).toBe('');
expect(env.all_proxy).toBe('');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('pins SSH to the validated address while retaining host identity and port', async () => {
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
const root = await makeWorkspace();
try {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'ssh://git@pinned.example:2222/example/repo.git',
ref: 'main',
sshAuth: {
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nYWJj\n-----END OPENSSH PRIVATE KEY-----\n',
knownHostsEntry: 'pinned.example ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=\n',
},
timeoutMs: 5000,
workspaceRoot: root,
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'branch' });
const sshCommand = spawnEnv(0).GIT_SSH_COMMAND;
expect(sshCommand).toContain('Hostname=93.184.216.34');
expect(sshCommand).toContain('HostKeyAlias=[pinned.example]:2222');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
@@ -766,12 +996,12 @@ describe('resolve/fetch/verify flow', () => {
it('self-resolves a full SHA without a network round trip', async () => {
const root = await makeWorkspace();
try {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
await expect(withLoopbackTargetProtection(() => nativeGitTransport.resolveRef({
repoUrl: 'https://127.0.0.1/example/repo.git',
ref: SHA_A.toUpperCase(),
timeoutMs: 5000,
workspaceRoot: root,
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'sha' });
}))).resolves.toMatchObject({ commitSha: SHA_A, kind: 'sha' });
// The SHA needs no ls-remote: the identity IS the value.
expect(mockSpawn).not.toHaveBeenCalled();
} finally {
@@ -991,6 +1221,26 @@ describe('clone failure classification and final size gate', () => {
}
});
it('rejects unsafe targets during fast-forward verification', async () => {
const root = await makeWorkspace();
try {
await expect(withLoopbackTargetProtection(() => verifyFastForward({
repoUrl: 'https://127.0.0.1/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
}))).rejects.toMatchObject({
transportFailure: true as const,
reason: 'unsafe-target',
});
expect(mockSpawn).not.toHaveBeenCalled();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('deepens history exponentially with a bounded number of remote fetches', async () => {
scriptSpawn([
{ code: 0 },
@@ -1341,8 +1591,7 @@ describe('clone failure classification and final size gate', () => {
// simulated confirmation has not arrived yet: settling here
// would let a caller start cleaning up the workspace while the
// child tree is still alive.
await new Promise((r) => setTimeout(r, 55));
expect(killInvoked).toBe(true);
await vi.waitFor(() => expect(killInvoked).toBe(true), { timeout: 250 });
expect(settled).toBe(false);
await expect(promise).rejects.toMatchObject({ transportFailure: true as const, reason: 'timeout' });
@@ -0,0 +1,116 @@
/**
* The CA bundle sink is the only place a per-fetch PEM file is written for
* git to read via http.sslCAInfo. These tests pin the invariants CodeQL was
* asked to ignore: every output path is inside the supplied metaDir, the
* written content is concatenated PEM only, and a non-PEM NODE_EXTRA_CA_CERTS
* file is dropped rather than passed through to git.
*/
import { promises as fs, mkdtempSync, statSync, writeFileSync } from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { writeCombinedCaBundle } from '../services/git/gitCaBundleSink';
const SAMPLE_CA_A = '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv\n-----END CERTIFICATE-----\n';
const SAMPLE_CA_B = '-----BEGIN CERTIFICATE-----\nQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=\n-----END CERTIFICATE-----\n';
// Controlled system CA fixture with a unique marker for deterministic tests
const SYSTEM_CA_FIXTURE = '-----BEGIN CERTIFICATE-----\nSENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345\n-----END CERTIFICATE-----\n';
describe('writeCombinedCaBundle', () => {
let metaDir: string;
let prevExtraCaCerts: string | undefined;
beforeEach(() => {
metaDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-ca-sink-'));
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
});
afterEach(async () => {
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
await fs.rm(metaDir, { recursive: true, force: true });
});
it('returns null when no per-source PEM and no NODE_EXTRA_CA_CERTS is set', async () => {
delete process.env.NODE_EXTRA_CA_CERTS;
const result = await writeCombinedCaBundle(metaDir, null);
expect(result).toBeNull();
});
it('writes a file inside the supplied metaDir and returns its absolute path', async () => {
delete process.env.NODE_EXTRA_CA_CERTS;
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A);
expect(result).not.toBeNull();
// The returned path is the metaDir/combined-ca.pem, with forward slashes
// for git. The test must compare resolved paths, not string prefixes,
// because the metaDir may itself contain a forward-slash via mkdtemp
// (which it does not on POSIX, but path.resolve normalizes either way).
const expected = path.resolve(metaDir, 'combined-ca.pem');
expect(result!.path.replace(/\//g, path.sep)).toBe(expected);
expect(result!.path.endsWith('combined-ca.pem')).toBe(true);
const body = await fs.readFile(result!.path, 'utf8');
expect(body).toContain('BEGIN CERTIFICATE');
expect(body).toContain('END CERTIFICATE');
});
it('writes the file with mode 0600', async () => {
if (process.platform === 'win32') {
// POSIX-only check; Windows ignores mode bits on fs.writeFile.
return;
}
delete process.env.NODE_EXTRA_CA_CERTS;
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A);
const stat = statSync(result!.path);
// 0o600 -> owner read+write, no group/other bits.
expect(stat.mode & 0o777).toBe(0o600);
});
it('includes system, per-source, and env-var CAs when all are provided', async () => {
const extraPath = path.join(metaDir, '..', 'extra-ca.pem');
writeFileSync(extraPath, SAMPLE_CA_B);
process.env.NODE_EXTRA_CA_CERTS = extraPath;
// Inject a controlled system CA fixture with a unique marker via the
// optional third parameter. This bypasses the platform read so the test
// is deterministic regardless of CI distro.
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A, SYSTEM_CA_FIXTURE);
expect(result).not.toBeNull();
const body = await fs.readFile(result!.path, 'utf8');
// Verify all three categories exist with unique, identifiable markers:
// (1) Per-source PEM (SAMPLE_CA_A)
expect(body).toContain('MIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv');
// (2) NODE_EXTRA_CA_CERTS file content (SAMPLE_CA_B)
expect(body).toContain('QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=');
// (3) System CA fixture (controlled, unique marker)
expect(body).toContain('SENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345');
});
it('omits system CA when injection parameter is null (negative control)', async () => {
const extraPath = path.join(metaDir, '..', 'extra-ca.pem');
writeFileSync(extraPath, SAMPLE_CA_B);
process.env.NODE_EXTRA_CA_CERTS = extraPath;
// Pass null to explicitly skip system CA injection
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A, null);
expect(result).not.toBeNull();
const body = await fs.readFile(result!.path, 'utf8');
// Per-source and env-var CAs are present
expect(body).toContain('MIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv');
expect(body).toContain('QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=');
// System CA marker is NOT present - proves injection was needed
expect(body).not.toContain('SENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345');
});
it('drops a NODE_EXTRA_CA_CERTS file that is not valid PEM rather than writing it through', async () => {
const extraPath = path.join(metaDir, '..', 'extra-ca.pem');
writeFileSync(extraPath, 'this is not a certificate');
process.env.NODE_EXTRA_CA_CERTS = extraPath;
// No per-source PEM either: nothing valid to write, so null.
const result = await writeCombinedCaBundle(metaDir, null);
expect(result).toBeNull();
});
it('drops a non-PEM per-source input rather than writing it through', async () => {
delete process.env.NODE_EXTRA_CA_CERTS;
const result = await writeCombinedCaBundle(metaDir, 'not a cert at all');
expect(result).toBeNull();
});
});
@@ -308,6 +308,11 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -350,6 +355,12 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -0,0 +1,157 @@
/**
* Failure classification and retry-delay coverage for the GitOps source
* controller. classifyFailure is a compile-enforced total map: adding a new
* TransportFailureReason or GitSourceErrorCode without updating the lookup
* tables here fails the build, not just these tests.
*/
import { describe, it, expect } from 'vitest';
import {
classifyFailure,
nextRetryAt,
DEFAULT_TRANSIENT_CEILING,
LOW_TRANSIENT_CEILING,
type FailureEvidence,
} from '../services/gitops/backoff';
function gitSourceError(code: string, transportReason?: string): FailureEvidence {
return { kind: 'git_source_error', code: code as never, transportReason: transportReason as never };
}
describe('classifyFailure', () => {
it('classifies a tip-changed race as supersession, not backoff', () => {
expect(classifyFailure(gitSourceError('GIT_ERROR', 'tip-changed'))).toEqual({ class: 'supersession' });
});
it('classifies a standalone timeout reason as transient', () => {
expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'timeout')))
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
});
it('classifies DNS resolution failure (target-unresolved) as transient', () => {
expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'target-unresolved')))
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
});
it('classifies an exit-coded network timeout as transient', () => {
expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'exit')))
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
});
it('classifies an exit-coded rate limit as transient', () => {
expect(classifyFailure(gitSourceError('RATE_LIMITED', 'exit')))
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
});
it('classifies an exit-coded unrecognized git error with a low retry ceiling', () => {
expect(classifyFailure(gitSourceError('GIT_ERROR', 'exit')))
.toEqual({ class: 'transient', retryCeiling: LOW_TRANSIENT_CEILING });
});
it.each(['invalid-url', 'unsafe-target', 'invalid-ref', 'redirect-scope'])(
'classifies %s as permanent configuration',
(reason) => {
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
},
);
it.each(['git-missing', 'git-old'])('classifies %s as permanent environment', (reason) => {
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
});
it('classifies a repository over the size cap as permanent', () => {
expect(classifyFailure(gitSourceError('GIT_ERROR', 'size'))).toEqual({ class: 'permanent' });
});
it.each(['ssh-auth-required'])('classifies %s as permanent authorization', (reason) => {
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
});
it.each(['AUTH_FAILED', 'SSH_HOST_KEY_FAILED'])('classifies %s (no transport reason) as permanent', (code) => {
expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'permanent' });
});
it.each(['ref-not-found', 'unsupported-ref'])('classifies %s as permanent configuration', (reason) => {
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
});
it.each(['REPO_NOT_FOUND', 'REF_NOT_FOUND', 'REF_DELETED', 'UNSUPPORTED_REF'])(
'classifies %s (no transport reason) as permanent',
(code) => {
expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'permanent' });
},
);
it.each(['STALE_PLAN', 'PLAN_BLOCKED', 'PLAN_FINGERPRINT_REQUIRED', 'LEGACY_PENDING', 'PLAN_UNAVAILABLE', 'FILE_NOT_FOUND'])(
'classifies %s as requiring operator action',
(code) => {
expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'operator_action_required' });
},
);
it('classifies a conflicting in-flight operation for reconciliation, not blind retry', () => {
expect(classifyFailure(gitSourceError('OPERATION_IN_FLIGHT'))).toEqual({ class: 'reconcile' });
});
it('classifies an unavailable policy scanner as degraded', () => {
expect(classifyFailure({ kind: 'policy_unavailable' })).toEqual({ class: 'degraded' });
});
it('classifies unavailable persistence as transient with no source-stage progress', () => {
expect(classifyFailure({ kind: 'persistence_unavailable' }))
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
});
it('classifies an invalid target binding as permanent at the target level', () => {
expect(classifyFailure({ kind: 'target_binding_invalid' })).toEqual({ class: 'target_permanent' });
});
it('classifies a temporarily unavailable target as transient at the target level', () => {
expect(classifyFailure({ kind: 'target_unavailable' })).toEqual({ class: 'target_transient' });
});
it('classifies a deploy/health failure after a successful apply as its own class, never refetch or reapply', () => {
expect(classifyFailure({ kind: 'target_mutation_failed' })).toEqual({ class: 'target_mutation_failed' });
});
it('classifies unavailable Blueprint evaluation as blocked, not retried', () => {
expect(classifyFailure({ kind: 'blueprint_unavailable' })).toEqual({ class: 'blocked' });
});
it('classifies an interrupted or unknown-completion operation for reconciliation', () => {
expect(classifyFailure({ kind: 'interrupted' })).toEqual({ class: 'reconcile' });
});
});
describe('nextRetryAt', () => {
it('computes the base delay with up to +-10% jitter on the first attempt', () => {
const now = 1_000_000;
const at = nextRetryAt(now, 0);
expect(at).toBeGreaterThanOrEqual(now + 54_000);
expect(at).toBeLessThanOrEqual(now + 66_000);
});
it('doubles the delay per retry count', () => {
const now = 1_000_000;
const at = nextRetryAt(now, 3); // 60s * 2^3 = 480s
expect(at).toBeGreaterThanOrEqual(now + 432_000);
expect(at).toBeLessThanOrEqual(now + 528_000);
});
it('caps the delay at one hour regardless of retry count', () => {
const now = 1_000_000;
const at = nextRetryAt(now, 20);
expect(at).toBeLessThanOrEqual(now + 3_600_000 * 1.1);
});
it('honors a provider retry floor larger than the computed delay', () => {
const now = 1_000_000;
const at = nextRetryAt(now, 0, 10_000_000);
expect(at).toBe(now + 10_000_000);
});
it('ignores a provider retry floor smaller than the computed delay', () => {
const now = 1_000_000;
const at = nextRetryAt(now, 5, 1_000);
expect(at).toBeGreaterThan(now + 1_000);
});
});
@@ -608,6 +608,11 @@ function inlineApp(id: string, blueprintId: number): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -184,6 +184,7 @@ describe('gitops interrupted create recovery', () => {
encrypted_deploy_key: null,
ssh_known_hosts_entry: null,
ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: SHA,
@@ -379,6 +380,7 @@ function seedCreate(
encrypted_deploy_key: options.encryptedDeployKey ?? null,
ssh_known_hosts_entry: options.sshKnownHostsEntry ?? null,
ssh_host_key_fingerprint: options.sshHostKeyFingerprint ?? null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: 0,
auto_deploy_on_apply: 0,
commit_sha: SHA,
@@ -438,6 +440,11 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -480,6 +487,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -655,6 +655,7 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
encrypted_deploy_key: null,
ssh_known_hosts_entry: null,
ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: 0,
auto_deploy_on_apply: 0,
commit_sha: SHA,
@@ -705,6 +706,11 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -747,6 +753,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
+37 -1
View File
@@ -64,7 +64,11 @@ describe('gitops deferred state', () => {
const app = store.getApplication('app-susp')!;
expect(app.suspended_at).not.toBeNull();
expect(app.accepted_generation_id).toBe(accepted);
expect(projectOf('app-susp').facets.source.status).toBe('source_suspended');
const sourceFacet = projectOf('app-susp').facets.source;
expect(sourceFacet.status).toBe('source_suspended');
if (sourceFacet.status === 'source_suspended') {
expect(sourceFacet.suspendedReason).toBe('operator paused sync');
}
// A suspended source refuses new work rather than queueing it.
expect(() => tx.fetchStarted('app-susp', env('op-susp-f'))).toThrow(/suspended/);
@@ -89,6 +93,27 @@ describe('gitops deferred state', () => {
expect(app.suspended_at).not.toBeNull();
});
it('keeps a source-suspension reason independent of an application-wide rollout pause reason', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
seedApplied('app-susp3', 'susp3-web');
tx.sourceSuspended('app-susp3', 'operator paused sync', env('op-susp3'));
// A later, unrelated application-wide rollout pause must not clobber the
// suspension reason: the two events share the application row but not
// its reason field.
tx.rolloutPaused('app-susp3', null, 'awaiting approval', env('op-pause3'));
const app = store.getApplication('app-susp3')!;
expect(app.source_suspended_reason).toBe('operator paused sync');
expect(app.pause_reason).toBe('awaiting approval');
tx.sourceUnsuspended('app-susp3', env('op-unsusp3'));
expect(store.getApplication('app-susp3')?.source_suspended_reason).toBeNull();
// Unsuspending the source must not touch the unrelated rollout pause.
expect(store.getApplication('app-susp3')?.pause_reason).toBe('awaiting approval');
});
it('pauses a rollout without claiming anything about health', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
@@ -314,6 +339,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -356,6 +386,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -962,6 +962,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -1009,6 +1014,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -644,7 +644,7 @@ describe('Direct Git producers drive the revision state', () => {
expect(recovered.active_operation_stage).toBeNull();
});
it('leaves a stack with no GitOps application untouched', async () => {
it('refuses to fetch when a configured stack has no GitOps application', async () => {
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-legacy';
@@ -667,6 +667,7 @@ describe('Direct Git producers drive the revision state', () => {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: 'eeeeeee5',
@@ -680,9 +681,12 @@ describe('Direct Git producers drive the revision state', () => {
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
stageRepo(COMPOSE_V2, 'fffffff6');
await svc.pull(stackName, { actor: 'tester' });
await expect(svc.pull(stackName, { actor: 'tester' })).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringContaining('GitOps tracking is unavailable'),
});
// The pull succeeded operationally and wrote no GitOps rows.
// No untracked fetch or GitOps history was written.
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
const historyRows = (await import('../services/DatabaseService')).DatabaseService
.getInstance().getDb()
@@ -0,0 +1,115 @@
/**
* The accepted-generation contract: a portable, content-only projection of
* a gitops_generations row, plus the target-dispatch boundary.
*/
import { describe, it, expect } from 'vitest';
import {
buildAcceptedGeneration,
BlueprintTargetAdapter,
type AcceptedGeneration,
} from '../services/gitops/handoff';
import type { GitOpsGenerationRow } from '../services/gitops/types';
function baseRow(overrides: Partial<GitOpsGenerationRow> = {}): GitOpsGenerationRow {
return {
id: 'gen-1',
application_id: 'app-1',
commit_sha: 'a'.repeat(40),
repo_url: 'https://github.com/example/repo.git',
configured_ref: 'main',
resolved_ref_kind: 'branch',
repo_identity_json: '{"host":"github.com","pathname":"/example/repo.git"}',
manifest_version: 4,
candidate_dir: 'generations/candidate-a',
applied_dir: 'generations/applied-a-0',
expected_invocation_json: '{}',
materialization_fingerprint: 'f'.repeat(64),
validation_ok: 1,
plan_blocked: 0,
change_plan_fingerprint: 'fp-1',
operation_id: 'op-1',
trigger: 'manual',
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
...overrides,
};
}
describe('buildAcceptedGeneration', () => {
it('decodes identity and lineage fields directly from the row', () => {
const gen = buildAcceptedGeneration(baseRow());
expect(gen.contractVersion).toBe(1);
expect(gen.generationId).toBe('gen-1');
expect(gen.applicationId).toBe('app-1');
expect(gen.repoIdentity).toEqual({ host: 'github.com', pathname: '/example/repo.git' });
expect(gen.configuredRef).toBe('main');
expect(gen.commitSha).toBe('a'.repeat(40));
expect(gen.resolvedRefKind).toBe('branch');
expect(gen.validationOk).toBe(true);
expect(gen.trigger).toBe('manual');
expect(gen.operationId).toBe('op-1');
});
it('records an explicit limitation for each missing portable field on a legacy row, never inventing evidence', () => {
const gen = buildAcceptedGeneration(baseRow());
expect(gen.portableManifest).toBeNull();
expect(gen.composeInputs).toBeNull();
expect(gen.sourcePolicyEvidence).toBeNull();
expect(gen.limitations).toEqual(expect.arrayContaining([
'portable_manifest_missing',
'compose_inputs_missing',
'source_policy_evidence_missing',
'security_policy_evidence_missing',
'support_requirements_missing',
'compatibility_requirements_missing',
]));
});
it('decodes real evidence when the row carries it, recording no limitation for that field', () => {
const gen = buildAcceptedGeneration(baseRow({
portable_manifest_json: '{"files":[]}',
compose_inputs_json: '{"composeFileOrder":["compose.yaml"]}',
}));
expect(gen.portableManifest).toEqual({ files: [] });
expect(gen.composeInputs).toEqual({ composeFileOrder: ['compose.yaml'] });
expect(gen.limitations).not.toContain('portable_manifest_missing');
expect(gen.limitations).not.toContain('compose_inputs_missing');
});
it('refuses to build a contract from an unparseable repo identity', () => {
expect(() => buildAcceptedGeneration(baseRow({ repo_identity_json: 'not json' }))).toThrow();
});
it('never populates secretCapability with a value, only its absence as capability metadata', () => {
const gen = buildAcceptedGeneration(baseRow());
expect(gen.secretCapability).toBeNull();
});
});
// A future field on AcceptedGeneration named like a target-mode concept
// (selector, frozen target set, node id, rollout batch, target project
// name, local path, or secret value) must fail this compile, not merely
// this test run: the contract stays structurally content-only.
type AssertNoTargetModeFields<T> = T extends Record<
'selector' | 'targetSet' | 'nodeId' | 'nodeIds' | 'rolloutBatch' | 'projectName' | 'candidateDir' | 'secretValue',
unknown
> ? never : true;
const _structurallyContentOnly: AssertNoTargetModeFields<AcceptedGeneration> = true;
void _structurallyContentOnly;
describe('BlueprintTargetAdapter', () => {
it('always returns a durable blocked result, never inspecting selectors or placement', async () => {
const adapter = new BlueprintTargetAdapter();
const gen = buildAcceptedGeneration(baseRow());
const result = await adapter.dispatch(gen, { targetMode: 'blueprint', nodeId: null, bindingRevision: null });
expect(result.status).toBe('blocked');
});
});
@@ -530,6 +530,11 @@ function application(): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -115,6 +115,7 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
encrypted_deploy_key: null,
ssh_known_hosts_entry: null,
ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: 0,
auto_deploy_on_apply: 0,
commit_sha: SHA,
@@ -165,6 +166,11 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -358,6 +358,7 @@ function seedStack(
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: options.lastApplied,
@@ -0,0 +1,152 @@
/**
* Normalized reconcile-outcome coverage. outcomeFromSourceFacet derives from
* the existing SourceFacet projection rather than inventing a second status
* source, so "no source change" and "converged" cannot silently collapse
* into the same result.
*/
import { describe, it, expect } from 'vitest';
import { outcomeFromSourceFacet } from '../services/gitops/outcomes';
import type { SourceFacet } from '../services/gitops/types';
const identity = {
configuredRepoUrl: 'https://github.com/example/repo.git',
repoIdentity: { host: 'github.com', pathname: '/example/repo.git' },
configuredRef: 'main',
desiredCommitSha: 'a'.repeat(40),
fetchedCommitSha: 'a'.repeat(40),
candidateGenerationId: null,
acceptedGenerationId: 'gen-1',
};
describe('outcomeFromSourceFacet', () => {
it('reports no_source_change for an accepted generation, never converged on SHA alone', () => {
const facet: SourceFacet = { ...identity, status: 'application_generation_accepted' };
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('no_source_change');
expect(result.commitSha).toBe('a'.repeat(40));
});
it('reports candidate_already_fetched for a ready candidate', () => {
const facet: SourceFacet = { ...identity, status: 'candidate_ready' };
expect(outcomeFromSourceFacet(facet).outcome).toBe('candidate_already_fetched');
});
it('reports pending_review with a review next action', () => {
const facet: SourceFacet = { ...identity, status: 'source_review_pending' };
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('pending_review');
expect(result.nextAction).toBe('review');
});
it('reports blocked with a resolve_conflict next action for a source conflict', () => {
const facet: SourceFacet = { ...identity, status: 'source_conflict_blocker' };
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('blocked');
expect(result.nextAction).toBe('resolve_conflict');
});
it('reports superseded for a candidate a newer revision replaced', () => {
const facet: SourceFacet = { ...identity, status: 'source_superseded', supersededGenerationId: 'gen-old' };
expect(outcomeFromSourceFacet(facet).outcome).toBe('superseded');
});
it('reports retry_scheduled with the retry time surfaced', () => {
const facet: SourceFacet = { ...identity, status: 'source_retry_scheduled', retryAt: 12345, retryCount: 2 };
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('retry_scheduled');
expect(result.retryAt).toBe(12345);
expect(result.nextAction).toBe('none');
});
it('reports suspended with a resume next action', () => {
const facet: SourceFacet = { ...identity, status: 'source_suspended', suspendedAt: 999, suspendedReason: 'operator paused sync' };
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('suspended');
expect(result.nextAction).toBe('resume');
expect(result.reason).toContain('operator paused sync');
});
it('reports failed_previous_intact for a source failure, with a retry next action when a retry is scheduled', () => {
const facet: SourceFacet = {
...identity,
status: 'source_failed',
failureStage: 'fetch',
failureClass: 'permanent',
failureAt: 100,
retryAt: 200,
retryCount: 1,
};
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('failed_previous_intact');
expect(result.nextAction).toBe('retry');
expect(result.retryAt).toBe(200);
});
it('reports failed_previous_intact with no retry next action when no retry is scheduled', () => {
const facet: SourceFacet = {
...identity,
status: 'source_failed',
failureStage: 'fetch',
failureClass: 'permanent',
failureAt: 100,
retryAt: null,
retryCount: 0,
};
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('failed_previous_intact');
expect(result.nextAction).toBe('configure_credentials');
});
it('reports recovery_required for an interrupted operation', () => {
const facet: SourceFacet = {
...identity,
status: 'source_unknown',
interruptedStage: 'fetch_started',
interruptedAt: 100,
interruptedOperationId: 'op-1',
interruptedGenerationId: null,
};
expect(outcomeFromSourceFacet(facet).outcome).toBe('recovery_required');
});
it('reports recovery_required with a view_target_results next action when recovery is outstanding', () => {
const facet: SourceFacet = { ...identity, status: 'recovery_required', recoveryRef: 'rec-1', recoveryGenerationId: 'gen-1' };
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('recovery_required');
expect(result.nextAction).toBe('view_target_results');
});
it('reports recovery_required when recovery itself failed, distinguishing that in the reason', () => {
const facet: SourceFacet = {
...identity,
status: 'recovery_failed',
recoveryRef: 'rec-1',
recoveryGenerationId: 'gen-1',
failureClass: 'io_error',
failureAt: 100,
};
const result = outcomeFromSourceFacet(facet);
expect(result.outcome).toBe('recovery_required');
expect(result.reason).toMatch(/recovery/i);
});
it('reports unknown for an application that is no longer live', () => {
const facet: SourceFacet = { ...identity, status: 'not_live', lifecycleStatus: 'detached' };
expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown');
});
it.each(['not_applicable', 'never_reconciled', 'checking_fetching', 'source_reconcile_required'] as const)(
'reports unknown for %s, which has no settled outcome yet',
(status) => {
const facet = status === 'not_applicable'
? ({ status } as SourceFacet)
: ({ ...identity, status } as SourceFacet);
expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown');
},
);
it('reports unknown while an operation is in flight (applying)', () => {
const facet: SourceFacet = { ...identity, status: 'applying', activeOperationId: 'op-1', activeGenerationId: 'gen-1' };
expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown');
});
});
@@ -0,0 +1,387 @@
/**
* Durable reconcile-attempt reservation and settlement: a bare history
* insert in its own transaction, never through mutateApp, so a reservation
* writes no application-row state and can be safely repeated.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { GitOpsStore } from '../services/gitops/store';
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
import { DatabaseService } from '../services/DatabaseService';
import type { GitOpsApplicationRow } from '../services/gitops/types';
let tmpDir: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
GitOpsStore.resetForTests();
GitOpsTransitions.resetForTests();
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('reconcile attempt reservation and settlement', () => {
it('reserves an attempt without touching application state', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-res', 'res-web'), nodeId: 1, envelope: env('op-act-res') });
const before = store.getApplication('app-res')!;
const result = tx.reserveReconcileAttempt('app-res', env('op-res-1'));
expect(result.reserved).toBe(true);
const after = store.getApplication('app-res')!;
expect(after.updated_at).toBe(before.updated_at);
expect(after.desired_commit_sha).toBe(before.desired_commit_sha);
});
it('returns reserved: false on a repeated reservation for the same operation', () => {
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-res2', 'res2-web'), nodeId: 1, envelope: env('op-act-res2') });
const first = tx.reserveReconcileAttempt('app-res2', env('op-res2-1'));
const second = tx.reserveReconcileAttempt('app-res2', env('op-res2-1'));
expect(first.reserved).toBe(true);
expect(second.reserved).toBe(false);
});
it('allows two different operations to each reserve their own attempt', () => {
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-res3', 'res3-web'), nodeId: 1, envelope: env('op-act-res3') });
const first = tx.reserveReconcileAttempt('app-res3', env('op-res3-a'));
const second = tx.reserveReconcileAttempt('app-res3', env('op-res3-b'));
expect(first.reserved).toBe(true);
expect(second.reserved).toBe(true);
});
it('settles a reserved attempt and finds it by operation id afterward', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-settle', 'settle-web'), nodeId: 1, envelope: env('op-act-settle') });
tx.reserveReconcileAttempt('app-settle', env('op-settle-1'));
const settleResult = tx.settleReconcileAttempt('app-settle', env('op-settle-1'), {
outcome: 'no_source_change',
reason: 'Nothing new to fetch.',
nextAction: 'none',
});
expect(settleResult.settled).toBe(true);
const settled = store.getSettledAttempt('app-settle', 'op-settle-1');
expect(settled).toBeDefined();
});
it('settling twice for the same operation is a no-op the second time', () => {
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-settle2', 'settle2-web'), nodeId: 1, envelope: env('op-act-settle2') });
tx.reserveReconcileAttempt('app-settle2', env('op-settle2-1'));
const first = tx.settleReconcileAttempt('app-settle2', env('op-settle2-1'), {
outcome: 'no_source_change',
reason: 'first',
nextAction: 'none',
});
const second = tx.settleReconcileAttempt('app-settle2', env('op-settle2-1'), {
outcome: 'no_source_change',
reason: 'second, must not overwrite',
nextAction: 'none',
});
expect(first.settled).toBe(true);
expect(second.settled).toBe(false);
});
it('has no settled attempt for a reservation that was never settled', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-orphan', 'orphan-web'), nodeId: 1, envelope: env('op-act-orphan') });
tx.reserveReconcileAttempt('app-orphan', env('op-orphan-1'));
expect(store.getSettledAttempt('app-orphan', 'op-orphan-1')).toBeUndefined();
});
it('lists an unsettled reservation but not one that has settled', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-unsettled', 'unsettled-web'), nodeId: 1, envelope: env('op-act-unsettled') });
tx.reserveReconcileAttempt('app-unsettled', env('op-unsettled-orphan'));
tx.reserveReconcileAttempt('app-unsettled', env('op-unsettled-done'));
tx.settleReconcileAttempt('app-unsettled', env('op-unsettled-done'), {
outcome: 'no_source_change',
reason: 'done',
nextAction: 'none',
});
const unsettled = store.listUnsettledReconcileAttempts();
const operationIds = unsettled.filter((r) => r.application_id === 'app-unsettled').map((r) => r.operation_id);
expect(operationIds).toContain('op-unsettled-orphan');
expect(operationIds).not.toContain('op-unsettled-done');
});
it('gets the started row for one exact attempt, or undefined when it was never reserved', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-started', 'started-web'), nodeId: 1, envelope: env('op-act-started') });
tx.reserveReconcileAttempt('app-started', env('op-started-1'));
expect(store.getStartedAttempt('app-started', 'op-started-1')).toBeDefined();
expect(store.getStartedAttempt('app-started', 'op-never-reserved')).toBeUndefined();
});
it('pages past a permanently unsettled row instead of returning it forever on every call with the same cursor', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-cursor', 'cursor-web'), nodeId: 1, envelope: env('op-act-cursor') });
tx.reserveReconcileAttempt('app-cursor', { operationId: 'op-cursor-stuck', actor: 'tester', trigger: 'manual', at: 1_000 });
tx.reserveReconcileAttempt('app-cursor', { operationId: 'op-cursor-next', actor: 'tester', trigger: 'manual', at: 2_000 });
const firstPage = store.listUnsettledReconcileAttempts(1);
expect(firstPage.map((r) => r.operation_id)).toEqual(['op-cursor-stuck']);
const cursor = { createdAt: firstPage[0].created_at, id: firstPage[0].id };
// Simulate op-cursor-stuck being permanently unrecoverable: it is never
// settled, so a caller must page past it using the cursor rather than
// seeing it again on the next call.
const secondPage = store.listUnsettledReconcileAttempts(1, cursor);
expect(secondPage.map((r) => r.operation_id)).toEqual(['op-cursor-next']);
});
it('allocates a fresh attemptSeq-derived operation id and reserves it in one transaction', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-alloc', 'alloc-web'), nodeId: 1, envelope: env('op-act-alloc') });
const before = store.getApplication('app-alloc')!;
const first = tx.allocateReconcileAttempt('app-alloc', 'tester', 'manual', Date.now());
const second = tx.allocateReconcileAttempt('app-alloc', 'tester', 'manual', Date.now());
expect(first.reserved).toBe(true);
expect(second.reserved).toBe(true);
expect(first.operationId).not.toBe(second.operationId);
const after = store.getApplication('app-alloc')!;
expect(after.attempt_seq).toBe(before.attempt_seq + 2);
});
it('rolls back the allocated sequence when reservation insertion fails', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-alloc-rollback', 'alloc-rollback-web'), nodeId: 1, envelope: env('op-act-alloc-rollback') });
const db = DatabaseService.getInstance().getDb();
const before = store.getApplication('app-alloc-rollback')!;
db.exec(`
CREATE TRIGGER fail_reconcile_reservation
BEFORE INSERT ON gitops_history
WHEN NEW.stage = 'source_reconcile_started'
BEGIN
SELECT RAISE(ABORT, 'simulated reservation insert failure');
END
`);
try {
expect(() => tx.allocateReconcileAttempt('app-alloc-rollback', 'tester', 'manual', Date.now()))
.toThrow('simulated reservation insert failure');
expect(store.getApplication('app-alloc-rollback')!.attempt_seq).toBe(before.attempt_seq);
expect(store.listUnsettledReconcileAttempts().some((row) => row.application_id === 'app-alloc-rollback')).toBe(false);
} finally {
db.exec('DROP TRIGGER fail_reconcile_reservation');
}
});
it('records a follower link on a reservation made on behalf of a coalesced request', () => {
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-follower', 'follower-web'), nodeId: 1, envelope: env('op-act-follower') });
const leader = tx.allocateReconcileAttempt('app-follower', 'tester', 'manual', Date.now());
const follower = tx.allocateReconcileAttempt('app-follower', 'tester', 'manual', Date.now(), leader.operationId);
expect(follower.reserved).toBe(true);
const started = DatabaseService.getInstance().getDb()
.prepare("SELECT after_json FROM gitops_history WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_started'")
.get('app-follower', follower.operationId) as { after_json: string };
expect(JSON.parse(started.after_json)).toEqual({ followerOf: leader.operationId });
});
it('records the original webhook delivery intent on its stable reservation', () => {
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-delivery-intent', 'delivery-intent-web'), nodeId: 1, envelope: env('op-act-delivery-intent') });
tx.reserveReconcileAttempt(
'app-delivery-intent',
env('webhook:fetch:delivery-intent'),
undefined,
{ autoApply: true, deploy: false },
);
const started = GitOpsStore.getInstance().getStartedAttempt('app-delivery-intent', 'webhook:fetch:delivery-intent')!;
expect(JSON.parse(started.after_json)).toEqual({
deliveryIntent: { autoApply: true, deploy: false },
});
});
it('reports the most recently settled attempt even when both share the same millisecond timestamp', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
tx.activateDirect({ application: app('app-latest', 'latest-web'), nodeId: 1, envelope: env('op-act-latest') });
// Same `at` on purpose: created_at alone cannot tell these two apart,
// so the query must break the tie by insertion order (rowid), not the
// id column, which is a random UUID unrelated to recency.
const sameInstant = 5_000;
tx.reserveReconcileAttempt('app-latest', { operationId: 'op-latest-1', actor: 'tester', trigger: 'manual', at: sameInstant });
tx.settleReconcileAttempt(
'app-latest',
{ operationId: 'op-latest-1', actor: 'tester', trigger: 'manual', at: sameInstant },
{ outcome: 'no_source_change', reason: 'first', nextAction: 'none' },
);
tx.reserveReconcileAttempt('app-latest', { operationId: 'op-latest-2', actor: 'tester', trigger: 'manual', at: sameInstant });
tx.settleReconcileAttempt(
'app-latest',
{ operationId: 'op-latest-2', actor: 'tester', trigger: 'manual', at: sameInstant },
{ outcome: 'retry_scheduled', reason: 'second', nextAction: 'none' },
);
const latest = store.latestSettledAttempt('app-latest');
expect(latest?.operation_id).toBe('op-latest-2');
});
});
describe('poll and retry eligibility queries', () => {
it('lists a source whose next_poll_at has arrived', () => {
const store = GitOpsStore.getInstance();
store.insertApplication({ ...app('app-poll-due', 'poll-due-web'), next_poll_at: 1_000 });
const due = store.listSourcesDueForPoll(1_000);
expect(due.map((a) => a.id)).toContain('app-poll-due');
});
it('excludes a source whose next_poll_at has not arrived yet', () => {
const store = GitOpsStore.getInstance();
store.insertApplication({ ...app('app-poll-future', 'poll-future-web'), next_poll_at: 5_000 });
const due = store.listSourcesDueForPoll(1_000);
expect(due.map((a) => a.id)).not.toContain('app-poll-future');
});
it('excludes a suspended source even when its poll time has arrived', () => {
const store = GitOpsStore.getInstance();
store.insertApplication({ ...app('app-poll-susp', 'poll-susp-web'), next_poll_at: 1_000, suspended_at: 500 });
const due = store.listSourcesDueForPoll(1_000);
expect(due.map((a) => a.id)).not.toContain('app-poll-susp');
});
it('excludes a source with an operation already in flight', () => {
const store = GitOpsStore.getInstance();
store.insertApplication({
...app('app-poll-busy', 'poll-busy-web'),
next_poll_at: 1_000,
active_operation_stage: 'fetch_started',
});
const due = store.listSourcesDueForPoll(1_000);
expect(due.map((a) => a.id)).not.toContain('app-poll-busy');
});
it('excludes a Blueprint-mode application from polling', () => {
const store = GitOpsStore.getInstance();
store.insertApplication({
...app('app-poll-bp', 'unused-bp'),
stack_name: null,
blueprint_id: 42,
target_mode: 'blueprint',
configured_repo_url: 'https://github.com/org/repo.git',
next_poll_at: 1_000,
});
const due = store.listSourcesDueForPoll(1_000);
expect(due.map((a) => a.id)).not.toContain('app-poll-bp');
});
it('lists an application whose retry_at has arrived', () => {
const store = GitOpsStore.getInstance();
store.insertApplication({ ...app('app-retry-due', 'retry-due-web'), retry_at: 1_000 });
const due = store.listApplicationsDueForRetry(1_000);
expect(due.map((a) => a.id)).toContain('app-retry-due');
});
it('excludes an application with no retry scheduled', () => {
const store = GitOpsStore.getInstance();
store.insertApplication(app('app-retry-none', 'retry-none-web'));
const due = store.listApplicationsDueForRetry(1_000);
expect(due.map((a) => a.id)).not.toContain('app-retry-none');
});
it('excludes a suspended application even when its retry time has arrived', () => {
const store = GitOpsStore.getInstance();
store.insertApplication({ ...app('app-retry-susp', 'retry-susp-web'), retry_at: 1_000, suspended_at: 500 });
const due = store.listApplicationsDueForRetry(1_000);
expect(due.map((a) => a.id)).not.toContain('app-retry-susp');
});
});
function env(operationId: string): EventEnvelope {
return { operationId, actor: 'tester', trigger: 'manual', at: Date.now() };
}
function app(id: string, stackName: string): GitOpsApplicationRow {
return {
id,
lifecycle_key: `direct:${stackName}`,
lifecycle_status: 'active',
target_mode: 'direct',
stack_name: stackName,
blueprint_id: null,
configured_repo_url: 'https://github.com/org/repo.git',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
configured_ref: 'main',
compose_paths_json: '["compose.yml"]',
context_dir: null,
sync_env: 0,
env_path: null,
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
review_required: 0,
artifact_set_id: null,
latest_artifact_set_id: null,
intent_revision_id: null,
rollout_candidate_id: null,
rollout_generation_id: null,
source_acceptance_ref: null,
placement_approval_ref: null,
rollout_authorization_ref: null,
legacy_combined_approval_ref: null,
preflight_fingerprint: null,
latest_operation_id: null,
active_operation_id: null,
active_operation_stage: null,
active_operation_at: null,
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
failure_at: null,
retry_at: null,
retry_count: 0,
suspended_at: null,
recovery_ref: null,
recovery_phase: null,
interruption_stage: null,
interruption_at: null,
interruption_operation_id: null,
interruption_generation_id: null,
evidence_fresh_at: null,
evidence_limitations_json: null,
created_at: 1,
updated_at: 1,
};
}
@@ -146,6 +146,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -188,6 +193,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -460,6 +460,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -502,6 +507,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -239,6 +239,61 @@ describe('gitops schema', () => {
expect(row?.configured_ref).toBe('v1');
});
it('round-trips the portable generation contract fields, defaulting to null for legacy rows', async () => {
const store = GitOpsStore.getInstance();
store.insertApplication(directApp('app-portable', 'portable-web'));
store.insertGeneration({
...generation('gen-portable-legacy', 'app-portable'),
});
const legacy = store.getGeneration('gen-portable-legacy');
expect(legacy?.portable_manifest_json).toBeNull();
expect(legacy?.compose_inputs_json).toBeNull();
expect(legacy?.source_policy_evidence_json).toBeNull();
expect(legacy?.security_policy_evidence_json).toBeNull();
expect(legacy?.support_requirements_json).toBeNull();
expect(legacy?.compatibility_requirements_json).toBeNull();
store.insertGeneration({
...generation('gen-portable-new', 'app-portable'),
portable_manifest_json: '{"files":[]}',
compose_inputs_json: '{"composeFileOrder":["compose.yaml"]}',
source_policy_evidence_json: '{"policy":"manual"}',
security_policy_evidence_json: '{"status":"allowed"}',
support_requirements_json: '{}',
compatibility_requirements_json: '{}',
});
const populated = store.getGeneration('gen-portable-new');
expect(populated?.portable_manifest_json).toBe('{"files":[]}');
expect(populated?.compose_inputs_json).toBe('{"composeFileOrder":["compose.yaml"]}');
expect(populated?.source_policy_evidence_json).toBe('{"policy":"manual"}');
});
it('defaults controller-owned columns to manual, off, and zero on a fresh application', async () => {
const store = GitOpsStore.getInstance();
store.insertApplication(directApp('app-ctrl', 'ctrl-web'));
const app = store.getApplication('app-ctrl');
expect(app?.source_policy).toBe('manual');
expect(app?.poll_interval_secs).toBeNull();
expect(app?.next_poll_at).toBeNull();
expect(app?.attempt_seq).toBe(0);
});
it('round-trips a configured poll interval and policy', async () => {
const store = GitOpsStore.getInstance();
store.insertApplication({
...directApp('app-ctrl2', 'ctrl2-web'),
source_policy: 'automatic',
poll_interval_secs: 120,
next_poll_at: 5000,
attempt_seq: 3,
});
const app = store.getApplication('app-ctrl2');
expect(app?.source_policy).toBe('automatic');
expect(app?.poll_interval_secs).toBe(120);
expect(app?.next_poll_at).toBe(5000);
expect(app?.attempt_seq).toBe(3);
});
it('round-trips fetched_resolved_ref_kind on application fetch transitions', async () => {
const store = GitOpsStore.getInstance();
store.insertApplication(directApp('app-fetch-kind', 'fetch-web'));
@@ -293,6 +348,11 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -361,6 +421,12 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
import { encodeArtifactEvidenceJson } from '../services/gitops/json';
import { GitOpsStore } from '../services/gitops/store';
import { GitOpsStore, emptyTargetRow } from '../services/gitops/store';
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
import { projectApplication } from '../services/gitops/derive';
import type { GitOpsApplicationRow, GitOpsGenerationRow } from '../services/gitops/types';
@@ -520,6 +520,222 @@ describe('gitops transitions', () => {
expect(store.getTarget('app-int', 1)?.deployed_generation_id).toBe('gen-int');
expect(store.getTarget('app-int', 1)?.failure_stage).toBeNull();
});
it('sourceAccepted accepts the generation without touching any target', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
const env = envelope('op-sa');
tx.activateDirect({ application: app('app-sa', 'sa-web'), nodeId: 1, envelope: env });
store.insertGeneration(gen('gen-sa', 'app-sa'));
tx.fetchStarted('app-sa', envelope('op-f-sa'));
tx.fetched('app-sa', 'deadbeef', envelope('op-f-sa'));
tx.candidateReady('app-sa', 'gen-sa', false, envelope('op-c-sa'));
tx.applyStarted('app-sa', 'gen-sa', envelope('op-sa'));
const result = tx.sourceAccepted({
applicationId: 'app-sa',
generationId: 'gen-sa',
artifactSetId: 'art-sa',
sourceAcceptanceId: 'acc-sa',
authority: 'operator',
envelope: env,
});
expect(result.replayed).toBe(false);
const application = store.getApplication('app-sa')!;
expect(application.accepted_generation_id).toBe('gen-sa');
expect(application.source_acceptance_ref).toBe('acc-sa');
expect(application.candidate_generation_id).toBeNull();
// sourceAccepted is mode-neutral: it must not bind the Direct target.
const target = store.getTarget('app-sa', 1)!;
expect(target.applied_generation_id).toBeNull();
expect(target.desired_generation_id).toBeNull();
expect(target.candidate_generation_id).toBe('gen-sa');
});
it('sourceAccepted refuses to accept a candidate while the source is suspended', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
const env = envelope('op-sas');
tx.activateDirect({ application: app('app-sas', 'sas-web'), nodeId: 1, envelope: env });
store.insertGeneration(gen('gen-sas', 'app-sas'));
tx.fetchStarted('app-sas', envelope('op-f-sas'));
tx.fetched('app-sas', 'deadbeef', envelope('op-f-sas'));
tx.candidateReady('app-sas', 'gen-sas', false, envelope('op-c-sas'));
tx.applyStarted('app-sas', 'gen-sas', envelope('op-sas'));
tx.sourceSuspended('app-sas', 'operator paused sync', envelope('op-susp-sas'));
expect(() => tx.sourceAccepted({
applicationId: 'app-sas',
generationId: 'gen-sas',
artifactSetId: 'art-sas',
sourceAcceptanceId: 'acc-sas',
authority: 'operator',
envelope: envelope('op-sas-2'),
})).toThrow(/suspended/);
const application = store.getApplication('app-sas')!;
expect(application.accepted_generation_id).toBeNull();
expect(application.candidate_generation_id).toBe('gen-sas');
expect(application.suspended_at).not.toBeNull();
});
it('targetApplied binds a Direct target only after the generation is accepted', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
const env = envelope('op-ta');
tx.activateDirect({ application: app('app-ta', 'ta-web'), nodeId: 1, envelope: env });
store.insertGeneration(gen('gen-ta', 'app-ta'));
tx.fetchStarted('app-ta', envelope('op-f-ta'));
tx.fetched('app-ta', 'deadbeef', envelope('op-f-ta'));
tx.candidateReady('app-ta', 'gen-ta', false, envelope('op-c-ta'));
tx.applyStarted('app-ta', 'gen-ta', envelope('op-ta'));
tx.sourceAccepted({
applicationId: 'app-ta',
generationId: 'gen-ta',
artifactSetId: 'art-ta',
sourceAcceptanceId: 'acc-ta',
authority: 'operator',
envelope: env,
});
const result = tx.targetApplied(1, {
applicationId: 'app-ta',
generationId: 'gen-ta',
artifactSetId: 'art-ta',
sourceAcceptanceId: 'acc-ta',
authority: 'operator',
envelope: env,
});
expect(result.replayed).toBe(false);
const target = store.getTarget('app-ta', 1)!;
expect(target.applied_generation_id).toBe('gen-ta');
expect(target.desired_generation_id).toBe('gen-ta');
expect(target.candidate_generation_id).toBeNull();
expect(target.expected_artifact_set_id).toBe('art-ta');
expect(target.source_acceptance_ref).toBe('acc-ta');
});
it('targetApplied refuses a delayed dispatch that would erase a newer candidate', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
const env = envelope('op-tan');
tx.activateDirect({ application: app('app-tan', 'tan-web'), nodeId: 1, envelope: env });
store.insertGeneration(gen('gen-tan-1', 'app-tan'));
tx.fetchStarted('app-tan', envelope('op-f-tan-1'));
tx.fetched('app-tan', 'deadbeef', envelope('op-f-tan-1'));
tx.candidateReady('app-tan', 'gen-tan-1', false, envelope('op-c-tan-1'));
tx.applyStarted('app-tan', 'gen-tan-1', envelope('op-tan'));
tx.sourceAccepted({
applicationId: 'app-tan',
generationId: 'gen-tan-1',
artifactSetId: 'art-tan-1',
sourceAcceptanceId: 'acc-tan-1',
authority: 'operator',
envelope: env,
});
// A newer revision arrives and supersedes generation 1 as the target's
// current candidate, before generation 1's dispatch ever binds a target.
store.insertGeneration(gen('gen-tan-2', 'app-tan'));
tx.fetchStarted('app-tan', envelope('op-f-tan-2'));
tx.fetched('app-tan', 'cafed00d', envelope('op-f-tan-2'));
tx.candidateReady('app-tan', 'gen-tan-2', false, envelope('op-c-tan-2'));
// Generation 1's delayed dispatch must not silently erase generation 2's
// candidate, even though generation 1 is (still) the accepted generation.
expect(() => tx.targetApplied(1, {
applicationId: 'app-tan',
generationId: 'gen-tan-1',
artifactSetId: 'art-tan-1',
sourceAcceptanceId: 'acc-tan-1',
authority: 'operator',
envelope: envelope('op-ta-delayed'),
})).toThrow(/candidate/);
const target = store.getTarget('app-tan', 1)!;
expect(target.candidate_generation_id).toBe('gen-tan-2');
expect(target.applied_generation_id).toBeNull();
});
it('targetApplied refuses a source acceptance reference that does not match what was accepted', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
const env = envelope('op-tar2');
tx.activateDirect({ application: app('app-tar2', 'tar2-web'), nodeId: 1, envelope: env });
store.insertGeneration(gen('gen-tar2', 'app-tar2'));
tx.fetchStarted('app-tar2', envelope('op-f-tar2'));
tx.fetched('app-tar2', 'deadbeef', envelope('op-f-tar2'));
tx.candidateReady('app-tar2', 'gen-tar2', false, envelope('op-c-tar2'));
tx.applyStarted('app-tar2', 'gen-tar2', envelope('op-tar2'));
tx.sourceAccepted({
applicationId: 'app-tar2',
generationId: 'gen-tar2',
artifactSetId: 'art-tar2',
sourceAcceptanceId: 'acc-tar2-real',
authority: 'operator',
envelope: env,
});
expect(() => tx.targetApplied(1, {
applicationId: 'app-tar2',
generationId: 'gen-tar2',
artifactSetId: 'art-tar2',
sourceAcceptanceId: 'acc-tar2-forged',
authority: 'operator',
envelope: envelope('op-ta-forged'),
})).toThrow(/acceptance/);
const target = store.getTarget('app-tar2', 1)!;
expect(target.source_acceptance_ref).not.toBe('acc-tar2-forged');
});
it('targetApplied refuses to bind a target on a non-Direct application', async () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
const { blankInlineApplication } = await import('../services/gitops/blueprintProducers');
const env = envelope('op-tam');
// A genuine Blueprint application: no Git identity, so the "accepted
// generation" and target row below are constructed directly rather than
// through the Direct fetch/candidate/apply path, which this mode does
// not have.
tx.activateInlineBlueprint({ application: blankInlineApplication('app-tam', 900, env.at), envelope: env });
store.insertGeneration(gen('gen-tam', 'app-tam'));
DatabaseService.getInstance().getDb().prepare(
"UPDATE gitops_applications SET accepted_generation_id = 'gen-tam' WHERE id = 'app-tam'",
).run();
store.upsertTarget(emptyTargetRow('app-tam', 1, env.at));
expect(() => tx.targetApplied(1, {
applicationId: 'app-tam',
generationId: 'gen-tam',
artifactSetId: 'art-tam',
sourceAcceptanceId: 'acc-tam',
authority: 'operator',
envelope: env,
})).toThrow(/direct/i);
});
it('targetApplied refuses a generation the application has not accepted', () => {
const store = GitOpsStore.getInstance();
const tx = GitOpsTransitions.getInstance();
const env = envelope('op-tar');
tx.activateDirect({ application: app('app-tar', 'tar-web'), nodeId: 1, envelope: env });
store.insertGeneration(gen('gen-tar', 'app-tar'));
tx.fetchStarted('app-tar', envelope('op-f-tar'));
tx.fetched('app-tar', 'deadbeef', envelope('op-f-tar'));
tx.candidateReady('app-tar', 'gen-tar', false, envelope('op-c-tar'));
expect(() => tx.targetApplied(1, {
applicationId: 'app-tar',
generationId: 'gen-tar',
artifactSetId: 'art-tar',
sourceAcceptanceId: 'acc-tar',
authority: 'operator',
envelope: env,
})).toThrow(/not accepted/);
});
});
function mustProject(applicationId: string) {
@@ -596,6 +812,11 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -638,6 +859,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
actor: 'tester',
previous_generation_id: null,
redacted_limitations_json: '[]',
portable_manifest_json: null,
compose_inputs_json: null,
source_policy_evidence_json: null,
security_policy_evidence_json: null,
support_requirements_json: null,
compatibility_requirements_json: null,
created_at: 1,
};
}
@@ -0,0 +1,85 @@
/**
* Pure trigger normalization and coalescing-key coverage for the GitOps
* source controller. No DB, no store: these are the identity/joining rules
* a controller submission goes through before anything durable happens.
*/
import { describe, it, expect } from 'vitest';
import { coalesceKey, deliveryKey, type ReconcileRequest } from '../services/gitops/triggers';
function fetchRequest(overrides: Partial<Extract<ReconcileRequest, { intent: 'fetch' }>> = {}): ReconcileRequest {
return {
intent: 'fetch',
applicationId: 'app-1',
stackName: 'web',
trigger: 'manual',
actor: 'tester',
...overrides,
};
}
function applyRequest(overrides: Partial<Extract<ReconcileRequest, { intent: 'apply' }>> = {}): ReconcileRequest {
return {
intent: 'apply',
applicationId: 'app-1',
stackName: 'web',
trigger: 'manual',
actor: 'tester',
commitSha: 'a'.repeat(40),
planFingerprint: 'fp-1',
deploy: false,
...overrides,
};
}
describe('coalesceKey', () => {
it('joins two fetch requests for the same application', () => {
expect(coalesceKey(fetchRequest())).toBe(coalesceKey(fetchRequest({ trigger: 'poll' })));
});
it('does not join fetch requests for different applications', () => {
expect(coalesceKey(fetchRequest({ applicationId: 'app-1' })))
.not.toBe(coalesceKey(fetchRequest({ applicationId: 'app-2' })));
});
it('joins two apply requests with identical commit, fingerprint, and deploy flag', () => {
expect(coalesceKey(applyRequest())).toBe(coalesceKey(applyRequest({ trigger: 'webhook' })));
});
it('does not join two applies with different plan fingerprints', () => {
expect(coalesceKey(applyRequest({ planFingerprint: 'fp-1' })))
.not.toBe(coalesceKey(applyRequest({ planFingerprint: 'fp-2' })));
});
it('does not join two applies with different commits', () => {
expect(coalesceKey(applyRequest({ commitSha: 'a'.repeat(40) })))
.not.toBe(coalesceKey(applyRequest({ commitSha: 'b'.repeat(40) })));
});
it('does not join two applies that differ only in deploy', () => {
expect(coalesceKey(applyRequest({ deploy: false })))
.not.toBe(coalesceKey(applyRequest({ deploy: true })));
});
it('never joins a fetch and an apply for the same application', () => {
expect(coalesceKey(fetchRequest())).not.toBe(coalesceKey(applyRequest()));
});
it('does not join two fetches for the same applicationId but different stack names', () => {
expect(coalesceKey(fetchRequest({ stackName: 'web' })))
.not.toBe(coalesceKey(fetchRequest({ stackName: 'other-stack' })));
});
});
describe('deliveryKey', () => {
it('namespaces the same delivery id differently per trigger', () => {
expect(deliveryKey('webhook', 'fetch', 'delivery-1')).not.toBe(deliveryKey('api', 'fetch', 'delivery-1'));
});
it('namespaces the same delivery id differently per intent', () => {
expect(deliveryKey('webhook', 'fetch', 'delivery-1')).not.toBe(deliveryKey('webhook', 'apply', 'delivery-1'));
});
it('is stable for the same trigger, intent, and delivery id', () => {
expect(deliveryKey('webhook', 'fetch', 'delivery-1')).toBe(deliveryKey('webhook', 'fetch', 'delivery-1'));
});
});
@@ -0,0 +1,98 @@
import { vi } from 'vitest';
import http from 'http';
import https from 'https';
import type { LookupFunction } from 'net';
import { AsyncLocalStorage } from 'async_hooks';
const targetProtectionScope = new AsyncLocalStorage<boolean>();
export async function withLoopbackTargetProtection<T>(action: () => PromiseLike<T>): Promise<T> {
return targetProtectionScope.run(false, async () => action());
}
vi.mock('../../utils/outboundTarget', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils/outboundTarget')>();
const fixtureAllows = (hostname: string): boolean => {
if (targetProtectionScope.getStore() === false) return false;
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase();
return normalized === 'localhost'
|| normalized === '::1'
|| normalized.startsWith('127.')
|| normalized.startsWith('10.')
|| normalized.startsWith('192.168.')
|| /^172\.(1[6-9]|2\d|3[01])\./.test(normalized)
|| normalized.endsWith('.example.com')
|| normalized.endsWith('.example')
|| normalized.endsWith('.invalid')
|| normalized.endsWith('.local')
|| normalized === 'remote'
|| normalized === 'remote2'
|| normalized === 'good-host'
|| normalized === 'bad-host';
};
const fixtureAddress = (hostname: string): string => {
const normalized = hostname.replace(/^\[|\]$/g, '');
if (normalized === 'localhost') return '127.0.0.1';
if (normalized === 'pinned.example') return '93.184.216.34';
return normalized;
};
const fixtureLookup: LookupFunction = (hostname, options, callback): void => {
if (!fixtureAllows(hostname)) {
actual.safeOutboundLookup(hostname, options, callback);
return;
}
const normalized = hostname.replace(/^\[|\]$/g, '');
const address = fixtureAddress(hostname);
const family = normalized === '::1' ? 6 : 4;
if (options.all) callback(null, [{ address, family }]);
else callback(null, address, family);
};
const fixtureHttpAgent = new http.Agent({ lookup: fixtureLookup });
const fixtureHttpsAgent = new https.Agent({ lookup: fixtureLookup });
return {
...actual,
assertSafeOutboundHostname: async (
hostname: string,
): Promise<void> => {
if (fixtureAllows(hostname)) return;
await actual.assertSafeOutboundHostname(hostname);
},
assertSafeOutboundUrl: async (
raw: string,
): Promise<URL> => {
const url = new URL(raw);
if (fixtureAllows(url.hostname)) return url;
return actual.assertSafeOutboundUrl(raw);
},
resolveSafeOutboundHostname: async (hostname: string) => {
if (fixtureAllows(hostname)) {
const normalized = hostname.replace(/^\[|\]$/g, '');
return [{ address: fixtureAddress(hostname), family: normalized === '::1' ? 6 : 4 }];
}
return actual.resolveSafeOutboundHostname(hostname);
},
safeOutboundLookup: fixtureLookup,
safeHttpAgent: fixtureHttpAgent,
safeHttpsAgent: fixtureHttpsAgent,
safeAxiosTransport: (trustedLoopback = false) => ({
maxRedirects: 0,
proxy: false,
...(trustedLoopback ? {} : { httpAgent: fixtureHttpAgent, httpsAgent: fixtureHttpsAgent }),
}),
safeRemoteFetch: async (
input: Parameters<typeof actual.safeRemoteFetch>[0],
init?: Parameters<typeof actual.safeRemoteFetch>[1],
trustedLoopback?: boolean,
) => {
const url = input instanceof URL
? input
: new URL(typeof input === 'string' ? input : input.url);
if (fixtureAllows(url.hostname)) {
return globalThis.fetch(input, { ...init, redirect: 'error' });
}
return actual.safeRemoteFetch(input, init, trustedLoopback);
},
};
});
@@ -56,6 +56,11 @@ export function directApplicationFixture(id: string, stackName: string): GitOpsA
active_generation_id: null,
pause_at: null,
pause_reason: null,
source_suspended_reason: null,
source_policy: 'manual',
poll_interval_secs: null,
next_poll_at: null,
attempt_seq: 0,
partial_json: null,
failure_stage: null,
failure_class: null,
@@ -368,4 +368,108 @@ describe('hubOnlyGuard', () => {
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
expect(res.status).not.toBe(503);
});
// Regression for api-tokens hub-only boundary: /api/api-tokens manages
// sensitive bearer material and must stay hub-only.
it('rejects /api/api-tokens (GET proxy) with 403', async () => {
const res = await request(app)
.get('/api/api-tokens')
.set('Authorization', authHeader)
.set('x-node-id', String(proxyRemoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/api-tokens (POST proxy) with 403', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Authorization', authHeader)
.set('x-node-id', String(proxyRemoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/api-tokens/:id (DELETE proxy) with 403', async () => {
const res = await request(app)
.delete('/api/api-tokens/1')
.set('Authorization', authHeader)
.set('x-node-id', String(proxyRemoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/api-tokens (GET pilot_agent) with 403', async () => {
const res = await request(app)
.get('/api/api-tokens')
.set('Authorization', authHeader)
.set('x-node-id', String(pilotRemoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/api-tokens (POST pilot_agent) with 403', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Authorization', authHeader)
.set('x-node-id', String(pilotRemoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/api-tokens/:id (DELETE pilot_agent) with 403', async () => {
const res = await request(app)
.delete('/api/api-tokens/1')
.set('Authorization', authHeader)
.set('x-node-id', String(pilotRemoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/api-tokens via ?nodeId= (Pilot mode, GET)', async () => {
const res = await request(app)
.get(`/api/api-tokens?nodeId=${pilotRemoteNodeId}`)
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects mixed-case /api/API-Tokens with 403 for proxy', async () => {
const res = await request(app)
.get('/api/API-Tokens')
.set('Authorization', authHeader)
.set('x-node-id', String(proxyRemoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('lets /api/api-tokens through locally (hub-local GET)', async () => {
const res = await request(app)
.get('/api/api-tokens')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
});
it('lets /api/api-tokens through locally (hub-local POST creates token)', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Authorization', authHeader)
.send({ name: 'local-test', scope: 'read-only', expires_in: 30 });
expect(res.status).toBe(201);
expect(res.body.id).toBeDefined();
});
it('lets /api/api-tokens through locally and deletes created token (hub-local DELETE)', async () => {
// Create, then delete using the returned ID for a deterministic lifecycle.
const postRes = await request(app)
.post('/api/api-tokens')
.set('Authorization', authHeader)
.send({ name: 'local-delete-test', scope: 'full-admin', expires_in: 30 });
expect(postRes.status).toBe(201);
expect(postRes.body.id).toBeDefined();
const delRes = await request(app)
.delete(`/api/api-tokens/${postRes.body.id}`)
.set('Authorization', authHeader);
expect(delRes.status).toBe(200);
});
});
@@ -493,7 +493,7 @@ describe('GET /api/fleet/container-labels', () => {
]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
@@ -511,7 +511,7 @@ describe('GET /api/fleet/container-labels', () => {
]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
// byLabel row missing valid key/value/source: must be rejected by the deep guard.
const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } }));
@@ -528,7 +528,7 @@ describe('GET /api/fleet/container-labels', () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
@@ -544,7 +544,7 @@ describe('GET /api/fleet/container-labels', () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
@@ -560,7 +560,7 @@ describe('GET /api/fleet/container-labels', () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
@@ -0,0 +1,66 @@
import express from 'express';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
cleanupTestDb,
setupTestDb,
TEST_PASSWORD,
TEST_USERNAME,
} from './helpers/setupTestDb';
let authRouter: typeof import('../routes/auth').authRouter;
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
vi.stubEnv('NODE_ENV', 'production');
vi.resetModules();
tmpDir = await setupTestDb();
({ authRouter } = await import('../routes/auth'));
app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use('/api/auth', authRouter);
});
afterAll(() => {
cleanupTestDb(tmpDir);
vi.unstubAllEnvs();
});
describe('production login rate limiter', () => {
it('blocks repeated attempts from one source', async () => {
for (let attempt = 0; attempt < 5; attempt += 1) {
const failure = await request(app)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.51.100.10')
.send({ username: TEST_USERNAME, password: 'wrong-password' });
expect(failure.status).toBe(401);
}
const blocked = await request(app)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.51.100.10')
.send({ username: TEST_USERNAME, password: 'wrong-password' });
expect(blocked.status).toBe(429);
});
it('allows valid credentials after failures from other sources', async () => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const failure = await request(app)
.post('/api/auth/login')
.set('X-Forwarded-For', `203.0.113.${attempt + 1}`)
.send({ username: TEST_USERNAME, password: 'wrong-password' });
expect(failure.status).toBe(401);
}
const validLogin = await request(app)
.post('/api/auth/login')
.set('X-Forwarded-For', '203.0.113.250')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
expect(validLogin.status).toBe(200);
});
});
@@ -75,6 +75,7 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
const fetchMock = vi
@@ -114,6 +115,7 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 }));
@@ -73,6 +73,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
const fetchMock = vi
@@ -112,6 +113,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 }));
@@ -160,6 +162,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ stacks: ['ok-string', 42, null, { not: 'a string' }] }),
@@ -223,6 +226,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
throw new MeshError('push_failed', 'simulated transport failure');
@@ -9,6 +9,7 @@
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
@@ -74,6 +75,28 @@ describe('MeshProxyTunnelDialer', () => {
expect(dialer.hasBridge(nodeId)).toBe(false);
});
it('rejects an unsafe proxy target before opening a WebSocket', async () => {
const dialer = MeshProxyTunnelDialer.resetForTest(0);
const db = DatabaseService.getInstance();
const nodeId = db.addNode({
name: 'proxy-test-unsafe-target',
type: 'remote',
compose_dir: '',
is_default: false,
mode: 'proxy',
api_url: 'http://127.0.0.1:1852',
api_token: 'test-token',
});
const result = await withLoopbackTargetProtection(() => dialer.ensureBridge(nodeId));
expect(result).toBeNull();
expect(dialer.getRecentFailure(nodeId)).toMatchObject({
code: 'network_error',
message: 'The target address is not allowed.',
});
});
it('expires the recent-failure cache entry after the cache TTL window', async () => {
const dialer = MeshProxyTunnelDialer.resetForTest(0);
const result = await dialer.ensureBridge(8888);
@@ -47,6 +47,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
const fetchMock = vi
@@ -82,6 +83,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
const fetchMock = vi
@@ -112,6 +114,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
@@ -139,6 +142,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'remote-tok',
trustedLoopback: false,
});
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('another operation is already in progress', { status: 500 }),
@@ -1621,6 +1621,41 @@ describe('MonitorService - Sencho dev build check', () => {
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
});
// C1: update eligibility is compose-declared. A dev *running* identity must
// not make a stable-declared pin eligible for the dev-build detector, and an
// unknown running identity must still gate before any registry call. These
// two disagreement directions pin that the running identity surfaced by
// SelfIdentityService never changes update behavior.
it('does not treat a stable-declared pin as eligible even when the running identity is a dev build', async () => {
// beforeEach() already mints a dev running imageId; the declared pin is stable.
// Reset the version-update inputs: an earlier suppression test queues a
// once-value that a stable-pin run would otherwise drain into the version
// path, which is not what this test observes.
mockGetLatestVersionInfo.mockReset();
mockGetPinInfo.mockResolvedValue(STABLE_PIN);
await runEvaluate();
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
expect(devBuildCalls()).toHaveLength(0);
expect(mockSetSystemState).not.toHaveBeenCalledWith('sencho_dev_build_available_digest', expect.anything());
});
it('still gates the registry comparison on a known running image id for an eligible dev-declared pin', async () => {
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockGetIdentity.mockReturnValue({
containerId: null, containerName: null, composeProjectName: null,
imageId: null, networkNames: [], volumeNames: [],
});
await runEvaluate();
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
expect(devBuildCalls()).toHaveLength(0);
// Unknown running id takes the retry-sooner path, not the detector path.
expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
});
});
// ── Per-container parallel fan-out ────────────────────────────────────
@@ -196,7 +196,7 @@ describe('networking summary', () => {
it('degrades a remote that errors to a node-error while keeping the hub', async () => {
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-degrade', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 }));
try {
const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader);
@@ -19,6 +19,7 @@ import { WebSocket } from 'ws';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { PilotTunnelManager } from '../services/PilotTunnelManager';
import { MeshProxyTunnelDialer } from '../services/MeshProxyTunnelDialer';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
let tmpDir: string;
let app: import('express').Express;
@@ -146,6 +147,45 @@ describe('node-management write routes require node:manage', () => {
expect(res.status).toBe(200);
expect(db.getNode(id)).toBeUndefined();
});
it('rejects proxy nodes whose API URL resolves to an unsafe address', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', `Bearer ${tokenForRole('admin')}`)
.send({
name: `nm-unsafe-${Date.now()}`,
type: 'remote',
mode: 'proxy',
api_url: 'http://[::ffff:127.0.0.1]:1852',
api_token: 'test-token',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not allowed/i);
});
it('rejects updating a proxy node to an unsafe API URL', async () => {
const db = DatabaseService.getInstance();
const id = db.addNode({
name: `nm-update-unsafe-${Date.now()}`,
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp/x',
is_default: false,
api_url: 'https://remote.example.com:1852',
api_token: 'test-token',
});
const res = await withLoopbackTargetProtection(() => request(app)
.put(`/api/nodes/${id}`)
.set('Authorization', `Bearer ${tokenForRole('admin')}`)
.send({ api_url: 'http://127.0.0.1:1852' }));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not allowed/i);
expect(db.getNode(id)?.api_url).toBe('https://remote.example.com:1852');
db.deleteNode(id);
});
});
describe('deleting a node tears down its tunnel or mesh bridge', () => {
@@ -77,6 +77,7 @@ describe('NodeRegistry.fetchMetaForNode', () => {
vi.spyOn(reg, 'getProxyTarget').mockReturnValue({
apiUrl: 'http://127.0.0.1:54321',
apiToken: '',
trustedLoopback: true,
});
const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({
data: {
@@ -96,8 +97,10 @@ describe('NodeRegistry.fetchMetaForNode', () => {
expect(axiosSpy).toHaveBeenCalledTimes(1);
const url = axiosSpy.mock.calls[0][0];
expect(url).toBe('http://127.0.0.1:54321/api/meta');
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string> };
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string>; httpAgent?: unknown; httpsAgent?: unknown };
expect(init.headers).toEqual({});
expect(init.httpAgent).toBeUndefined();
expect(init.httpsAgent).toBeUndefined();
db.deleteNode(nodeId);
});
@@ -118,6 +121,7 @@ describe('NodeRegistry.fetchMetaForNode', () => {
vi.spyOn(reg, 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'real-token',
trustedLoopback: false,
});
const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({
data: { version: '0.76.7', capabilities: [], startedAt: 1, updateError: null },
@@ -125,8 +129,10 @@ describe('NodeRegistry.fetchMetaForNode', () => {
await reg.fetchMetaForNode(nodeId);
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string> };
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string>; httpAgent?: unknown; httpsAgent?: unknown };
expect(init.headers).toEqual({ Authorization: 'Bearer real-token' });
expect(init.httpAgent).toBeDefined();
expect(init.httpsAgent).toBeDefined();
db.deleteNode(nodeId);
});
+1 -1
View File
@@ -59,7 +59,7 @@ describe('POST /api/nodes - api_url SSRF validation (C2 fix)', () => {
.set('Authorization', authHeader)
.send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/loopback/i);
expect(res.body.error).toMatch(/not allowed/i);
});
it('rejects 127.0.0.1 api_url', async () => {
@@ -0,0 +1,183 @@
import http from 'http';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
assertSafeOutboundUrl,
isBlockedOutboundAddress,
safeOutboundLookup,
safeAxiosTransport,
safeRemoteFetch,
UnsafeOutboundTargetError,
} from '../utils/outboundTarget';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
describe('outbound target validation', () => {
it.each([
'127.0.0.1',
'169.254.169.254',
'100.100.100.200',
'192.0.2.10',
'198.18.0.10',
'198.51.100.10',
'203.0.113.10',
'0.0.0.0',
'224.0.0.1',
'::1',
'fe80::1',
'ff02::1',
'100::1',
'2001:db8::1',
'fd00:ec2::254',
'::ffff:7f00:1',
])('blocks unsafe address %s', (address) => {
expect(isBlockedOutboundAddress(address)).toBe(true);
});
it.each([
'10.0.0.10',
'172.16.0.10',
'192.168.1.10',
'fd12:3456:789a::10',
'8.8.8.8',
'::ffff:808:808',
])('allows private and ordinary unicast address %s', (address) => {
expect(isBlockedOutboundAddress(address)).toBe(false);
});
it('rejects a URL whose literal host is unsafe', async () => {
await expect(withLoopbackTargetProtection(() =>
assertSafeOutboundUrl('https://[::ffff:127.0.0.1]/repo.git')))
.rejects.toBeInstanceOf(UnsafeOutboundTargetError);
});
it('accepts private LAN targets', async () => {
await expect(assertSafeOutboundUrl('http://192.168.1.50:1852'))
.resolves.toMatchObject({ hostname: '192.168.1.50' });
});
it('allows only loopback targets when the E2E test override is active', async () => {
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND', 'true');
await expect(actual.resolveSafeOutboundHostname('127.0.0.1'))
.resolves.toEqual([{ address: '127.0.0.1', family: 4 }]);
await expect(actual.resolveSafeOutboundHostname('169.254.169.254'))
.rejects.toMatchObject({ reason: 'blocked' });
});
it('ignores the E2E loopback override outside the test environment', async () => {
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND', 'true');
await expect(actual.resolveSafeOutboundHostname('127.0.0.1'))
.rejects.toMatchObject({ reason: 'blocked' });
});
it('rejects an unsafe address inside the connection lookup', async () => {
const error = await withLoopbackTargetProtection(() =>
new Promise<NodeJS.ErrnoException | null>((resolve) => {
safeOutboundLookup('127.0.0.1', {}, (lookupError) => resolve(lookupError));
}));
expect(error).toBeInstanceOf(UnsafeOutboundTargetError);
});
it('rejects a hostname when any resolved address is unsafe', async () => {
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
const resolveMixed = async () => [
{ address: '93.184.216.34', family: 4 as const },
{ address: '169.254.169.254', family: 4 as const },
];
await expect(actual.resolveSafeOutboundHostname('mixed.example', resolveMixed))
.rejects.toMatchObject({ reason: 'blocked' });
});
it('rejects an exact metadata address returned by DNS', async () => {
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
const resolveMetadata = async () => [
{ address: '100.100.100.200', family: 4 as const },
];
await expect(actual.resolveSafeOutboundHostname('metadata.example', resolveMetadata))
.rejects.toMatchObject({ reason: 'blocked' });
});
it('rechecks DNS at connection time after safe validation', async () => {
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
const resolveSafe = async () => [{ address: '93.184.216.34', family: 4 as const }];
await expect(actual.resolveSafeOutboundHostname('rebinding.example', resolveSafe)).resolves.toHaveLength(1);
const rebindingLookup = actual.createSafeOutboundLookup((_hostname, _options, callback) => {
callback(null, [{ address: '169.254.169.254', family: 4 }]);
});
const connectionError = await new Promise<NodeJS.ErrnoException | null>((resolve) => {
rebindingLookup('rebinding.example', {}, (lookupError) => resolve(lookupError));
});
expect(connectionError).toMatchObject({ reason: 'blocked' });
});
it('rejects unsafe addresses at fetch connection time', async () => {
await expect(withLoopbackTargetProtection(() =>
safeRemoteFetch('http://127.0.0.1:1852/api/meta')))
.rejects.toBeInstanceOf(UnsafeOutboundTargetError);
});
it('rejects redirects without contacting the redirect destination', async () => {
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
let destinationRequests = 0;
const destination = http.createServer((_req, res) => {
destinationRequests += 1;
res.end('unexpected');
});
await new Promise<void>((resolve) => destination.listen(0, '127.0.0.1', resolve));
const destinationAddress = destination.address();
if (!destinationAddress || typeof destinationAddress === 'string') throw new Error('Missing destination address');
const redirect = http.createServer((_req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${destinationAddress.port}/target` });
res.end();
});
await new Promise<void>((resolve) => redirect.listen(0, '127.0.0.1', resolve));
const redirectAddress = redirect.address();
if (!redirectAddress || typeof redirectAddress === 'string') throw new Error('Missing redirect address');
try {
await expect(actual.safeRemoteFetch(
`http://127.0.0.1:${redirectAddress.port}/start`,
{},
true,
)).rejects.toThrow();
expect(destinationRequests).toBe(0);
} finally {
await Promise.all([
new Promise<void>((resolve, reject) => destination.close((error) => error ? reject(error) : resolve())),
new Promise<void>((resolve, reject) => redirect.close((error) => error ? reject(error) : resolve())),
]);
}
});
it('isolates protected loopback checks from concurrent fixture requests', async () => {
const [ordinary, protectedResult] = await Promise.all([
assertSafeOutboundUrl('http://127.0.0.1:1852').then(() => 'allowed'),
withLoopbackTargetProtection(() => assertSafeOutboundUrl('http://127.0.0.1:1852'))
.then(() => 'allowed', () => 'blocked'),
]);
expect(ordinary).toBe('allowed');
expect(protectedResult).toBe('blocked');
});
it('disables environment proxy routing for guarded Axios requests', () => {
expect(safeAxiosTransport(false)).toMatchObject({
maxRedirects: 0,
proxy: false,
});
});
});
@@ -18,6 +18,7 @@ import request from 'supertest';
import crypto from 'crypto';
import { parse as parseYaml } from 'yaml';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs';
interface ComposeService {
image: string;
@@ -223,6 +224,39 @@ describe('SENCHO_PUBLIC_URL override in mintPilotEnrollment', () => {
expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
});
it('ignores a forwarded HTTPS scheme from an untrusted peer', async () => {
delete process.env.SENCHO_PUBLIC_URL;
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.set('Host', 'sencho.example.com')
.set('X-Forwarded-Proto', 'https')
.send({ name: 'pilot-untrusted-forwarded-scheme', type: 'remote', mode: 'pilot_agent' });
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('http://sencho.example.com');
});
it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => {
delete process.env.SENCHO_PUBLIC_URL;
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
resetTrustedProxyBlockListCache();
try {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.set('Host', 'sencho.example.com')
.set('X-Forwarded-Proto', 'https')
.send({ name: 'pilot-trusted-forwarded-scheme', type: 'remote', mode: 'pilot_agent' });
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('https://sencho.example.com');
} finally {
delete process.env.SENCHO_TRUSTED_PROXY_CIDRS;
resetTrustedProxyBlockListCache();
}
});
it('falls back to request host when env var is malformed', async () => {
process.env.SENCHO_PUBLIC_URL = 'not a url';
const res = await request(app)
@@ -69,6 +69,7 @@ import {
_resetTrivyMissingNotificationStateForTests,
enforcePolicyForImageRefs,
enforcePolicyPreDeploy,
evaluateCandidatePolicy,
} from '../services/PolicyEnforcement';
function mkPolicy(overrides: Partial<ScanPolicy> = {}): ScanPolicy {
@@ -796,3 +797,129 @@ describe('enforcePolicyForImageRefs - risk-based inputs (KEV / fixable / optiona
expect(dbStub.insertAuditLog.mock.calls[0][0].summary).toContain('policy.suppression_pass');
});
});
// Candidate (pre-acceptance) evaluation must not fail open the way the
// deploy-time gate deliberately does: an unresolvable scanner state must
// surface as its own `unavailable` outcome so a caller can withhold automatic
// acceptance, rather than silently reusing `ok: true`.
describe('evaluateCandidatePolicy', () => {
beforeEach(() => {
trivyStub.isTrivyAvailable.mockReset();
trivyStub.scanImagePreflight.mockReset();
composeStub.listStackImages.mockReset();
dbStub.getMatchingPolicy.mockReset();
dbStub.insertAuditLog.mockReset();
dbStub.getGlobalSettings.mockReset().mockReturnValue({});
dbStub.getAllVulnerabilityDetails.mockReset().mockReturnValue([]);
dbStub.getCveSuppressions.mockReset().mockReturnValue([]);
dbStub.getCveIntel.mockReset().mockReturnValue(new Map());
notificationStub.dispatchAlert.mockReset();
_resetTrivyMissingNotificationStateForTests();
});
it('reports allowed when no matching policy exists', async () => {
dbStub.getMatchingPolicy.mockReturnValue(null);
const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' });
expect(result.status).toBe('allowed');
});
it('reports unavailable, not allowed, when the scanner cannot be evaluated', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(false);
const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' });
expect(result.status).toBe('unavailable');
if (result.status === 'unavailable') {
expect(result.reason).toBeTruthy();
}
});
it('reports blocked when a scanned image exceeds the policy severity', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 7, highest_severity: 'CRITICAL', critical_count: 1 }));
const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' });
expect(result.status).toBe('blocked');
if (result.status === 'blocked') {
expect(result.violations).toHaveLength(1);
}
});
it('reports allowed on an authorized bypass', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 8, highest_severity: 'CRITICAL', critical_count: 1 }));
const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: true, actor: 'admin' });
expect(result.status).toBe('allowed');
});
it('never reads compose from disk; the caller supplies candidate image refs', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 9, highest_severity: 'LOW' }));
await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' });
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
it('reports unavailable, not allowed, for an image reference that cannot be scanned', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
const result = await evaluateCandidatePolicy('web', 1, ['not a valid ref!!'], { bypass: false, actor: 'u' });
expect(result.status).toBe('unavailable');
expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled();
});
it('reports unavailable, not blocked, when the scanner throws for every image', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
trivyStub.scanImagePreflight.mockRejectedValue(new Error('scan process crashed'));
const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: false, actor: 'u' });
expect(result.status).toBe('unavailable');
});
it('still reports blocked when at least one image has a genuine scanned violation', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
trivyStub.scanImagePreflight
.mockRejectedValueOnce(new Error('scan process crashed'))
.mockResolvedValueOnce(mkScan({ id: 11, highest_severity: 'CRITICAL', critical_count: 1 }));
const result = await evaluateCandidatePolicy('web', 1, ['unreachable:1', 'nginx:1.27'], { bypass: false, actor: 'u' });
expect(result.status).toBe('blocked');
});
it('honors an explicit bypass even when the scanner is unavailable', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(false);
const result = await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: true, actor: 'admin' });
expect(result.status).toBe('allowed');
});
it('does not attribute its audit trail to a deploy that never happened', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 10, highest_severity: 'CRITICAL', critical_count: 1 }));
await evaluateCandidatePolicy('web', 1, ['nginx:1.27'], { bypass: true, actor: 'admin' });
expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1);
const entry = dbStub.insertAuditLog.mock.calls[0][0];
expect(entry.path).not.toMatch(/\/deploy$/);
});
});
@@ -17,6 +17,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
describe('remote proxy mount order', () => {
let tmpDir: string;
@@ -65,6 +66,16 @@ describe('remote proxy mount order', () => {
expect(res.body?.error).toMatch(/unreachable/i);
});
it('refuses an unsafe target from an existing node row', async () => {
const res = await withLoopbackTargetProtection(() => request(app)
.get('/api/stacks')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId)));
expect(res.status).toBe(502);
expect(res.body?.error).toMatch(/not allowed/i);
});
it('routes local requests (no x-node-id) to the local handler', async () => {
const res = await request(app)
.get('/api/stacks')
@@ -57,7 +57,7 @@ describe('pilot-agent-mode proxy role header parity', () => {
const registry = NodeRegistry.getInstance();
const orig = registry.getProxyTarget.bind(registry);
vi.spyOn(registry, 'getProxyTarget').mockImplementation((nid: number) => {
if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '' };
if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '', trustedLoopback: true };
return orig(nid);
});
});
@@ -48,6 +48,7 @@ describe('captured invocation on recovery Compose args', () => {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: 'abc',
@@ -66,7 +66,7 @@ describe('registryDeliveryOutbound', () => {
apiPath: '/api/stacks/demo/deploy',
nodeId,
node,
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
body,
});
@@ -86,7 +86,7 @@ describe('registryDeliveryOutbound', () => {
apiPath: '/api/stacks/demo/deploy',
nodeId,
node,
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
body,
});
@@ -122,7 +122,7 @@ describe('registryDeliveryOutbound', () => {
apiPath: '/api/stacks/demo/deploy',
nodeId,
node,
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
body: {},
abortSignal: controller.signal,
});
@@ -160,7 +160,7 @@ describe('registryDeliveryOutbound', () => {
apiPath: '/api/stacks/demo/deploy',
nodeId,
node,
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
body: {},
});
@@ -210,7 +210,7 @@ describe('registryDeliveryOutbound', () => {
apiPath: '/api/stacks/demo/deploy',
nodeId,
node,
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
body: {},
abortSignal: controller.signal,
});
@@ -0,0 +1,37 @@
import { IncomingMessage } from 'http';
import { Socket } from 'net';
import { PassThrough } from 'stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { wsProxyServer } from '../proxy/websocketProxy';
import { handleRemoteForwarder } from '../websocket/remoteForwarder';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
afterEach(() => vi.restoreAllMocks());
describe('remote WebSocket target validation', () => {
it('rejects an unsafe target before invoking the WebSocket proxy', async () => {
const req = new IncomingMessage(new Socket());
req.url = '/api/containers/demo/logs?nodeId=2';
req.headers.host = 'sencho.example';
const socket = new PassThrough();
socket.resume();
const proxySpy = vi.spyOn(wsProxyServer, 'ws').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
await withLoopbackTargetProtection(() => handleRemoteForwarder(
req,
socket,
Buffer.alloc(0),
{
pathname: '/api/containers/demo/logs',
target: {
apiUrl: 'http://127.0.0.1:1852',
apiToken: 'test-token',
trustedLoopback: false,
},
},
));
expect(proxySpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,197 @@
/**
* Unit tests for SelfIdentityService.getBuildInfo(): the canonical runtime
* build identity (version, channel, imageRef, imageId, revision), the detached
* bounded revision enrichment, and the failure-isolation guarantee.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockContainer, mockDocker, mockInspectImage, mockGetSenchoVersion } = vi.hoisted(() => {
const mockContainer = { inspect: vi.fn() };
const mockDocker = {
getContainer: vi.fn(() => mockContainer),
getImage: vi.fn(),
listImages: vi.fn().mockResolvedValue([]),
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
listNetworks: vi.fn().mockResolvedValue([]),
listContainers: vi.fn().mockResolvedValue([]),
};
return {
mockContainer,
mockDocker,
mockInspectImage: vi.fn(),
mockGetSenchoVersion: vi.fn(() => '0.97.1'),
};
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDocker: () => mockDocker,
getDefaultNodeId: () => 1,
}),
},
}));
// Replace defaultInspectImage so enrichment is deterministic and isolated.
vi.mock('../services/selfDevBuildDetect', () => ({
defaultInspectImage: (...args: unknown[]) => mockInspectImage(...args),
}));
vi.mock('../services/CapabilityRegistry', () => ({
getSenchoVersion: () => mockGetSenchoVersion(),
}));
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
vi.mock('util', () => ({ promisify: () => vi.fn() }));
import SelfIdentityService from '../services/SelfIdentityService';
const FULL_IMAGE_ID_HEX = 'b'.repeat(64);
const DIGEST = 'a'.repeat(64);
const originalHostname = process.env.HOSTNAME;
beforeEach(() => {
vi.clearAllMocks();
mockContainer.inspect.mockReset();
mockDocker.getImage.mockReset();
mockInspectImage.mockReset();
mockGetSenchoVersion.mockReturnValue('0.97.1');
SelfIdentityService.getInstance().resetForTesting();
});
afterEach(() => {
vi.restoreAllMocks();
if (originalHostname === undefined) delete process.env.HOSTNAME;
else process.env.HOSTNAME = originalHostname;
});
async function initWith(configImage: string | undefined): Promise<SelfIdentityService> {
process.env.HOSTNAME = 'sencho-1';
mockContainer.inspect.mockResolvedValue({
Id: 'a'.repeat(64),
Name: '/sencho',
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
...(configImage !== undefined ? { Config: { Image: configImage } } : {}),
NetworkSettings: { Networks: {} },
Mounts: [],
});
const svc = SelfIdentityService.getInstance();
await svc.initialize();
return svc;
}
/** Enrichment runs detached; poll until the condition holds so assertions are stable. */
async function until(assert: () => void): Promise<void> {
await vi.waitFor(assert, { timeout: 2000 });
}
describe('SelfIdentityService.getBuildInfo', () => {
it('identifies a dev image as DEV even when the packaged semver matches the previous stable', async () => {
// The regression case from the ticket: dev image, version still 0.97.1.
// The inspect mock must be set before initialize(): enrichment fires
// detached during initialize(), before the awaited call returns.
mockInspectImage.mockResolvedValue({
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`));
const info = svc.getBuildInfo();
expect(info.version).toBe('0.97.1');
expect(info.channel).toBe('dev');
expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
});
it('derives the revision from a pinned dev-<sha> tag without an image inspect', async () => {
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
await until(() => expect(svc.getBuildInfo().revision).toBe('dev-abc1234'));
const info = svc.getBuildInfo();
expect(info.channel).toBe('dev');
expect(mockInspectImage).not.toHaveBeenCalled();
});
it('classifies a stable image as stable', async () => {
mockInspectImage.mockResolvedValue({
RepoDigests: [`ghcr.io/studio-saelix/sencho@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
const svc = await initWith('ghcr.io/studio-saelix/sencho:0.97.1');
await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`));
expect(svc.getBuildInfo().channel).toBe('stable');
});
it('reads unknown for partial metadata (no running image reference)', async () => {
const svc = await initWith(undefined);
const info = svc.getBuildInfo();
expect(info.imageRef).toBeNull();
expect(info.channel).toBe('unknown');
expect(info.revision).toBeNull();
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
});
it('keeps the dev channel but null revision when image inspection fails', async () => {
mockInspectImage.mockRejectedValue(new Error('docker daemon unreachable'));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
await until(() => expect(warnSpy).toHaveBeenCalled());
const info = svc.getBuildInfo();
expect(info.channel).toBe('dev');
expect(info.revision).toBeNull();
// C2: a failed enrichment must not corrupt the already-captured core identity.
expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
});
it('never inspects the image again on repeated reads', async () => {
mockInspectImage.mockResolvedValue({
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
await until(() => expect(mockInspectImage).toHaveBeenCalledTimes(1));
for (let i = 0; i < 5; i++) svc.getBuildInfo();
expect(mockInspectImage).toHaveBeenCalledTimes(1);
});
it('exposes revision only after enrichment settles, and whenRevisionResolved awaits that', async () => {
let resolveInspect!: (v: { RepoDigests: string[]; Os: string; Architecture: string }) => void;
mockInspectImage.mockReturnValue(new Promise((res) => { resolveInspect = res; }));
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
// initialize() returned without awaiting the detached enrichment, so the
// revision is still transiently null and the settle promise is pending.
expect(svc.getBuildInfo().revision).toBeNull();
// A reader that awaits the settle promise (the build-info route) blocks
// until enrichment lands, then observes the resolved digest, so a single
// successful read never freezes a transient null.
const settled = svc.whenRevisionResolved();
resolveInspect({
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
await settled;
expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`);
});
it('resolves whenRevisionResolved immediately when no enrichment ever started', async () => {
process.env.HOSTNAME = undefined;
const svc = SelfIdentityService.getInstance();
await svc.whenRevisionResolved();
expect(svc.getBuildInfo().revision).toBeNull();
});
});
@@ -0,0 +1,207 @@
/**
* SourceController: the background driver for unattended reconciliation.
* GitOpsStore's due-queries and GitSourceService.reconcile() are mocked so
* these tests exercise only the timer/coalescing behavior, not real fetch
* or apply mechanics (already covered by git-source-service.test.ts).
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { directApplicationFixture } from './helpers/gitopsFixtures';
import { GitOpsStore } from '../services/gitops/store';
import { GitSourceService } from '../services/GitSourceService';
import { SourceController } from '../services/gitops/SourceController';
import type { GitOpsApplicationRow } from '../services/gitops/types';
import type { ReconcileResult } from '../services/gitops/outcomes';
const TICK_MS = 60_000;
const okResult: ReconcileResult = { outcome: 'no_source_change', reason: 'ok', nextAction: 'none' };
let tmpDir: string;
let controller: SourceController;
/** Point both due-queries at fixed rows; the scan reads nothing else. */
function mockDue(duePoll: GitOpsApplicationRow[], dueRetry: GitOpsApplicationRow[] = []): void {
vi.spyOn(GitOpsStore.getInstance(), 'listSourcesDueForPoll').mockReturnValue(duePoll);
vi.spyOn(GitOpsStore.getInstance(), 'listApplicationsDueForRetry').mockReturnValue(dueRetry);
}
function spyOnReconcile() {
return vi.spyOn(GitSourceService.getInstance(), 'reconcile');
}
/** Run the next scheduled tick and let the evaluations it fires settle. */
async function advanceOneTick(): Promise<void> {
await vi.advanceTimersByTimeAsync(TICK_MS);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
GitOpsStore.resetForTests();
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
vi.useFakeTimers();
SourceController.resetForTests();
controller = SourceController.getInstance();
});
afterEach(() => {
controller.stop();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('SourceController', () => {
it('evaluates a source whose poll interval is due', async () => {
mockDue([directApplicationFixture('app-poll', 'poll-web')]);
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
controller.start();
await advanceOneTick();
expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({
intent: 'fetch',
applicationId: 'app-poll',
stackName: 'poll-web',
trigger: 'poll',
}));
});
it('evaluates an application whose retry_at has arrived, tagged as a retry trigger', async () => {
const app = { ...directApplicationFixture('app-retry', 'retry-web'), retry_at: Date.now() - 1_000 };
mockDue([], [app]);
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
controller.start();
await advanceOneTick();
expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({
applicationId: 'app-retry',
trigger: 'retry',
}));
});
it('evaluates an application due for both poll and retry exactly once', async () => {
const app = { ...directApplicationFixture('app-both', 'both-web'), retry_at: Date.now() - 1_000 };
mockDue([app], [app]);
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
controller.start();
await advanceOneTick();
expect(reconcile).toHaveBeenCalledTimes(1);
});
it('does not re-evaluate an application still in flight from a previous tick', async () => {
mockDue([directApplicationFixture('app-slow', 'slow-web')]);
let settleFirstCall!: (result: ReconcileResult) => void;
const firstCall = new Promise<ReconcileResult>((resolve) => { settleFirstCall = resolve; });
const reconcile = spyOnReconcile().mockReturnValue(firstCall);
controller.start();
await advanceOneTick();
expect(reconcile).toHaveBeenCalledTimes(1);
// A second tick fires while the first evaluation is still pending.
await advanceOneTick();
expect(reconcile).toHaveBeenCalledTimes(1);
settleFirstCall(okResult);
await Promise.resolve();
await Promise.resolve();
// Now that the first evaluation has settled, a later tick may pick it up again.
await advanceOneTick();
expect(reconcile).toHaveBeenCalledTimes(2);
});
it('recovers on the next tick after a store query throws, rather than dying permanently', async () => {
mockDue([directApplicationFixture('app-recovers', 'recovers-web')]);
vi.spyOn(GitOpsStore.getInstance(), 'listSourcesDueForPoll').mockImplementationOnce(() => {
throw new Error('database is locked');
});
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
vi.spyOn(console, 'error').mockImplementation(() => {});
controller.start();
await advanceOneTick();
expect(reconcile).not.toHaveBeenCalled();
await advanceOneTick();
expect(reconcile).toHaveBeenCalledTimes(1);
});
it('releases the in-flight slot for an application whose reconcile rejects', async () => {
mockDue([directApplicationFixture('app-rejects', 'rejects-web')]);
const reconcile = spyOnReconcile().mockRejectedValue(new Error('boom'));
controller.start();
await advanceOneTick();
await advanceOneTick();
expect(reconcile).toHaveBeenCalledTimes(2);
});
it('does not evaluate anything after stop', async () => {
mockDue([directApplicationFixture('app-stopped', 'stopped-web')]);
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
controller.start();
controller.stop();
await advanceOneTick();
await advanceOneTick();
expect(reconcile).not.toHaveBeenCalled();
});
it('does not double-arm when start() is called reentrantly from within an in-flight evaluation', async () => {
mockDue([directApplicationFixture('app-reentrant-start', 'reentrant-start-web')]);
// tick() nulls `timer` before scanning, so a start() call landing
// synchronously during that scan must not see a false "not running"
// reading and arm a second timer.
spyOnReconcile().mockImplementation(() => {
controller.start();
return Promise.resolve(okResult);
});
controller.start();
await advanceOneTick();
expect(vi.getTimerCount()).toBe(1);
});
it('logs rather than silently skipping an application with no stack_name', async () => {
mockDue([{ ...directApplicationFixture('app-no-stack', 'no-stack-web'), stack_name: null }]);
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
controller.start();
await advanceOneTick();
expect(reconcile).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('app-no-stack'));
});
it('restartPolling never leaves two timers running', () => {
controller.start();
controller.restartPolling();
controller.restartPolling();
expect(vi.getTimerCount()).toBe(1);
});
it('start is a no-op when already running', async () => {
mockDue([directApplicationFixture('app-double-start', 'double-start-web')]);
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
controller.start();
controller.start();
await advanceOneTick();
expect(reconcile).toHaveBeenCalledTimes(1);
});
});
+19 -1
View File
@@ -31,6 +31,12 @@ describe('sshTrust URL parsing', () => {
expect(parsed?.port).toBe(2222);
});
it('preserves absolute paths in default-port ssh:// URLs', () => {
const parsed = parseSshUrl('ssh://git@host.example/abs/path/repo.git');
expect(parsed?.href).toBe('git@host.example:/abs/path/repo.git');
expect(parsed?.pathname).toBe('/abs/path/repo.git');
});
it('classifies transport kind from mixed inputs', () => {
expect(parseRepoTransportUrl('https://github.com/org/repo.git')?.kind).toBe('https');
expect(parseRepoTransportUrl('git@host:org/repo.git')?.kind).toBe('ssh');
@@ -72,11 +78,23 @@ describe('ssh credential canonicalization', () => {
describe('ssh command builder', () => {
it('enforces strict host key checking', () => {
const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts');
const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts', {
address: '10.0.0.8',
hostKeyAlias: 'git.internal.example',
});
expect(cmd).toContain('StrictHostKeyChecking=yes');
expect(cmd).toContain('UserKnownHostsFile=/tmp/known_hosts');
expect(cmd).toContain('IdentitiesOnly=yes');
});
it('pins the address while retaining the repository host identity', () => {
const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts', {
address: '10.0.0.8',
hostKeyAlias: '[git.internal.example]:2222',
});
expect(cmd).toContain('Hostname=10.0.0.8');
expect(cmd).toContain('HostKeyAlias=[git.internal.example]:2222');
});
});
describe('SSH stderr classification', () => {
@@ -79,6 +79,21 @@ describe('classifyStackApiPath', () => {
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
});
// Load-bearing the same way as history/manifest above: without a rule
// here, suspend/resume/retry 403 on every remote node before the
// controller routes that use them exist.
it('maps git-source/suspend, resume, and retry to stack:edit', () => {
expect(classifyStackApiPath('POST', '/stacks/web/git-source/suspend')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('POST', '/stacks/web/git-source/resume')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
expect(classifyStackApiPath('POST', '/stacks/web/git-source/retry')).toEqual({
kind: 'named-stack', stackName: 'web', action: 'stack:edit',
});
});
});
describe('static exclusions', () => {
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { resolveTestHandle } from './__helpers__/testHandleResolver';
const FILE = 'fixture.test.ts';
describe('resolveTestHandle', () => {
it('resolves a plain it() declaration', () => {
const src = `it('does the thing', () => { expect(1).toBe(1); });`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
});
it('resolves a plain test() declaration', () => {
const src = `test('does the thing', () => {});`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
});
it('fails when the title is not present', () => {
const src = `it('does something else', () => {});`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' });
});
it('fails on a duplicate title with two runnable declarations', () => {
const src = `
it('does the thing', () => {});
it('does the thing', () => {});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'duplicate' });
});
it('fails when the test itself is .skip', () => {
const src = `it.skip('does the thing', () => {});`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
});
it('fails when the test itself is .todo', () => {
const src = `it.todo('does the thing');`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
});
it('fails when the test itself is .failing', () => {
const src = `it.failing('does the thing', () => {});`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
});
it('fails when the test itself is .skipIf', () => {
const src = `it.skipIf(true)('does the thing', () => {});`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
});
it('fails under an unconditionally skipped describe', () => {
const src = `
describe.skip('suite', () => {
it('does the thing', () => {});
});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
});
it('fails under a describe.runIf, approved-looking predicate or not', () => {
const src = `
describe.runIf(requireGitBinary())('suite', () => {
it('does the thing', () => {});
});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
});
it('fails under a describe.skipIf with an arbitrary, unapproved predicate', () => {
const src = `
describe.skipIf(!someLocalCheck())('suite', () => {
it('does the thing', () => {});
});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
});
it('passes under a describe.skipIf that calls the approved requireGitBinary helper', () => {
const src = `
describe.skipIf(!requireGitBinary())('suite', () => {
it('does the thing', () => {});
});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
});
it('passes under a describe.skipIf that calls the approved requireSshd helper alongside other checks', () => {
const src = `
describe.skipIf(!requireGitBinary() || !requireSshd())('suite', () => {
it('does the thing', () => {});
});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
});
it('fails when an approved outer describe.skipIf wraps an unapproved inner describe.skip', () => {
const src = `
describe.skipIf(!requireGitBinary())('outer', () => {
describe.skip('inner', () => {
it('does the thing', () => {});
});
});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
});
it('resolves a title nested two levels inside approved describe.skipIf blocks', () => {
const src = `
describe.skipIf(!requireGitBinary())('outer', () => {
describe('inner (no modifier)', () => {
it('does the thing', () => {});
});
});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
});
it('does not resolve a title only present in a comment', () => {
const src = `
// it('does the thing', () => {});
it('does another thing', () => {});
`;
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' });
});
});
@@ -0,0 +1,68 @@
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
import { createApp } from '../app';
import { isSecureRequest } from '../helpers/cookies';
import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs';
describe('Express trusted proxy configuration', () => {
beforeEach(() => {
delete process.env.SENCHO_TRUSTED_PROXY_CIDRS;
resetTrustedProxyBlockListCache();
});
it('ignores forwarded client addresses from an untrusted direct peer', async () => {
const app = createApp();
app.get('/peer-ip', (req, res) => res.json({ ip: req.ip }));
const res = await request(app)
.get('/peer-ip')
.set('X-Forwarded-For', '203.0.113.50');
expect(res.status).toBe(200);
expect(res.body.ip).not.toBe('203.0.113.50');
});
it('honors forwarded client addresses from an allowlisted proxy peer', async () => {
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
resetTrustedProxyBlockListCache();
const app = createApp();
app.get('/peer-ip', (req, res) => res.json({ ip: req.ip }));
const res = await request(app)
.get('/peer-ip')
.set('X-Forwarded-For', '203.0.113.50');
expect(res.status).toBe(200);
expect(res.body.ip).toBe('203.0.113.50');
});
it('ignores a forwarded HTTPS scheme from an untrusted direct peer', async () => {
const app = createApp();
app.get('/request-scheme', (req, res) => {
res.json({ protocol: req.protocol, secure: isSecureRequest(req) });
});
const res = await request(app)
.get('/request-scheme')
.set('X-Forwarded-Proto', 'https');
expect(res.status).toBe(200);
expect(res.body).toEqual({ protocol: 'http', secure: false });
});
it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => {
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
resetTrustedProxyBlockListCache();
const app = createApp();
app.get('/request-scheme', (req, res) => {
res.json({ protocol: req.protocol, secure: isSecureRequest(req) });
});
const res = await request(app)
.get('/request-scheme')
.set('X-Forwarded-Proto', 'https');
expect(res.status).toBe(200);
expect(res.body).toEqual({ protocol: 'https', secure: true });
});
});
@@ -23,6 +23,13 @@ describe('trustedProxyCidrs', () => {
expect(isTrustedProxyPeer('192.168.1.1')).toBe(false);
});
it('matches IPv4-mapped IPv6 peers against IPv4 CIDRs', () => {
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '10.0.0.0/8';
resetTrustedProxyBlockListCache();
expect(isTrustedProxyPeer('::ffff:10.1.2.3')).toBe(true);
expect(isTrustedProxyPeer('::ffff:192.168.1.1')).toBe(false);
});
it('fails closed on invalid entries', () => {
process.env.SENCHO_TRUSTED_PROXY_CIDRS = 'not-a-cidr';
resetTrustedProxyBlockListCache();
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
@@ -25,6 +26,7 @@ function seedGitSource(stackName: string): void {
env_path: null,
auth_type: 'none',
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
encrypted_ca_bundle: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
@@ -236,4 +238,142 @@ describe('node-aware Git source webhooks', () => {
expect(history[0].status).toBe('success');
expect(history[0].error).toMatch(/debounced/i);
});
it('forwards a provider delivery id to a remote node under the webhook namespace', async () => {
const db = DatabaseService.getInstance();
const remoteNodeId = db.addNode({
name: 'remote-delivery-id-webhook',
type: 'remote',
compose_dir: '/tmp',
is_default: false,
api_url: 'http://remote-delivery.example',
api_token: 'remote-token',
});
const webhookId = db.addWebhook({
node_id: remoteNodeId,
name: 'delivery id remote git',
stack_name: 'remote-stack',
action: 'git-pull',
secret: WebhookService.getInstance().generateSecret(),
enabled: true,
});
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 'success', message: 'Fetched.' }), { status: 200 }),
);
const webhook = db.getWebhook(webhookId)!;
const deliverySourceId = db.getGlobalSettings().delivery_source_id;
const result = await WebhookService.getInstance().execute(
webhook,
'git-pull',
'test',
undefined,
'provider-delivery-1',
);
expect(result.success).toBe(true);
expect(fetchSpy).toHaveBeenCalledWith(
'http://remote-delivery.example/api/stacks/remote-stack/git-source/webhook-pull',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining(`"deliveryId":"webhook:${deliverySourceId}:${webhookId}:provider-delivery-1"`),
}),
);
});
it('passes the same producer-scoped delivery identity to a local Git source', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id;
const webhookId = db.addWebhook({
node_id: nodeId,
name: 'delivery id local git',
stack_name: 'local-delivery-stack',
action: 'git-pull',
secret: WebhookService.getInstance().generateSecret(),
enabled: true,
});
const webhook = db.getWebhook(webhookId)!;
const deliverySourceId = db.getGlobalSettings().delivery_source_id;
const { FileSystemService } = await import('../services/FileSystemService');
const { GitSourceService } = await import('../services/GitSourceService');
const stacksSpy = vi.spyOn(FileSystemService.prototype, 'getStacks')
.mockResolvedValue(['local-delivery-stack']);
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull')
.mockResolvedValue({ status: 'success', message: 'Fetched.' });
try {
const result = await WebhookService.getInstance().execute(
webhook,
'git-pull',
'test',
undefined,
'provider-delivery-2',
);
expect(result).toEqual({ success: true, duration_ms: expect.any(Number) });
expect(pullSpy).toHaveBeenCalledWith(
'local-delivery-stack',
true,
`webhook:${deliverySourceId}:${webhookId}:provider-delivery-2`,
);
const secondWebhookId = db.addWebhook({
node_id: nodeId,
name: 'second delivery id local git',
stack_name: 'local-delivery-stack',
action: 'git-pull',
secret: webhook.secret,
enabled: true,
});
pullSpy.mockClear();
await WebhookService.getInstance().execute(
db.getWebhook(secondWebhookId)!,
'git-pull',
'test',
undefined,
'provider-delivery-2',
);
expect(pullSpy).toHaveBeenCalledWith(
'local-delivery-stack',
true,
`webhook:${deliverySourceId}:${secondWebhookId}:provider-delivery-2`,
);
db.updateGlobalSetting('delivery_source_id', 'second-control-source');
pullSpy.mockClear();
await WebhookService.getInstance().execute(
webhook,
'git-pull',
'test',
undefined,
'provider-delivery-2',
);
expect(pullSpy).toHaveBeenCalledWith(
'local-delivery-stack',
true,
`webhook:second-control-source:${webhookId}:provider-delivery-2`,
);
db.updateGlobalSetting('delivery_source_id', deliverySourceId!);
const oversizedDeliveryId = 'x'.repeat(300);
const boundedId = crypto.createHash('sha256').update(oversizedDeliveryId).digest('hex');
pullSpy.mockClear();
await WebhookService.getInstance().execute(
webhook,
'git-pull',
'test',
undefined,
oversizedDeliveryId,
);
expect(pullSpy).toHaveBeenCalledWith(
'local-delivery-stack',
true,
`webhook:${deliverySourceId}:${webhookId}:sha256:${boundedId}`,
);
} finally {
if (deliverySourceId) db.updateGlobalSetting('delivery_source_id', deliverySourceId);
stacksSpy.mockRestore();
pullSpy.mockRestore();
}
});
});
@@ -251,6 +251,55 @@ describe('POST /api/webhooks/:id/trigger: authenticated happy path', () => {
expect(res.body).toMatchObject({ action: 'start' });
});
it('extracts a recognized provider delivery header and passes it through to execute', async () => {
const { id, secret } = createWebhook({ action: 'stop' });
const body = '{}';
const executeSpy = vi.spyOn(WebhookService.getInstance(), 'execute').mockResolvedValue({ success: true, duration_ms: 0 });
try {
await request(app)
.post(`/api/webhooks/${id}/trigger`)
.set('Content-Type', 'application/json')
.set('X-Webhook-Signature', sign(body, secret))
.set('X-GitHub-Delivery', 'gh-delivery-123')
.send(body);
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({ id }),
'stop',
expect.anything(),
true,
'gh-delivery-123',
);
} finally {
executeSpy.mockRestore();
}
});
it('passes no delivery id through when the caller sends no recognized header', async () => {
const { id, secret } = createWebhook({ action: 'stop' });
const body = '{}';
const executeSpy = vi.spyOn(WebhookService.getInstance(), 'execute').mockResolvedValue({ success: true, duration_ms: 0 });
try {
await request(app)
.post(`/api/webhooks/${id}/trigger`)
.set('Content-Type', 'application/json')
.set('X-Webhook-Signature', sign(body, secret))
.send(body);
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({ id }),
'stop',
expect.anything(),
true,
undefined,
);
} finally {
executeSpy.mockRestore();
}
});
it('rejects an unknown action override with 400 after the signature passes (L2)', async () => {
const { id, secret } = createWebhook();
const body = '{"action":"nuke-the-cluster"}';

Some files were not shown because too many files have changed in this diff Show More