mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-09 17:05:51 +00:00
chore: merge main into fix/sso-role-sync-granular
This commit is contained in:
+6
-5
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Generated
+385
-360
File diff suppressed because it is too large
Load Diff
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user