From a32bf3ded411a1f8f000c1267022137a51b51083 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 08:48:23 +0000 Subject: [PATCH 1/4] fix test web --- .github/branch-protection-guide.md | 85 +++++- .github/pull_request_template.md | 2 +- .github/workflows/_quality-gate.yml | 19 +- .github/workflows/codeql.yml | 71 +++++ .github/workflows/dev-fast-ci.yml | 85 ++++++ .gitleaks.toml | 46 +++ Makefile | 178 ++++++++--- backend/cmd/openapi/gen.go | 13 +- backend/docs/openapi/README.md | 3 + backend/docs/openapi/api.yaml | 124 ++++---- backend/docs/openapi/ext-api.yaml | 82 ++++- backend/docs/openapi/group-matrix.yaml | 16 +- backend/domain/routes/routes_coverage_test.go | 281 +++++++++++++++++- backend/go.mod | 8 +- backend/go.sum | 8 + build/.env | 2 +- build/reports/gitleaks-report.json | 1 + .../story1.2-makefile.md | 6 +- .../story1.6-security-scanning.md | 5 +- tests/e2e/README.md | 13 +- web/package-lock.json | 280 ++++++++--------- .../pages/system/PlatformStatusPage.test.tsx | 2 +- .../_app/_auth/resources/-servers.test.tsx | 21 +- web/vitest.config.ts | 1 + 24 files changed, 1073 insertions(+), 279 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dev-fast-ci.yml create mode 100644 .gitleaks.toml create mode 100644 build/reports/gitleaks-report.json diff --git a/.github/branch-protection-guide.md b/.github/branch-protection-guide.md index 4275bb8c..ee564564 100644 --- a/.github/branch-protection-guide.md +++ b/.github/branch-protection-guide.md @@ -1,32 +1,69 @@ # Branch Protection And CI Roles -This repository uses three CI layers with different responsibilities: +This repository uses three primary CI/CD layers with different responsibilities: - `PR Gate`: the pre-merge blocking gate for `main` - `Main Post-Merge`: post-merge validation for merge-result health and advisory supply-chain checks - `Release`: tag-based release preparation and draft release creation +There is also one source-security workflow managed alongside the CI layers: + +- `CodeQL`: source code security scanning for PRs to `main` and pushes to `main` + +There is also one optional developer workflow: + +- `Dev Fast CI`: lightweight self-checks for non-`main` development branches + Related workflows: - `.github/workflows/pr-gate.yml` +- `.github/workflows/_quality-gate.yml` - `.github/workflows/main-post-merge.yml` +- `.github/workflows/codeql.yml` +- `.github/workflows/dev-fast-ci.yml` - `.github/workflows/release.yml` - `.github/workflows/publish-images.yml` ## What Should Block Merge -Only the PR gate should be configured as the required branch-protection check for `main`. +Only the PR gate should be configured as the required branch-protection check set for `main`. + +Recommended required check targets: + +- `PR Gate / Quality Gate / Lint (pull_request)` +- `PR Gate / Quality Gate / Test Backend (pull_request)` +- `PR Gate / Quality Gate / Test Frontend (pull_request)` +- `PR Gate / Quality Gate / Security Scan (pull_request)` +- `PR Gate / Quality Gate / E2E Smoke (pull_request)` -Recommended required check target: +Optional later additions after the first successful CodeQL PR runs: -- workflow: `PR Gate` -- job: `Quality Gate` +- the real reported CodeQL PR job checks, such as the Go and JavaScript TypeScript analysis jobs Why: -- It is the main admission gate for code entering `main`. -- It runs lint, tests, security fast checks, and e2e smoke. -- It avoids using post-merge or release-only checks as pre-merge blockers. +- `PR Gate` is the main admission gate for code entering `main` +- it runs lint, tests, security fast checks, and e2e smoke +- it avoids using post-merge or release-only checks as pre-merge blockers +- GitHub branch protection must reference the real job-level checks reported by workflows, not abstract wrapper names like `PR Gate` or `Quality Gate` + +Do not set these as required checks unless matching workflows are actually reporting them: + +- `PR Gate` +- `Quality Gate` +- `Code scanning results` + +## Developer Branch Fast CI + +`Dev Fast CI` is intended for personal development branches and excludes `main`. + +It is useful for early feedback, but it is not a merge-governance layer and should not replace the PR gate. + +Recommended use: + +- run lightweight lint, backend/frontend tests, and fast security checks on `push -> non-main branches` +- do not treat `Dev Fast CI` as a required check for `main` +- do not use it as a substitute for PR review or branch protection ## What Should Not Block Merge @@ -55,8 +92,9 @@ Enable: Recommended manual settings outside the repository: -- set `PR Gate / Quality Gate` as a required status check -- require at least one human approval +- set the five PR job checks listed above as required status checks +- after the first successful CodeQL runs, optionally add the real reported CodeQL PR check names if you want source security scanning to block merges +- require at least one human approval when the repository has more than one active maintainer - add CODEOWNERS later for sensitive areas such as workflows, release logic, and runtime bootstrapping ## How To Treat AI In Branch Protection @@ -76,4 +114,29 @@ Release workflows are not part of the merge gate. - `Release` validates tags, generates release artifacts, and creates a draft release - `Publish Images` runs only after a release is published -This separation avoids using release-specific work as a daily development bottleneck. \ No newline at end of file +This separation avoids using release-specific work as a daily development bottleneck. + +## Current Code Scanning State + +There is a dedicated repository-managed CodeQL workflow in `.github/workflows/codeql.yml`. + +What exists right now: + +- `CodeQL` runs for `pull_request -> main` +- `CodeQL` runs for `push -> main` +- `Main Post-Merge` runs an advisory Trivy image scan +- the Trivy SARIF report is uploaded to GitHub Security using `github/codeql-action/upload-sarif@v3` +- the Trivy SARIF upload happens after pushes to `main`, not as a PR gate + +What this means operationally: + +- PRs to `main` now produce dedicated CodeQL checks and code scanning results +- pushes to `main` continue to refresh the default-branch code scanning baseline +- branch protection should use the real CodeQL job check names after they appear, not the generic label `Code scanning results` +- Trivy image scanning remains a post-merge image-security signal, not a source-code scanning gate + +Recommended interpretation: + +- CodeQL is the source-code security layer +- Trivy image scanning is the post-merge image-security layer +- these are complementary and should not be treated as the same check type \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5a0cada6..50f2caf2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,7 +17,7 @@ Explain the problem, requirement, or risk this PR addresses. - [ ] `make lint` - [ ] `make test backend` - [ ] `make test web` -- [ ] `make e2e` +- [ ] `make test e2e fast` - [ ] Other validation noted below Additional validation notes: diff --git a/.github/workflows/_quality-gate.yml b/.github/workflows/_quality-gate.yml index edcc2e8d..d0aeec71 100644 --- a/.github/workflows/_quality-gate.yml +++ b/.github/workflows/_quality-gate.yml @@ -59,6 +59,21 @@ jobs: - name: Run backend tests run: make test backend + openapi: + name: OpenAPI Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Run OpenAPI sync check + run: make openapi-sync + test-frontend: name: Test Frontend runs-on: ubuntu-latest @@ -164,7 +179,7 @@ jobs: e2e: name: E2E Smoke runs-on: ubuntu-latest - needs: [lint, test-backend, test-frontend, sec] + needs: [lint, test-backend, test-frontend, openapi, sec] timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -173,7 +188,7 @@ jobs: run: mkdir -p build/reports/e2e - name: Run container smoke test - run: make e2e + run: make test e2e fast env: APPOS_E2E_ARTIFACT_DIR: build/reports/e2e APPOS_E2E_KEEP_CONTAINER_ON_FAILURE: "1" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..5a4c1cc1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,71 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze-go: + name: Analyze Go + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Build Go sources + run: cd backend && go build ./... + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:go + + analyze-javascript-typescript: + name: Analyze JavaScript TypeScript + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend deps + run: cd web && npm ci + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:javascript-typescript \ No newline at end of file diff --git a/.github/workflows/dev-fast-ci.yml b/.github/workflows/dev-fast-ci.yml new file mode 100644 index 00000000..b83765ad --- /dev/null +++ b/.github/workflows/dev-fast-ci.yml @@ -0,0 +1,85 @@ +name: Dev Fast CI + +on: + push: + branches-ignore: + - main + workflow_dispatch: + +concurrency: + group: dev-fast-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + fast-checks: + name: Fast Checks + runs-on: ubuntu-latest + env: + GITLEAKS_REPORT_PATH: build/reports/gitleaks-report.json + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Prepare Go tool bin + run: | + mkdir -p "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend deps + run: cd web && npm ci + + - name: Install fast-check tools + run: | + set -euo pipefail + GOBIN="$RUNNER_TEMP/bin" go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + GOBIN="$RUNNER_TEMP/bin" go install golang.org/x/vuln/cmd/govulncheck@latest + + GL_VERSION="8.24.2" + ARCH="$(uname -m)" + case "$ARCH" in + x86_64) GL_ARCH="x64" ;; + aarch64|arm64) GL_ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; + esac + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GL_VERSION}/gitleaks_${GL_VERSION}_linux_${GL_ARCH}.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP/bin" gitleaks + + - name: Run fast lint + run: make lint fast + env: + GOLANGCI_LINT_BIN: ${{ runner.temp }}/bin/golangci-lint + + - name: Run fast backend tests + run: make test backend fast + + - name: Run frontend tests + run: make test web + + - name: Run fast security checks + run: make sec fast + env: + GOVULNCHECK_BIN: ${{ runner.temp }}/bin/govulncheck + GITLEAKS_BIN: ${{ runner.temp }}/bin/gitleaks + GITLEAKS_REPORT_PATH: ${{ env.GITLEAKS_REPORT_PATH }} + + - name: Upload fast CI gitleaks report + if: always() + uses: actions/upload-artifact@v4 + with: + name: dev-fast-gitleaks-${{ github.sha }} + path: ${{ env.GITLEAKS_REPORT_PATH }} + if-no-files-found: ignore \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..935c0549 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,46 @@ +title = "AppOS gitleaks config" + +[extend] +useDefault = true + +[[allowlists]] +description = "Generated frontend build output" +paths = [ + '''^web/dist/''' +] + +[[allowlists]] +description = "BMAD file manifest stores content hashes, not credentials" +paths = [ + '''^_bmad/_config/files-manifest\.csv$''' +] + +[[allowlists]] +description = "Bundled skill docs use redacted or illustrative token examples" +paths = [ + '''^\.agents/skills/.*/resources/knowledge/api-testing-patterns\.md$''', + '''^\.agents/skills/wds-6-asset-generation/steps-p/step-01-load-context\.md$''' +] + +[[allowlists]] +description = "Test fixtures use deterministic non-production placeholders" +paths = [ + '''^backend/domain/certs/resolve_test\.go$''', + '''^backend/domain/monitor/signals/agent/agent_test\.go$''', + '''^backend/domain/monitor/signals/checks/credential_sweep_test\.go$''', + '''^backend/domain/worker/monitoring_checks_test\.go$''' +] +regexes = [ + '''MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=''', + '''-----BEGIN PRIVATE KEY-----''', +] + +[[allowlists]] +description = "Deterministic development-only crypto fallback keys" +paths = [ + '''^backend/infra/crypto/crypto\.go$''', + '''^backend/domain/secrets/legacy_encryption\.go$''' +] +regexes = [ + '''0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef''' +] \ No newline at end of file diff --git a/Makefile b/Makefile index 567b0cfe..36327fc3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -.PHONY: help install tidy build run test test-strict test-fast lint lint-strict lint-fast fmt fmt-strict fmt-fast check check-fast version-check sec sec-strict sec-fast scan sbom e2e \ +.PHONY: help install tidy build run test test-strict test-fast lint lint-strict lint-fast fmt fmt-strict fmt-fast check check-fast sec sec-strict sec-fast artifact-scan \ backend web backend-targeted fast strict build-local latest dev \ image start stop restart logs stats delete rm kill-port redo \ openapi-gen openapi-merge openapi-check openapi-sync @@ -20,9 +20,12 @@ GITLEAKS_ARGS := $(if $(CI),--redact,--no-git --redact) GOLANGCI_LINT_BIN ?= golangci-lint GOVULNCHECK_BIN ?= govulncheck GITLEAKS_BIN ?= gitleaks +ACTIONLINT_BIN ?= actionlint GITLEAKS_REPORT_PATH ?= build/reports/gitleaks-report.json GO_BIN_DIR := $(shell GOBIN="$$(go env GOBIN)"; if [ -n "$$GOBIN" ]; then printf '%s' "$$GOBIN"; else printf '%s/bin' "$$(go env GOPATH)"; fi) DEFAULT_GOLANGCI_LINT_BIN := $(GO_BIN_DIR)/golangci-lint +DEFAULT_GOVULNCHECK_BIN := $(GO_BIN_DIR)/govulncheck +DEFAULT_ACTIONLINT_BIN := $(GO_BIN_DIR)/actionlint # ============================================================ # Help @@ -43,28 +46,32 @@ help: @echo " make redo Full rebuild: rm volumes + build + image + start dev" @echo "" @printf "\033[36mTesting & Quality:\033[0m\n" - @echo " make test Run strict tests (Go + JS, stop early)" - @echo " make test fast Run faster tests (bulk Go test execution)" + @echo " make test Run strict tests (Go + JS + E2E smoke, stop early)" + @echo " make test fast Run faster tests (Go + JS, no E2E)" @echo " make test backend Run strict backend Go tests from backend/" @echo " make test backend fast Run faster backend Go tests from backend/" @echo " make test web Run web tests from web/" @echo " make test backend-targeted Run backend routes/secrets/migrations test set" - @echo " make lint Run strict linters (golangci-lint/go vet, eslint)" + @echo " make test e2e Run the full end-to-end suite entrypoint" + @echo " make test e2e fast Run the smoke E2E suite" + @echo " make lint Run strict linters (golangci-lint, actionlint, eslint, web typecheck)" @echo " make lint fast Run advisory/fast lint mode" @echo " make fmt Format code in strict mode" @echo " make fmt fast Format code in tolerant/fast mode" - @echo " make check Run strict fmt + lint + test + sec, stop at first error" - @echo " make check fast Run faster fmt + lint + test + sec flow" - @echo " make e2e Run end-to-end/container-required smoke tests" + @echo " make check Run strict quality checks (lint + fmt + openapi-check + test), stop at first error" + @echo " make check fast Run faster quality check flow" @echo " make version-check Validate Git tag version metadata or print current git-derived version" + @echo "" + @printf "\033[36mOpenAPI:\033[0m\n" @echo " make openapi-gen Auto-generate OpenAPI spec skeleton from route source" @echo " make openapi-merge Merge ext-api.yaml + native-api.yaml -> api.yaml" - @echo " make openapi-check Assert all /api/ext routes are in the spec (CI gate)" + @echo " make openapi-check Validate code->spec coverage and group-matrix generated anchors" @echo " make openapi-sync Generate + validate OpenAPI in one command" - @echo " make sec Run strict security scan (govulncheck, npm audit, gitleaks)" + @echo "" + @printf "\033[36mSecurity & Artifacts:\033[0m\n" + @echo " make sec Run strict source security scan (govulncheck, npm audit, gitleaks, trivy config)" @echo " make sec fast Run advisory/fast security scan" - @echo " make scan Container image scan (trivy, HIGH/CRITICAL)" - @echo " make sbom Generate SBOM → sbom.spdx.json (syft)" + @echo " make artifact-scan Generate SBOM and scan the built image (syft + trivy)" @echo "" @printf "\033[36mBuild Image:\033[0m\n" @echo " make image build Build production image (multi-stage Dockerfile)" @@ -130,6 +137,16 @@ install: fi @echo "✓ Dependencies installed" @echo "" + @echo "Installing Node.js CLI tools..." + @# Qodo CLI is published on npm as @qodo/command (provides the `qodo` binary) + @if ! command -v qodo >/dev/null 2>&1; then \ + echo "→ qodo..."; \ + npm install -g @qodo/command; \ + else \ + echo "✓ qodo already installed"; \ + fi + @echo "✓ Node.js CLI tools installed" + @echo "" @echo "Installing Go tooling..." @# golangci-lint @if [ ! -x "$(DEFAULT_GOLANGCI_LINT_BIN)" ] && ! command -v golangci-lint >/dev/null 2>&1; then \ @@ -138,11 +155,17 @@ install: else \ echo "✓ golangci-lint already installed"; \ fi + @if [ ! -x "$(DEFAULT_ACTIONLINT_BIN)" ] && ! command -v actionlint >/dev/null 2>&1; then \ + echo "→ actionlint..."; \ + go install github.com/rhysd/actionlint/cmd/actionlint@latest; \ + else \ + echo "✓ actionlint already installed"; \ + fi @echo "✓ Go tooling installed to $(GO_BIN_DIR)" @echo "" @echo "Installing security tools..." @# govulncheck - @if ! command -v govulncheck >/dev/null 2>&1; then \ + @if [ ! -x "$(DEFAULT_GOVULNCHECK_BIN)" ] && ! command -v govulncheck >/dev/null 2>&1; then \ echo "→ govulncheck..."; \ go install golang.org/x/vuln/cmd/govulncheck@latest; \ else \ @@ -271,6 +294,17 @@ else ifeq ($(QUALITY_SCOPE),backend-targeted) @echo "Running targeted backend tests..." @cd backend && go test ./domain/routes ./domain/secrets ./infra/migrations -v @echo "✓ Targeted backend tests completed" +else ifeq ($(QUALITY_SCOPE),e2e) +ifeq ($(QUALITY_MODE),fast) + @echo "Running E2E smoke suite..." + @bash tests/e2e/container-smoke.sh + @bash tests/e2e/setup-status.sh + @echo "✓ E2E smoke suite completed" +else + @echo "Running full E2E suite..." + @$(MAKE) test e2e fast + @echo "✓ Full E2E suite completed" +endif else @echo "Running tests ($(QUALITY_MODE))..." ifeq ($(QUALITY_MODE),fast) @@ -282,6 +316,7 @@ ifeq ($(QUALITY_MODE),fast) echo "→ JS tests..."; \ cd web && npm test 2>/dev/null; \ fi + @echo "→ E2E skipped in fast mode" else @if [ -f "backend/go.mod" ]; then \ echo "→ Go tests (package-by-package)..."; \ @@ -322,6 +357,8 @@ else fi; \ rm -f "$$log_file"; \ fi + @echo "→ E2E smoke tests..." + @$(MAKE) test e2e fast endif @echo "✓ Tests completed" endif @@ -342,10 +379,26 @@ ifeq ($(QUALITY_MODE),fast) echo "→ go vet (golangci-lint not installed)..."; \ cd backend && go vet ./... || true; \ fi + @if [ -d ".github/workflows" ]; then \ + actionlint_bin="$(ACTIONLINT_BIN)"; \ + if ! [ -x "$$actionlint_bin" ] && ! command -v "$$actionlint_bin" >/dev/null 2>&1 && [ -x "$(DEFAULT_ACTIONLINT_BIN)" ]; then \ + actionlint_bin="$(DEFAULT_ACTIONLINT_BIN)"; \ + fi; \ + if [ -x "$$actionlint_bin" ] || command -v "$$actionlint_bin" >/dev/null 2>&1; then \ + echo "→ actionlint..."; \ + "$$actionlint_bin" || true; \ + else \ + echo "→ actionlint skipped (not installed)..."; \ + fi; \ + fi @if [ -f "web/node_modules/.bin/eslint" ]; then \ echo "→ eslint..."; \ cd web && npx eslint src/ || true; \ fi + @if [ -f "web/package.json" ]; then \ + echo "→ web typecheck..."; \ + cd web && npm run typecheck || true; \ + fi else @if [ -x "$(GOLANGCI_LINT_BIN)" ] || command -v "$(GOLANGCI_LINT_BIN)" >/dev/null 2>&1 || [ -x "$(DEFAULT_GOLANGCI_LINT_BIN)" ]; then \ echo "→ golangci-lint..."; \ @@ -360,10 +413,29 @@ else echo " Install it with 'make install' or run 'make lint fast' for advisory fallback mode."; \ exit 1; \ fi + @if [ -d ".github/workflows" ]; then \ + actionlint_bin="$(ACTIONLINT_BIN)"; \ + if ! [ -x "$$actionlint_bin" ] && ! command -v "$$actionlint_bin" >/dev/null 2>&1; then \ + actionlint_bin="$(DEFAULT_ACTIONLINT_BIN)"; \ + fi; \ + if [ -x "$$actionlint_bin" ] || command -v "$$actionlint_bin" >/dev/null 2>&1; then \ + echo "→ actionlint..."; \ + "$$actionlint_bin"; \ + else \ + echo "✗ actionlint is required for strict lint mode."; \ + echo " Expected binary at $(DEFAULT_ACTIONLINT_BIN) or on PATH."; \ + echo " Install it with 'make install' or run 'make lint fast' for advisory fallback mode."; \ + exit 1; \ + fi; \ + fi @if [ -f "web/node_modules/.bin/eslint" ]; then \ echo "→ eslint..."; \ cd web && npx eslint src/; \ fi + @if [ -f "web/package.json" ]; then \ + echo "→ web typecheck..."; \ + cd web && npm run typecheck; \ + fi endif @echo "✓ Linting completed" @@ -401,10 +473,10 @@ fmt-fast: check: @set -e; \ echo "Running full check ($(QUALITY_MODE), stop at first error)..."; \ - $(MAKE) fmt $(if $(filter fast,$(QUALITY_MODE)),fast,) || { echo "✗ check failed at: fmt"; exit 1; }; \ $(MAKE) lint $(if $(filter fast,$(QUALITY_MODE)),fast,) || { echo "✗ check failed at: lint"; exit 1; }; \ + $(MAKE) fmt $(if $(filter fast,$(QUALITY_MODE)),fast,) || { echo "✗ check failed at: fmt"; exit 1; }; \ + $(MAKE) openapi-check || { echo "✗ check failed at: openapi-check"; exit 1; }; \ $(MAKE) test $(if $(filter fast,$(QUALITY_MODE)),fast,) || { echo "✗ check failed at: test"; exit 1; }; \ - $(MAKE) sec $(if $(filter fast,$(QUALITY_MODE)),fast,) || { echo "✗ check failed at: sec"; exit 1; }; \ echo "✓ Check completed" check-fast: @@ -421,8 +493,8 @@ openapi-merge: @echo "→ spec: backend/docs/openapi/api.yaml" openapi-check: - @echo "Checking all generated custom routes are covered by OpenAPI spec..." - @cd backend && go test ./domain/routes/ -run TestAllCustomRoutesCoveredByOpenAPISpec -v + @echo "Checking OpenAPI coverage and group-matrix generated anchors..." + @cd backend && go test ./domain/routes/ -run 'TestAll(CustomRoutesCoveredByOpenAPISpec|MatrixExtSurfacesHaveGeneratedSpecAnchors)' -v openapi-sync: @echo "Syncing OpenAPI spec (generate + merge + validate)..." @@ -444,8 +516,12 @@ sec: ifeq ($(QUALITY_MODE),fast) @echo "Running security checks (fast)..." @echo "→ govulncheck (Go CVE scan)..." - @if [ -x "$(GOVULNCHECK_BIN)" ] || command -v "$(GOVULNCHECK_BIN)" >/dev/null 2>&1; then \ - cd backend && "$(GOVULNCHECK_BIN)" ./... || true; \ + @if [ -x "$(GOVULNCHECK_BIN)" ] || command -v "$(GOVULNCHECK_BIN)" >/dev/null 2>&1 || [ -x "$(DEFAULT_GOVULNCHECK_BIN)" ]; then \ + govuln_bin="$(GOVULNCHECK_BIN)"; \ + if ! [ -x "$$govuln_bin" ] && ! command -v "$$govuln_bin" >/dev/null 2>&1; then \ + govuln_bin="$(DEFAULT_GOVULNCHECK_BIN)"; \ + fi; \ + cd backend && "$$govuln_bin" ./... || true; \ else \ echo " ⚠ govulncheck not installed. Run 'make install' first."; \ fi @@ -475,13 +551,33 @@ ifeq ($(QUALITY_MODE),fast) else \ echo " ⚠ gitleaks not installed. Run 'make install' first."; \ fi + @echo "" + @echo "→ trivy config (IaC / Docker / workflow misconfiguration scan)..." + @if ! command -v docker >/dev/null 2>&1; then \ + echo " ⚠ docker not installed. Skip trivy config scan."; \ + else \ + docker run --rm \ + -v "$$(pwd):/workspace" \ + -w /workspace \ + aquasec/trivy:latest config \ + --skip-check-update \ + --skip-version-check \ + --timeout 10m \ + --severity HIGH,CRITICAL \ + --exit-code 0 \ + /workspace/build || true; \ + fi @echo "✓ Security checks completed" else @echo "Running security checks (strict)..." @echo "→ govulncheck (Go CVE scan)..." - @if [ -x "$(GOVULNCHECK_BIN)" ] || command -v "$(GOVULNCHECK_BIN)" >/dev/null 2>&1; then \ - cd backend && "$(GOVULNCHECK_BIN)" ./...; \ + @if [ -x "$(GOVULNCHECK_BIN)" ] || command -v "$(GOVULNCHECK_BIN)" >/dev/null 2>&1 || [ -x "$(DEFAULT_GOVULNCHECK_BIN)" ]; then \ + govuln_bin="$(GOVULNCHECK_BIN)"; \ + if ! [ -x "$$govuln_bin" ] && ! command -v "$$govuln_bin" >/dev/null 2>&1; then \ + govuln_bin="$(DEFAULT_GOVULNCHECK_BIN)"; \ + fi; \ + cd backend && "$$govuln_bin" ./...; \ else \ echo "✗ govulncheck not installed. Run 'make install' first."; \ exit 1; \ @@ -510,6 +606,23 @@ else echo "✗ gitleaks not installed. Run 'make install' first."; \ exit 1; \ fi + @echo "" + @echo "→ trivy config (IaC / Docker / workflow misconfiguration scan)..." + @if ! command -v docker >/dev/null 2>&1; then \ + echo "✗ docker is required for trivy config scan."; \ + exit 1; \ + else \ + docker run --rm \ + -v "$$(pwd):/workspace" \ + -w /workspace \ + aquasec/trivy:latest config \ + --skip-check-update \ + --skip-version-check \ + --timeout 10m \ + --severity HIGH,CRITICAL \ + --exit-code 1 \ + /workspace/build; \ + fi @echo "✓ Security checks completed" endif @@ -519,7 +632,15 @@ sec-strict: sec-fast: @$(MAKE) sec fast -scan: +artifact-scan: + @echo "Generating Software Bill of Materials (SBOM)..." + @if ! command -v syft >/dev/null 2>&1; then \ + echo "✗ syft not installed. Run 'make install' first."; exit 1; \ + fi + @syft dir:backend dir:web/src -o spdx-json > sbom.spdx.json + @echo "✓ SBOM generated → sbom.spdx.json" + @wc -l sbom.spdx.json | awk '{print " Lines: " $$1}' + @echo "" @echo "Scanning container image for vulnerabilities (HIGH/CRITICAL)..." @if ! docker image inspect websoft9dev/appos:latest >/dev/null 2>&1; then \ echo "✗ Image websoft9dev/appos:latest not found. Run 'make image build' first."; exit 1; \ @@ -532,21 +653,6 @@ scan: websoft9dev/appos:latest @echo "✓ Image scan completed" -sbom: - @echo "Generating Software Bill of Materials (SBOM)..." - @if ! command -v syft >/dev/null 2>&1; then \ - echo "✗ syft not installed. Run 'make install' first."; exit 1; \ - fi - syft dir:backend dir:web/src -o spdx-json > sbom.spdx.json - @echo "✓ SBOM generated → sbom.spdx.json" - @wc -l sbom.spdx.json | awk '{print " Lines: " $$1}' - -e2e: - @echo "Running end-to-end tests (container required)..." - @bash tests/e2e/container-smoke.sh - @bash tests/e2e/setup-status.sh - @echo "✓ E2E tests completed" - # ============================================================ # Build Image # ============================================================ diff --git a/backend/cmd/openapi/gen.go b/backend/cmd/openapi/gen.go index 8377ec8c..55b1b1c3 100644 --- a/backend/cmd/openapi/gen.go +++ b/backend/cmd/openapi/gen.go @@ -38,6 +38,7 @@ var ( reRouterGroupAssign = regexp.MustCompile(`(\w+)\s*:?=\s*(\w+)\.Router\.Group\("([^"]*)"\)`) reInlineGroupCall = regexp.MustCompile(`\b(register[A-Za-z_][A-Za-z0-9_]*)\((\w+)(\.Router)?\.Group\("([^"]*)"\)\)`) reRouteMethod = regexp.MustCompile(`(\w+)\.(GET|POST|PUT|DELETE|PATCH|HEAD)\("([^"]*)"`) + reRootRouterMethod = regexp.MustCompile(`(\w+)\.Router\.(GET|POST|PUT|DELETE|PATCH|HEAD)\("([^"]*)"`) reRouteMethodHandler = regexp.MustCompile(`(\w+)\.(GET|POST|PUT|DELETE|PATCH|HEAD)\("([^"]*)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)`) reAuthBind = regexp.MustCompile(`(\w+)\.Bind\(apis\.RequireAuth\(\)\)`) reSuperuserBind = regexp.MustCompile(`(\w+)\.Bind\(apis\.RequireSuperuserAuth\(\)\)`) @@ -261,7 +262,7 @@ func scanFile(filePath string, seeds map[string]functionSeed) ([]route, map[stri // Collect route registrations if m := reRouteMethod.FindStringSubmatch(line); m != nil { varName, method, suffix := m[1], m[2], m[3] - if base, ok := vars[varName]; ok && strings.HasPrefix(base, "/api/") { + if base, ok := vars[varName]; ok && (strings.HasPrefix(base, "/api/") || strings.HasPrefix(base, "/tunnel/setup")) { r := route{method: method, path: base + suffix} r.detectedAuth = strings.TrimSpace(varAuth[varName]) if hm := reRouteMethodHandler.FindStringSubmatch(line); hm != nil { @@ -317,6 +318,16 @@ func scanFile(filePath string, seeds map[string]functionSeed) ([]route, map[stri routes = append(routes, r) } } + + if m := reRootRouterMethod.FindStringSubmatch(line); m != nil { + varName, method, suffix := m[1], m[2], m[3] + if base, ok := vars[varName]; ok { + fullPath := base + suffix + if strings.HasPrefix(fullPath, "/api/") || strings.HasPrefix(fullPath, "/tunnel/setup") { + routes = append(routes, route{method: method, path: fullPath, detectedAuth: authForPath(fullPath, superuserVarPaths)}) + } + } + } } return routes, superuserVarPaths } diff --git a/backend/docs/openapi/README.md b/backend/docs/openapi/README.md index bee08d5e..f9bc1547 100644 --- a/backend/docs/openapi/README.md +++ b/backend/docs/openapi/README.md @@ -19,4 +19,7 @@ Rules: Commands: - make openapi-gen: regenerate ext-api.yaml. - make openapi-merge: merge ext-api.yaml and native-api.yaml into api.yaml. +- make openapi-check: validate both directions for generated custom-route docs: + - every custom route anchor found in route code is present in ext-api.yaml + - every extSurface entry in group-matrix.yaml has at least one matching generated path in ext-api.yaml after make openapi-gen - make openapi-sync: generate, merge, and validate in one step. \ No newline at end of file diff --git a/backend/docs/openapi/api.yaml b/backend/docs/openapi/api.yaml index f6fea31d..95b2f487 100644 --- a/backend/docs/openapi/api.yaml +++ b/backend/docs/openapi/api.yaml @@ -37,7 +37,7 @@ tags: name: Health - description: Infrastructure-as-Code workspace and template file operations. name: IaC - - description: Monitoring overview, target status and series queries, server agent bootstrap, agent ingest APIs, and Netdata remote-write ingress. + - description: Monitoring overview, container telemetry, target status and series queries, server agent bootstrap, and agent ingest APIs. name: Monitoring - description: Pipeline run inventory and detail APIs for lifecycle-native execution tracking. name: Pipelines @@ -49,7 +49,7 @@ tags: name: Realtime - description: Release inventory and app-scoped release inspection APIs. name: Releases - - description: Generic resource-store collection APIs for cloud accounts, databases, and scripts only. + - description: Generic resource-store collection APIs for scripts only. name: Resource - description: Secret storage, rotation, resolve, and reveal APIs. name: Secrets @@ -69,7 +69,7 @@ tags: name: Space & User Files - description: Host metrics and file browser endpoints. name: System - - description: Native cron jobs API + - description: PocketBase scheduled tasks and cron management APIs. name: System Cron - description: Interactive terminal and remote file APIs for SSH, Docker exec, SFTP, and local shell sessions. name: Terminal @@ -3951,9 +3951,7 @@ paths: - System Cron /api/crons/{jobId}/logs: get: - description: | - Returns recent structured execution log lines for one cron job, filtered from PocketBase _logs. Only AppOS-instrumented jobs are guaranteed to have rich log data. Non-instrumented native jobs return an empty items array. - operationId: appos_crons_logs + operationId: get_api_crons_jobid_logs parameters: - in: path name: jobId @@ -3965,57 +3963,17 @@ paths: content: application/json: schema: - properties: - items: - items: - properties: - created: - format: date-time - type: string - durationMs: - nullable: true - type: number - error: - nullable: true - level: - type: integer - message: - type: string - phase: - enum: - - start - - success - - error - type: string - runId: - type: string - trigger: - enum: - - scheduled - - manual - type: string - type: object - type: array - jobId: - type: string - lastDurationMs: - nullable: true - type: number - lastRun: - format: date-time - nullable: true - type: string - lastStatus: - enum: - - success - - error - nullable: true - type: string - type: object - description: OK - security: - - bearerAuth: [] - summary: Get cron job execution logs + $ref: '#/components/schemas/SuccessEnvelope' + description: OK + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + security: + - bearerAuth: [] + summary: Get crons by jobId logs tags: - System Cron /api/exposures: @@ -7312,6 +7270,38 @@ paths: summary: Create or execute monitor servers by id agent token tags: - Monitoring + /api/monitor/servers/{id}/container-telemetry: + get: + operationId: get_api_monitor_servers_id_container-telemetry + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: query + name: window + required: false + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessEnvelope' + description: OK + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + security: + - bearerAuth: [] + summary: Get monitor servers by id container telemetry + tags: + - Monitoring /api/monitor/targets/{targetType}/{targetId}: get: operationId: get_api_monitor_targets_targettype_targetid @@ -10715,3 +10705,23 @@ paths: summary: Create or execute tunnel servers by id token tags: - Tunnel + /tunnel/setup/{token}: + get: + operationId: get_tunnel_setup_token + parameters: + - in: path + name: token + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessEnvelope' + description: OK + security: [] + summary: Get tunnel setup by token + tags: + - Tunnel diff --git a/backend/docs/openapi/ext-api.yaml b/backend/docs/openapi/ext-api.yaml index c800a13d..abaceb41 100644 --- a/backend/docs/openapi/ext-api.yaml +++ b/backend/docs/openapi/ext-api.yaml @@ -32,7 +32,7 @@ tags: - name: IaC description: "Infrastructure-as-Code workspace and template file operations." - name: Monitoring - description: "Monitoring overview, target status and series queries, server agent bootstrap, agent ingest APIs, and Netdata remote-write ingress." + description: "Monitoring overview, container telemetry, target status and series queries, server agent bootstrap, and agent ingest APIs." - name: Pipelines description: "Pipeline run inventory and detail APIs for lifecycle-native execution tracking." - name: Provider Accounts @@ -42,7 +42,7 @@ tags: - name: Releases description: "Release inventory and app-scoped release inspection APIs." - name: Resource - description: "Generic resource-store collection APIs for cloud accounts, databases, and scripts only." + description: "Generic resource-store collection APIs for scripts only." - name: Secrets description: "Secret storage, rotation, resolve, and reveal APIs." - name: Servers @@ -61,6 +61,8 @@ tags: description: "Workspace and storage-space related operations." - name: System description: "Host metrics and file browser endpoints." + - name: System Cron + description: "PocketBase scheduled tasks and cron management APIs." - name: Terminal description: "Interactive terminal and remote file APIs for SSH, Docker exec, SFTP, and local shell sessions." - name: Topics @@ -2535,6 +2537,32 @@ paths: schema: type: object additionalProperties: true + /api/crons/{jobId}/logs: + get: + tags: [System Cron] + summary: Get crons by jobId logs + operationId: get_api_crons_jobid_logs + parameters: + - name: jobId + in: path + required: true + schema: + type: string + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessEnvelope' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' /api/exposures: get: tags: [Exposures] @@ -5687,6 +5715,37 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + /api/monitor/servers/{id}/container-telemetry: + get: + tags: [Monitoring] + summary: Get monitor servers by id container telemetry + operationId: get_api_monitor_servers_id_container-telemetry + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: window + in: query + required: false + schema: + type: string + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessEnvelope' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' /api/monitor/targets/{targetType}/{targetId}: get: tags: [Monitoring] @@ -8921,3 +8980,22 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + /tunnel/setup/{token}: + get: + tags: [Tunnel] + summary: Get tunnel setup by token + operationId: get_tunnel_setup_token + parameters: + - name: token + in: path + required: true + schema: + type: string + security: [] # public + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessEnvelope' diff --git a/backend/docs/openapi/group-matrix.yaml b/backend/docs/openapi/group-matrix.yaml index 205bac1f..f2e694b2 100644 --- a/backend/docs/openapi/group-matrix.yaml +++ b/backend/docs/openapi/group-matrix.yaml @@ -298,11 +298,9 @@ groups: nativeRefs: [] - group: Resource - description: Generic resource-store collection APIs for cloud accounts, databases, and scripts only. + description: Generic resource-store collection APIs for scripts only. apiType: Ext extSurface: - - /api/ext/resources/cloud-accounts* - - /api/ext/resources/databases* - /api/ext/resources/scripts* nativeSurface: [] sources: @@ -400,10 +398,11 @@ groups: - https://pocketbase.io/docs/api-records/#crud-actions - group: Monitoring - description: Monitoring overview, target status and series queries, server agent bootstrap, agent ingest APIs, and Netdata remote-write ingress. + description: Monitoring overview, container telemetry, target status and series queries, server agent bootstrap, and agent ingest APIs. apiType: Ext extSurface: - GET /api/monitor/overview + - GET /api/monitor/servers/{id}/container-telemetry - GET /api/monitor/targets/{targetType}/{targetId} - GET /api/monitor/targets/{targetType}/{targetId}/series - POST /api/monitor/servers/{id}/agent-token @@ -412,7 +411,6 @@ groups: - POST /api/monitor/ingest/metrics - POST /api/monitor/ingest/heartbeat - POST /api/monitor/ingest/runtime-status - - POST /api/monitor/netdata/write nativeSurface: [] sources: extRouteFiles: @@ -504,13 +502,15 @@ groups: - group: System Cron description: PocketBase scheduled tasks and cron management APIs. - apiType: Native - extSurface: [] + apiType: Mixed + extSurface: + - GET /api/crons/{jobId}/logs nativeSurface: - GET /api/crons - POST /api/crons/{jobId} sources: - extRouteFiles: [] + extRouteFiles: + - crons.go nativeRefs: - https://pocketbase.io/docs/api-crons/ diff --git a/backend/domain/routes/routes_coverage_test.go b/backend/domain/routes/routes_coverage_test.go index 1acba402..7173f4b6 100644 --- a/backend/domain/routes/routes_coverage_test.go +++ b/backend/domain/routes/routes_coverage_test.go @@ -15,10 +15,25 @@ import ( var ( reGroupAssign = regexp.MustCompile(`(\w+)\s*:?=\s*(\w+)\.Group\("([^"]*)"\)`) reRouterGroupAssign = regexp.MustCompile(`(\w+)\s*:?=\s*(\w+)\.Router\.Group\("([^"]*)"\)`) + reInlineGroupCall = regexp.MustCompile(`\b(register[A-Za-z_][A-Za-z0-9_]*)\((\w+)(\.Router)?\.Group\("([^"]*)"\)\)`) + reFuncSignature = regexp.MustCompile(`^func\s*(\([^)]*\)\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)`) + reRegisterCall = regexp.MustCompile(`\b(register[A-Za-z_][A-Za-z0-9_]*)\((\w+)\)`) reRouteMethod = regexp.MustCompile(`(\w+)\.(GET|POST|PUT|DELETE|PATCH|HEAD)\("([^"]*)"`) - reSpecPathKey = regexp.MustCompile(`^ (/(?:api/ext|api/servers|api/terminal|api/actions|api/pipelines|api/releases|api/exposures|api/apps|api/components|api/catalog)[^\s:]*):\s*$`) + reRootRouterMethod = regexp.MustCompile(`(\w+)\.Router\.(GET|POST|PUT|DELETE|PATCH|HEAD)\("([^"]*)"`) + reSpecPathKey = regexp.MustCompile(`^ (/(?:api|tunnel/setup)[^\s:]*):\s*$`) + reSpecMethodKey = regexp.MustCompile(`^ (get|post|put|delete|patch|head):\s*$`) ) +type matrixGroup struct { + name string + apiType string + extSurface []string +} + +type functionSeed struct { + basePath string +} + func TestAllCustomRoutesCoveredByOpenAPISpec(t *testing.T) { _, thisFile, _, _ := runtime.Caller(0) routesDir := filepath.Dir(thisFile) @@ -61,7 +76,54 @@ func TestAllCustomRoutesCoveredByOpenAPISpec(t *testing.T) { } } +func TestAllMatrixExtSurfacesHaveGeneratedSpecAnchors(t *testing.T) { + _, thisFile, _, _ := runtime.Caller(0) + routesDir := filepath.Dir(thisFile) + + specPath := filepath.Join(routesDir, "../../docs/openapi/ext-api.yaml") + specOps, err := extractSpecOperations(specPath) + if err != nil { + t.Skipf("OpenAPI spec not found at %s (create it to enable enforcement): %v", specPath, err) + } + + matrixPath := filepath.Join(routesDir, "../../docs/openapi/group-matrix.yaml") + groups, err := extractMatrixGroups(matrixPath) + if err != nil { + t.Fatalf("failed to parse group matrix: %v", err) + } + + var missing []string + for _, group := range groups { + apiType := strings.ToLower(strings.TrimSpace(group.apiType)) + if apiType != "ext" && apiType != "mixed" { + continue + } + for _, surface := range group.extSurface { + matched := false + for _, op := range specOps { + if matrixSurfaceMatchesRoute(surface, op) { + matched = true + break + } + } + if !matched { + missing = append(missing, fmt.Sprintf("[%s] %s", group.name, strings.TrimSpace(surface))) + } + } + } + + if len(missing) > 0 { + sort.Strings(missing) + t.Fatalf("%d group-matrix extSurface pattern(s) have no matching generated path in backend/docs/openapi/ext-api.yaml. make openapi-gen is not producing these entries.\n\n%s", len(missing), strings.Join(missing, "\n")) + } +} + func extractCustomRoutes(dir string) ([]string, error) { + seeds, err := loadRouteFunctionSeeds(dir) + if err != nil { + return nil, err + } + files, err := filepath.Glob(filepath.Join(dir, "*.go")) if err != nil { return nil, err @@ -72,7 +134,7 @@ func extractCustomRoutes(dir string) ([]string, error) { if strings.HasSuffix(f, "_test.go") { continue } - routes, err := extractRoutesFromFile(f) + routes, err := extractRoutesFromFile(f, seeds) if err != nil { return nil, fmt.Errorf("parsing %s: %w", filepath.Base(f), err) } @@ -81,7 +143,7 @@ func extractCustomRoutes(dir string) ([]string, error) { return all, nil } -func extractRoutesFromFile(path string) ([]string, error) { +func extractRoutesFromFile(path string, seeds map[string]functionSeed) ([]string, error) { data, err := os.ReadFile(path) if err != nil { return nil, err @@ -111,6 +173,29 @@ func extractRoutesFromFile(path string) ([]string, error) { scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() + trim := strings.TrimSpace(line) + + if m := reInlineGroupCall.FindStringSubmatch(line); m != nil { + funcName, parent, routerSelector, suffix := m[1], m[2], m[3], m[4] + basePath := "" + if base, ok := vars[parent]; ok && base != "" { + basePath = base + suffix + } else if routerSelector != "" { + basePath = suffix + } + if basePath != "" { + seeds[funcName] = functionSeed{basePath: basePath} + } + } + + if m := reFuncSignature.FindStringSubmatch(trim); m != nil { + if seed, ok := seeds[m[2]]; ok { + paramName := firstParamName(m[3]) + if paramName != "" { + vars[paramName] = seed.basePath + } + } + } if m := reGroupAssign.FindStringSubmatch(line); m != nil { newVar, parent, suffix := m[1], m[2], m[3] @@ -126,17 +211,90 @@ func extractRoutesFromFile(path string) ([]string, error) { } } + if m := reRegisterCall.FindStringSubmatch(line); m != nil { + funcName, argName := m[1], m[2] + if basePath, ok := vars[argName]; ok && basePath != "" { + seeds[funcName] = functionSeed{basePath: basePath} + } + } + if m := reRouteMethod.FindStringSubmatch(line); m != nil { varName, method, suffix := m[1], m[2], m[3] - if base, ok := vars[varName]; ok && (strings.HasPrefix(base, "/api/ext") || strings.HasPrefix(base, "/api/servers") || strings.HasPrefix(base, "/api/terminal") || strings.HasPrefix(base, "/api/actions") || strings.HasPrefix(base, "/api/pipelines") || strings.HasPrefix(base, "/api/releases") || strings.HasPrefix(base, "/api/exposures") || strings.HasPrefix(base, "/api/apps") || strings.HasPrefix(base, "/api/components") || strings.HasPrefix(base, "/api/catalog")) { + if base, ok := vars[varName]; ok && (strings.HasPrefix(base, "/api/") || strings.HasPrefix(base, "/tunnel/setup")) { routes = append(routes, method+" "+base+suffix) } } + + if m := reRootRouterMethod.FindStringSubmatch(line); m != nil { + varName, method, suffix := m[1], m[2], m[3] + if base, ok := vars[varName]; ok { + fullPath := base + suffix + if strings.HasPrefix(fullPath, "/api/") || strings.HasPrefix(fullPath, "/tunnel/setup") { + routes = append(routes, method+" "+fullPath) + } + } + } } return routes, scanner.Err() } +func loadRouteFunctionSeeds(routesDir string) (map[string]functionSeed, error) { + path := filepath.Join(routesDir, "routes.go") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + vars := map[string]string{"se": ""} + seeds := map[string]functionSeed{} + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := scanner.Text() + + if m := reGroupAssign.FindStringSubmatch(line); m != nil { + newVar, parent, suffix := m[1], m[2], m[3] + if base, ok := vars[parent]; ok { + vars[newVar] = base + suffix + } + } + + if m := reRouterGroupAssign.FindStringSubmatch(line); m != nil { + newVar, parent, suffix := m[1], m[2], m[3] + if base, ok := vars[parent]; ok { + vars[newVar] = base + suffix + } + } + + if m := reRegisterCall.FindStringSubmatch(line); m != nil { + funcName, argName := m[1], m[2] + if basePath, ok := vars[argName]; ok { + seeds[funcName] = functionSeed{basePath: basePath} + } + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return seeds, nil +} + +func firstParamName(params string) string { + trimmed := strings.TrimSpace(params) + if trimmed == "" { + return "" + } + first := strings.Split(trimmed, ",")[0] + fields := strings.Fields(strings.TrimSpace(first)) + if len(fields) == 0 { + return "" + } + return strings.TrimSpace(fields[0]) +} + func extractSpecPaths(specPath string) (map[string]struct{}, error) { f, err := os.Open(specPath) if err != nil { @@ -181,3 +339,118 @@ func extractDuplicateSpecPathKeys(specPath string) ([]string, error) { } return duplicates, nil } + +func extractSpecOperations(specPath string) ([]string, error) { + f, err := os.Open(specPath) + if err != nil { + return nil, err + } + defer f.Close() + + var ops []string + currentPath := "" + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if m := reSpecPathKey.FindStringSubmatch(line); m != nil { + currentPath = strings.TrimSpace(m[1]) + continue + } + if m := reSpecMethodKey.FindStringSubmatch(line); m != nil && currentPath != "" { + ops = append(ops, strings.ToUpper(strings.TrimSpace(m[1]))+" "+currentPath) + } + } + return ops, scanner.Err() +} + +func extractMatrixGroups(matrixPath string) ([]matrixGroup, error) { + f, err := os.Open(matrixPath) + if err != nil { + return nil, err + } + defer f.Close() + + var groups []matrixGroup + var current *matrixGroup + inExtSurface := false + + flush := func() { + if current == nil { + return + } + groups = append(groups, *current) + current = nil + inExtSurface = false + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + + if strings.HasPrefix(line, " - group:") { + flush() + current = &matrixGroup{name: strings.TrimSpace(strings.TrimPrefix(line, " - group:"))} + continue + } + if current == nil { + continue + } + + if strings.HasPrefix(line, " apiType:") { + current.apiType = strings.TrimSpace(strings.TrimPrefix(line, " apiType:")) + inExtSurface = false + continue + } + if strings.HasPrefix(line, " extSurface:") { + inExtSurface = true + continue + } + if inExtSurface && strings.HasPrefix(line, " - ") { + current.extSurface = append(current.extSurface, strings.TrimSpace(strings.TrimPrefix(line, " - "))) + continue + } + if inExtSurface && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") { + inExtSurface = false + } + } + flush() + + return groups, scanner.Err() +} + +func matrixSurfaceMatchesRoute(surface string, route string) bool { + method, path, hasMethod := parseSurfacePattern(surface) + routeMethod, routePath, ok := parseRouteEntry(route) + if !ok { + return false + } + if hasMethod && method != routeMethod { + return false + } + if strings.HasSuffix(path, "*") { + prefix := strings.TrimSuffix(path, "*") + base := strings.TrimSuffix(prefix, "/") + return routePath == base || strings.HasPrefix(routePath, prefix) + } + return routePath == path +} + +func parseSurfacePattern(surface string) (method string, path string, hasMethod bool) { + parts := strings.Fields(strings.TrimSpace(surface)) + if len(parts) >= 2 { + candidate := strings.ToUpper(strings.TrimSpace(parts[0])) + switch candidate { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD": + return candidate, strings.TrimSpace(parts[1]), true + } + } + return "", strings.TrimSpace(surface), false +} + +func parseRouteEntry(route string) (method string, path string, ok bool) { + parts := strings.Fields(strings.TrimSpace(route)) + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} diff --git a/backend/go.mod b/backend/go.mod index 9b464e59..b2e044f7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/websoft9/appos/backend -go 1.26.0 +go 1.26.2 require ( github.com/creack/pty v1.1.24 @@ -39,12 +39,12 @@ require ( github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/image v0.35.0 // indirect + golang.org/x/image v0.39.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.10 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index ee511f10..9e592548 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -101,8 +101,11 @@ golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHi golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= @@ -110,6 +113,8 @@ golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= @@ -120,11 +125,14 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= diff --git a/build/.env b/build/.env index 8dcf338b..f1c2ebd4 100644 --- a/build/.env +++ b/build/.env @@ -17,7 +17,7 @@ APPOS_DATA_PATH=/appos/data INIT_MODE=auto # auto: create superuser from env vars | setup: create via web UI # Credentials -APPOS_SECRET_KEY=BKHyx5SQ0QEwEp0jfHcLLNYbY4dtDtsrzJPcAHoXG2Q= +APPOS_SECRET_KEY=replace-with-a-random-base64-secret SUPERVISOR_PASSWORD=changeme SUPERUSER_EMAIL=admin@websoft9.com SUPERUSER_PASSWORD=changeme123 diff --git a/build/reports/gitleaks-report.json b/build/reports/gitleaks-report.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/build/reports/gitleaks-report.json @@ -0,0 +1 @@ +[] diff --git a/specs/implementation-artifacts/story1.2-makefile.md b/specs/implementation-artifacts/story1.2-makefile.md index bb3bae6b..3a5a38ab 100644 --- a/specs/implementation-artifacts/story1.2-makefile.md +++ b/specs/implementation-artifacts/story1.2-makefile.md @@ -36,7 +36,7 @@ make run 9092 # Copy artifacts + restart on port 9092 ### Testing & Quality ```bash -make test # Run all tests (Go + JS) +make test # Run strict tests (Go + JS + make test e2e fast) make lint # Run linters (golangci-lint, eslint) make fmt # Format code (gofmt, prettier) make check # fmt + lint in one step (local dev) @@ -145,7 +145,7 @@ make start # Code → Test cycle # ... edit code ... make run # Hot reload in 10 seconds -make test # Verify changes +make test # Verify changes (includes make test e2e fast in strict mode) ``` ### Production Build @@ -158,7 +158,7 @@ make start ### Testing ```bash -make test # Run all tests +make test # Run strict tests (includes make test e2e fast) make lint # Check code quality make fmt # Auto-format code make check # fmt + lint (local dev shortcut) diff --git a/specs/implementation-artifacts/story1.6-security-scanning.md b/specs/implementation-artifacts/story1.6-security-scanning.md index 22559618..e76c3c27 100644 --- a/specs/implementation-artifacts/story1.6-security-scanning.md +++ b/specs/implementation-artifacts/story1.6-security-scanning.md @@ -8,9 +8,8 @@ 作为开发者,我想要代码和镜像的安全检测工具,这样可以在开发阶段发现潜在漏洞和供应链风险。 ## 验收标准 -- [x] `make sec`: govulncheck(Go CVE)+ npm audit(JS CVE high+)+ gitleaks(密钥泄露检测) -- [x] `make scan`: trivy 镜像扫描(HIGH/CRITICAL,advisory 模式不阻断)— 通过 Docker 运行,无需安装 -- [x] `make sbom`: syft 生成 SBOM → `sbom.spdx.json`(范围:backend + web/src) +- [x] `make sec`: govulncheck(Go CVE)+ npm audit(JS CVE high+)+ gitleaks(密钥泄露检测)+ trivy config(源码配置风险扫描) +- [x] `make artifact-scan`: 先用 syft 生成 SBOM → `sbom.spdx.json`(范围:backend + web/src),再执行 trivy 镜像扫描(HIGH/CRITICAL,advisory 模式不阻断) - [x] `.golangci.yml`: gosec 纳入 lint 流程,豁免 G304/G115,测试文件仅豁免 errcheck/ineffassign - [x] CI `scan` job: trivy SARIF 推送 GitHub Security 标签页,SBOM 推送 GitHub Dependency Graph - [x] 工具安装集成到 `make install`(govulncheck/gitleaks/syft;trivy 通过 Docker 运行无需安装) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 207c7088..865335df 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -11,11 +11,13 @@ This directory is reserved for tests that require a real AppOS container runtime ## Current Entry Point -- `make e2e` +- `make test` (strict mode includes `make test e2e fast` after backend + web tests) +- `make test e2e` (full E2E entrypoint; currently runs the smoke suite until broader scenarios are added) +- `make test e2e fast` (smoke E2E suite) - `tests/e2e/container-smoke.sh` - `tests/e2e/setup-status.sh` -The smoke test builds the local AppOS image, starts a real container, and waits for `/api/health` to become reachable. +The smoke suite builds the local AppOS image, starts a real container, and waits for `/api/health` to become reachable. The setup-status scenario reuses the same real container startup path and verifies that `/api/ext/setup/status` is publicly reachable and returns the expected fresh-install contract (`needsSetup: true`, `initMode: auto`). @@ -29,4 +31,9 @@ The following existing tests were reviewed and intentionally left in the regular - `backend/cmd/appos-agent/main_test.go` - `web/src/routes/_app/_auth/resources/-servers.test.tsx` -These tests exercise Docker-aware logic, but they do not need the AppOS container itself and therefore do not belong in E2E. \ No newline at end of file +These tests exercise Docker-aware logic, but they do not need the AppOS container itself and therefore do not belong in E2E. + +## Planned Layering + +- `make test e2e fast`: smoke coverage for container boot and critical public/health flows. +- `make test e2e`: the full E2E entrypoint. It currently delegates to smoke and is intended to grow as broader runtime scenarios are added. \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index 0272a163..c0072e8f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3036,9 +3036,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "cpu": [ "arm" ], @@ -3049,9 +3049,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -3062,9 +3062,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -3075,9 +3075,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -3088,9 +3088,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "cpu": [ "arm64" ], @@ -3101,9 +3101,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -3114,9 +3114,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], @@ -3127,9 +3127,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ "arm" ], @@ -3140,9 +3140,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], @@ -3153,9 +3153,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ "arm64" ], @@ -3166,9 +3166,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ "loong64" ], @@ -3179,9 +3179,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ "loong64" ], @@ -3192,9 +3192,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ "ppc64" ], @@ -3205,9 +3205,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ "ppc64" ], @@ -3218,9 +3218,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ "riscv64" ], @@ -3231,9 +3231,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ "riscv64" ], @@ -3244,9 +3244,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "cpu": [ "s390x" ], @@ -3257,9 +3257,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "cpu": [ "x64" ], @@ -3270,9 +3270,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "cpu": [ "x64" ], @@ -3283,9 +3283,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "cpu": [ "x64" ], @@ -3296,9 +3296,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -3309,9 +3309,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ "arm64" ], @@ -3322,9 +3322,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ "ia32" ], @@ -3335,9 +3335,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -3348,9 +3348,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "cpu": [ "x64" ], @@ -4516,9 +4516,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -4526,13 +4526,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -4785,9 +4785,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4850,9 +4850,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4977,9 +4977,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -6084,9 +6084,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -7638,9 +7638,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -7863,9 +7863,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7890,9 +7890,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -8317,9 +8317,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -8501,9 +8501,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -8516,31 +8516,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, @@ -9049,9 +9049,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -9318,9 +9318,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", "peer": true, "dependencies": { diff --git a/web/src/pages/system/PlatformStatusPage.test.tsx b/web/src/pages/system/PlatformStatusPage.test.tsx index e3b47835..4292dfb3 100644 --- a/web/src/pages/system/PlatformStatusPage.test.tsx +++ b/web/src/pages/system/PlatformStatusPage.test.tsx @@ -295,5 +295,5 @@ describe('PlatformStatusPage', () => { expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() expect(await screen.findByText('Docker')).toBeInTheDocument() - }, 10000) + }, 15000) }) diff --git a/web/src/routes/_app/_auth/resources/-servers.test.tsx b/web/src/routes/_app/_auth/resources/-servers.test.tsx index 6e2a8531..0c7afeda 100644 --- a/web/src/routes/_app/_auth/resources/-servers.test.tsx +++ b/web/src/routes/_app/_auth/resources/-servers.test.tsx @@ -17,6 +17,9 @@ const createServerMock = vi.fn() const getSecretMock = vi.fn() const updateSecretMock = vi.fn() const getLocalDockerBridgeAddressMock = vi.fn() +const getSystemdStatusMock = vi.fn() +const installMonitorAgentMock = vi.fn() +const updateMonitorAgentMock = vi.fn() let searchState: Record = {} function isSecretSummaryRequest(path: string) { @@ -82,7 +85,10 @@ vi.mock('@/lib/pb', () => ({ vi.mock('@/lib/connect-api', () => ({ checkServerStatus: vi.fn(), getLocalDockerBridgeAddress: (...args: unknown[]) => getLocalDockerBridgeAddressMock(...args), + getSystemdStatus: (...args: unknown[]) => getSystemdStatusMock(...args), + installMonitorAgent: (...args: unknown[]) => installMonitorAgentMock(...args), serverPower: vi.fn(), + updateMonitorAgent: (...args: unknown[]) => updateMonitorAgentMock(...args), })) vi.mock('@/contexts/AuthContext', () => ({ @@ -104,6 +110,9 @@ describe('ServersPage layout', () => { getSecretMock.mockReset() updateSecretMock.mockReset() getLocalDockerBridgeAddressMock.mockReset() + getSystemdStatusMock.mockReset() + installMonitorAgentMock.mockReset() + updateMonitorAgentMock.mockReset() searchState = {} sendMock.mockImplementation((path: string) => { if (path === '/api/servers/connection') { @@ -211,6 +220,14 @@ describe('ServersPage layout', () => { }) updateSecretMock.mockResolvedValue({}) getLocalDockerBridgeAddressMock.mockResolvedValue('172.17.0.1') + getSystemdStatusMock.mockResolvedValue({ + server_id: 'server-1', + service: 'netdata', + status: {}, + status_text: '', + }) + installMonitorAgentMock.mockResolvedValue({ status: 'installed' }) + updateMonitorAgentMock.mockResolvedValue({ status: 'updated' }) }) afterEach(() => { @@ -367,7 +384,7 @@ describe('ServersPage layout', () => { await waitFor(() => { expect(screen.queryByRole('columnheader', { name: 'Monitor' })).toBeNull() }) - }, 15000) + }, 25000) it('renders the unified Connection column and lifecycle primary actions', async () => { sendMock.mockImplementation((path: string) => { @@ -911,7 +928,7 @@ describe('ServersPage layout', () => { method: 'PUT', body: { payload: { value: 'new-pass' } }, }) - }, 10000) + }, 15000) it('renders connection type as cards, pre-fills a generated name, and uses the simplified credential action', async () => { render() diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 12447a6b..07ad0a13 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -13,5 +13,6 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.test.{ts,tsx}'], + testTimeout: 10000, }, }) From e054922775540859ff263fb9cccb306101178764 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 09:40:48 +0000 Subject: [PATCH 2/4] fix web test --- backend/domain/routes/deploy.go | 6 +- backend/domain/routes/deploy_test.go | 51 ++++++++++++ backend/domain/routes/docker.go | 2 + backend/domain/routes/docker_test.go | 71 ++++++++++++++++ backend/domain/routes/routes.go | 1 - backend/domain/routes/server.go | 81 ++++++++++++++++++- backend/domain/routes/server_test.go | 34 ++++++++ backend/infra/docker/ssh.go | 72 ++++++++++++++++- backend/infra/docker/ssh_test.go | 65 +++++++++++++++ web/src/pages/apps/AppDetailPage.test.tsx | 6 +- .../pages/deploy/actions/action-utils.test.ts | 39 +++++++++ web/src/pages/overview/OverviewPage.test.tsx | 26 +++--- web/src/pages/system/MonitorOverview.test.tsx | 10 +-- web/src/pages/system/TunnelsPage.test.tsx | 6 +- 14 files changed, 439 insertions(+), 31 deletions(-) create mode 100644 backend/infra/docker/ssh_test.go create mode 100644 web/src/pages/deploy/actions/action-utils.test.ts diff --git a/backend/domain/routes/deploy.go b/backend/domain/routes/deploy.go index a8b095b0..b458eb40 100644 --- a/backend/domain/routes/deploy.go +++ b/backend/domain/routes/deploy.go @@ -29,13 +29,17 @@ func registerOperationRoutes(g *router.RouterGroup[*core.RequestEvent]) { o.DELETE("/{id}", handleOperationDelete) o.POST("/{id}/cancel", handleOperationCancel) o.GET("/{id}/logs", handleOperationLogs) - o.GET("/{id}/stream", handleOperationLogStream) o.POST("/install/name-availability", handleOperationInstallNameAvailability) o.POST("/install/git-compose", handleOperationInstallGitCompose) o.POST("/install/manual-compose", handleOperationInstallManualCompose) o.POST("/install/git-compose/check", handleOperationInstallGitComposeCheck) o.POST("/install/manual-compose/check", handleOperationInstallManualComposeCheck) + stream := g.Group("/actions") + stream.Bind(wsTokenAuth()) + stream.Bind(apis.RequireSuperuserAuth()) + stream.GET("/{id}/stream", handleOperationLogStream) + p := g.Group("/pipelines") p.Bind(apis.RequireSuperuserAuth()) p.GET("", handlePipelineList) diff --git a/backend/domain/routes/deploy_test.go b/backend/domain/routes/deploy_test.go index 79db581d..94214688 100644 --- a/backend/domain/routes/deploy_test.go +++ b/backend/domain/routes/deploy_test.go @@ -49,6 +49,57 @@ func (te *testEnv) doOperations(t *testing.T, method, url, body string, authenti return rec } +func (te *testEnv) doRegisteredRoute(t *testing.T, method, url, body string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + + r, err := apis.NewRouter(te.app) + if err != nil { + t.Fatal(err) + } + + Register(&core.ServeEvent{App: te.app, Router: r}) + + mux, err := r.BuildMux() + if err != nil { + t.Fatal(err) + } + + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + + req := httptest.NewRequest(method, url, bodyReader) + req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestRegisterRejectsQueryTokenForPlainHTTPAPI(t *testing.T) { + te := newTestEnv(t) + defer te.cleanup() + + rec := te.doRegisteredRoute(t, http.MethodGet, "/api/catalog/categories?token="+te.token, "", nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for plain HTTP query-token auth, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestOperationLogStreamAllowsQueryTokenAuth(t *testing.T) { + te := newTestEnv(t) + defer te.cleanup() + + rec := te.doOperations(t, http.MethodGet, "/api/actions/missing-id/stream?token="+te.token, "", false) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 after query-token auth reached stream handler, got %d: %s", rec.Code, rec.Body.String()) + } +} + func TestOperationManualComposeCreateListDetail(t *testing.T) { te := newTestEnv(t) defer te.cleanup() diff --git a/backend/domain/routes/docker.go b/backend/domain/routes/docker.go index 09bfd8d4..00d8453b 100644 --- a/backend/domain/routes/docker.go +++ b/backend/domain/routes/docker.go @@ -7,6 +7,7 @@ import ( "strconv" "sync" + "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/router" "github.com/websoft9/appos/backend/domain/audit" @@ -38,6 +39,7 @@ func init() { // /api/ext/docker/volumes/* — volume management func registerDockerRoutes(g *router.RouterGroup[*core.RequestEvent]) { d := g.Group("/docker") + d.Bind(apis.RequireSuperuserAuth()) // ─── Servers list ─────────────────────────────────── d.GET("/servers", handleDockerServers) diff --git a/backend/domain/routes/docker_test.go b/backend/domain/routes/docker_test.go index 33a4d69d..83050d18 100644 --- a/backend/domain/routes/docker_test.go +++ b/backend/domain/routes/docker_test.go @@ -1,8 +1,13 @@ package routes import ( + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" servers "github.com/websoft9/appos/backend/domain/resource/servers" ) @@ -57,3 +62,69 @@ func TestTunnelSSHPortFromServices(t *testing.T) { }) } } + +func doDocker(t *testing.T, te *testEnv, method, url, body, token string) *httptest.ResponseRecorder { + t.Helper() + + r, err := apis.NewRouter(te.app) + if err != nil { + t.Fatal(err) + } + + g := r.Group("/api/ext") + g.Bind(apis.RequireAuth()) + registerDockerRoutes(g) + + mux, err := r.BuildMux() + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(method, url, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", token) + } + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func createRegularUserToken(t *testing.T, te *testEnv) string { + t.Helper() + + usersCol, err := te.app.FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + user := core.NewRecord(usersCol) + user.Set("email", "user@test.com") + user.SetPassword("1234567890") + if err := te.app.Save(user); err != nil { + t.Fatal(err) + } + + token, err := user.NewStaticAuthToken(0) + if err != nil { + t.Fatal(err) + } + return token +} + +func TestDockerRoutesRequireSuperuser(t *testing.T) { + te := newTestEnv(t) + defer te.cleanup() + + userToken := createRegularUserToken(t, te) + + rec := doDocker(t, te, http.MethodGet, "/api/ext/docker/servers", "", userToken) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-superuser, got %d: %s", rec.Code, rec.Body.String()) + } + + rec = doDocker(t, te, http.MethodGet, "/api/ext/docker/servers", "", te.token) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for superuser, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/domain/routes/routes.go b/backend/domain/routes/routes.go index 99381dd4..6fcc0337 100644 --- a/backend/domain/routes/routes.go +++ b/backend/domain/routes/routes.go @@ -71,7 +71,6 @@ func Register(se *core.ServeEvent) { components.Bind(apis.RequireAuth()) deployments := se.Router.Group("/api") - deployments.Bind(wsTokenAuth()) deployments.Bind(apis.RequireAuth()) // Server catalog routes (ops, ports, systemd) — no terminal sessions diff --git a/backend/domain/routes/server.go b/backend/domain/routes/server.go index f78179f4..bcb5da41 100644 --- a/backend/domain/routes/server.go +++ b/backend/domain/routes/server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "strings" "github.com/gorilla/websocket" @@ -16,8 +17,7 @@ import ( ) var wsUpgrader = websocket.Upgrader{ - // TODO: validate Origin header for production CSRF protection. - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: allowWebSocketOrigin, } var dockerBridgeIPv4Lookup = netutil.LookupInterfaceIPv4 @@ -69,6 +69,83 @@ func wsTokenAuth() *hook.Handler[*core.RequestEvent] { } } +func allowWebSocketOrigin(r *http.Request) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return false + } + requestScheme := resolveWebSocketHTTPScheme(r) + if !strings.EqualFold(parsed.Scheme, requestScheme) { + return false + } + return sameWebSocketOriginHost(parsed.Host, resolveWebSocketHTTPHost(r), requestScheme) +} + +func resolveWebSocketHTTPScheme(r *http.Request) string { + if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") || r.TLS != nil { + return "https" + } + return "http" +} + +func resolveWebSocketHTTPHost(r *http.Request) string { + host := firstForwardedHostValue(r.Host) + forwardedHost := firstForwardedHostValue(r.Header.Get("X-Forwarded-Host")) + if host == "" { + host = forwardedHost + } + if forwardedHost != "" && forwardedHostCarriesPort(host, forwardedHost) { + host = forwardedHost + } + if !hostHasExplicitPort(host) { + if forwardedPort := firstForwardedPortValue(r.Header.Get("X-Forwarded-Port")); forwardedPort != "" { + host = appendPortIfMissing(host, forwardedPort) + } + } + return host +} + +func sameWebSocketOriginHost(originHost string, requestHost string, scheme string) bool { + if !strings.EqualFold(stripOptionalPort(originHost), stripOptionalPort(requestHost)) { + return false + } + return effectivePort(originHost, scheme) == effectivePort(requestHost, scheme) +} + +func effectivePort(host string, scheme string) string { + if host == "" { + return defaultPortForScheme(scheme) + } + if strings.HasPrefix(host, "[") { + if idx := strings.LastIndex(host, "]:"); idx >= 0 { + return host[idx+2:] + } + return defaultPortForScheme(scheme) + } + idx := strings.LastIndex(host, ":") + if idx <= 0 || strings.Contains(host[:idx], ":") { + return defaultPortForScheme(scheme) + } + port := host[idx+1:] + for _, ch := range port { + if ch < '0' || ch > '9' { + return defaultPortForScheme(scheme) + } + } + return port +} + +func defaultPortForScheme(scheme string) string { + if strings.EqualFold(strings.TrimSpace(scheme), "https") { + return "443" + } + return "80" +} + // registerServerRoutes registers server catalog/ops routes (non-terminal). // These handle connectivity checks, power, ports, and systemd operations. func registerServerRoutes(g *router.RouterGroup[*core.RequestEvent]) { diff --git a/backend/domain/routes/server_test.go b/backend/domain/routes/server_test.go index bfa1b27f..5e61e378 100644 --- a/backend/domain/routes/server_test.go +++ b/backend/domain/routes/server_test.go @@ -52,6 +52,40 @@ func TestLocalDockerBridgeRequiresAuth(t *testing.T) { } } +func TestAllowWebSocketOriginAllowsEmptyOrigin(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://console.example.com/api/actions/demo/stream", nil) + if !allowWebSocketOrigin(req) { + t.Fatal("expected empty origin to be allowed") + } +} + +func TestAllowWebSocketOriginAllowsSameOrigin(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://console.example.com/api/actions/demo/stream", nil) + req.Header.Set("Origin", "https://console.example.com") + if !allowWebSocketOrigin(req) { + t.Fatal("expected same origin to be allowed") + } +} + +func TestAllowWebSocketOriginRejectsCrossOrigin(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://console.example.com/api/actions/demo/stream", nil) + req.Header.Set("Origin", "https://evil.example.com") + if allowWebSocketOrigin(req) { + t.Fatal("expected cross origin to be rejected") + } +} + +func TestAllowWebSocketOriginUsesForwardedProxyHostAndProto(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://internal.example.local/api/actions/demo/stream", nil) + req.Host = "console.example.com" + req.Header.Set("X-Forwarded-Host", "console.example.com:9443") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("Origin", "https://console.example.com:9443") + if !allowWebSocketOrigin(req) { + t.Fatal("expected forwarded proxy origin to be allowed") + } +} + func TestLocalDockerBridgeReturnsAddress(t *testing.T) { te := newTestEnv(t) defer te.cleanup() diff --git a/backend/infra/docker/ssh.go b/backend/infra/docker/ssh.go index ff824b88..e76dfe53 100644 --- a/backend/infra/docker/ssh.go +++ b/backend/infra/docker/ssh.go @@ -5,10 +5,13 @@ import ( "context" "fmt" "io" + "os" + "path/filepath" "strings" "time" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" ) // SSHConfig holds connection parameters for an SSH executor. @@ -55,10 +58,15 @@ func (e *SSHExecutor) clientConfig() (*ssh.ClientConfig, error) { authMethods = []ssh.AuthMethod{ssh.Password(e.cfg.Secret)} } + hostKeyCallback, err := resolveHostKeyCallback() + if err != nil { + return nil, err + } + return &ssh.ClientConfig{ User: e.cfg.User, Auth: authMethods, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // intentional for now + HostKeyCallback: hostKeyCallback, Timeout: 10 * time.Second, }, nil } @@ -86,7 +94,7 @@ func (e *SSHExecutor) Run(ctx context.Context, command string, args ...string) ( } defer session.Close() - cmd := strings.Join(append([]string{command}, args...), " ") + cmd := buildShellCommand(command, args...) if e.cfg.SudoEnabled { if e.cfg.SudoPassword != "" { // -S: read password from stdin; -p '': suppress prompt text @@ -132,7 +140,7 @@ func (e *SSHExecutor) RunStream(ctx context.Context, command string, args ...str return nil, fmt.Errorf("ssh session: %w", err) } - cmd := strings.Join(append([]string{command}, args...), " ") + cmd := buildShellCommand(command, args...) if e.cfg.SudoEnabled { if e.cfg.SudoPassword != "" { // -S: read password from stdin; -p '': suppress prompt text @@ -198,3 +206,61 @@ func (r *sshReadCloser) Close() error { _ = r.client.Close() return err } + +func buildShellCommand(command string, args ...string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, shellQuote(command)) + for _, arg := range args { + parts = append(parts, shellQuote(arg)) + } + return strings.Join(parts, " ") +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func resolveHostKeyCallback() (ssh.HostKeyCallback, error) { + knownHostsPath := strings.TrimSpace(os.Getenv("APPOS_SSH_KNOWN_HOSTS")) + candidates := make([]string, 0, 3) + if knownHostsPath != "" { + candidates = append(candidates, knownHostsPath) + } + if homeDir, err := os.UserHomeDir(); err == nil && homeDir != "" { + candidates = append(candidates, filepath.Join(homeDir, ".ssh", "known_hosts")) + } + candidates = append(candidates, "/etc/ssh/ssh_known_hosts") + + existing := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + existing = append(existing, candidate) + } + } + + if len(existing) > 0 { + callback, err := knownhosts.New(existing...) + if err != nil { + return nil, fmt.Errorf("load known_hosts: %w", err) + } + return callback, nil + } + + requireStrict := strings.ToLower(strings.TrimSpace(os.Getenv("APPOS_REQUIRE_SSH_HOST_KEY"))) + if requireStrict == "1" || requireStrict == "true" || requireStrict == "yes" { + return nil, fmt.Errorf("ssh host key verification required: no known_hosts file found (set by APPOS_REQUIRE_SSH_HOST_KEY)") + } + + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // fallback for environments without known_hosts +} diff --git a/backend/infra/docker/ssh_test.go b/backend/infra/docker/ssh_test.go new file mode 100644 index 00000000..f33f5356 --- /dev/null +++ b/backend/infra/docker/ssh_test.go @@ -0,0 +1,65 @@ +package docker + +import ( + "crypto/ed25519" + "crypto/rand" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +func TestBuildShellCommandQuotesArguments(t *testing.T) { + got := buildShellCommand("docker", "compose", "-f", "/srv/app dir/docker-compose.yml", "up;touch /tmp/pwn", "quo'te") + want := "'docker' 'compose' '-f' '/srv/app dir/docker-compose.yml' 'up;touch /tmp/pwn' 'quo'\\''te'" + if got != want { + t.Fatalf("unexpected shell command\nwant: %s\n got: %s", want, got) + } +} + +func TestResolveHostKeyCallbackStrictWithoutKnownHostsFails(t *testing.T) { + t.Setenv("APPOS_SSH_KNOWN_HOSTS", filepath.Join(t.TempDir(), "missing_known_hosts")) + t.Setenv("APPOS_REQUIRE_SSH_HOST_KEY", "true") + t.Setenv("HOME", t.TempDir()) + + _, err := resolveHostKeyCallback() + if err == nil { + t.Fatal("expected missing known_hosts to fail in strict mode") + } + if !strings.Contains(err.Error(), "ssh host key verification required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveHostKeyCallbackUsesConfiguredKnownHosts(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("APPOS_REQUIRE_SSH_HOST_KEY", "true") + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(privateKey) + if err != nil { + t.Fatal(err) + } + + knownHostsPath := filepath.Join(homeDir, "known_hosts") + line := knownhosts.Line([]string{"example.com"}, signer.PublicKey()) + if err := os.WriteFile(knownHostsPath, []byte(line+"\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("APPOS_SSH_KNOWN_HOSTS", knownHostsPath) + + callback, err := resolveHostKeyCallback() + if err != nil { + t.Fatalf("expected configured known_hosts to load, got %v", err) + } + if callback == nil { + t.Fatal("expected non-nil host key callback") + } +} \ No newline at end of file diff --git a/web/src/pages/apps/AppDetailPage.test.tsx b/web/src/pages/apps/AppDetailPage.test.tsx index fdf37d31..c86ef7dc 100644 --- a/web/src/pages/apps/AppDetailPage.test.tsx +++ b/web/src/pages/apps/AppDetailPage.test.tsx @@ -981,11 +981,11 @@ describe('AppDetailPage', () => { expect(sendMock).toHaveBeenCalledWith('/api/ext/backup/list', { method: 'GET' }) }) - expect(screen.getByText('demo-app-db')).toBeInTheDocument() + expect(await screen.findByText('demo-app-db')).toBeInTheDocument() expect(screen.queryByText('shared-platform-db')).not.toBeInTheDocument() - expect(screen.getByText('demo-app-data')).toBeInTheDocument() + expect(await screen.findByText('demo-app-data')).toBeInTheDocument() expect(screen.queryByText('shared-cache')).not.toBeInTheDocument() - expect(screen.getByText('Platform backup inventory is not connected yet.')).toBeInTheDocument() + expect(await screen.findByText('Platform backup inventory is not connected yet.')).toBeInTheDocument() await waitFor(() => { expect(sendMock).toHaveBeenCalledWith('/api/ext/docker/containers/container-1', { method: 'GET', diff --git a/web/src/pages/deploy/actions/action-utils.test.ts b/web/src/pages/deploy/actions/action-utils.test.ts new file mode 100644 index 00000000..3dfea517 --- /dev/null +++ b/web/src/pages/deploy/actions/action-utils.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildActionWebSocketUrl } from './action-utils' + +const { authStore } = vi.hoisted(() => ({ + authStore: { + token: '', + }, +})) + +vi.mock('@/lib/pb', () => ({ + pb: { + authStore, + }, +})) + +describe('buildActionWebSocketUrl', () => { + beforeEach(() => { + authStore.token = '' + window.history.replaceState({}, '', '/actions/demo') + }) + + it('uses the current browser host and protocol', () => { + const url = new URL(buildActionWebSocketUrl('act_123')) + + expect(url.protocol).toBe(window.location.protocol === 'https:' ? 'wss:' : 'ws:') + expect(url.host).toBe(window.location.host) + expect(url.pathname).toBe('/api/actions/act_123/stream') + expect(url.search).toBe('') + }) + + it('preserves the auth token as a query parameter for websocket routes', () => { + authStore.token = 'token-123' + + const url = new URL(buildActionWebSocketUrl('act_live')) + + expect(url.pathname).toBe('/api/actions/act_live/stream') + expect(url.searchParams.get('token')).toBe('token-123') + }) +}) \ No newline at end of file diff --git a/web/src/pages/overview/OverviewPage.test.tsx b/web/src/pages/overview/OverviewPage.test.tsx index 3368b6e2..ee6040f1 100644 --- a/web/src/pages/overview/OverviewPage.test.tsx +++ b/web/src/pages/overview/OverviewPage.test.tsx @@ -257,24 +257,24 @@ describe('OverviewPage', () => { 'Operational cockpit for AppOS health, current risks, and recent change across your single-server workspace.' ) ).not.toBeInTheDocument() - expect(screen.getByText('Applications')).toBeInTheDocument() - expect(screen.getByText('Attention Needed')).toBeInTheDocument() + expect(await screen.findByText('Applications')).toBeInTheDocument() + expect(await screen.findByText('Attention Needed')).toBeInTheDocument() expect(await screen.findByText('Needs Attention')).toBeInTheDocument() - expect(screen.getAllByText('AppOS Core').length).toBeGreaterThan(0) + expect((await screen.findAllByText('AppOS Core')).length).toBeGreaterThan(0) expect(await screen.findByText('AppOS Core Trends')).toBeInTheDocument() - expect(screen.getByLabelText('cpu time series chart')).toBeInTheDocument() - expect(screen.getByLabelText('memory time series chart')).toBeInTheDocument() - expect(screen.getByLabelText('disk_usage time series chart')).toBeInTheDocument() - expect(screen.getByLabelText('network time series chart')).toBeInTheDocument() - expect(screen.getByText('Recent App Changes')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /System Monitor/i })).toHaveAttribute('href', '/status') - expect(screen.getByRole('link', { name: /Manage Servers/i })).toHaveAttribute( + expect(await screen.findByLabelText('cpu time series chart')).toBeInTheDocument() + expect(await screen.findByLabelText('memory time series chart')).toBeInTheDocument() + expect(await screen.findByLabelText('disk_usage time series chart')).toBeInTheDocument() + expect(await screen.findByLabelText('network time series chart')).toBeInTheDocument() + expect(await screen.findByText('Recent App Changes')).toBeInTheDocument() + expect(await screen.findByRole('link', { name: /System Monitor/i })).toHaveAttribute('href', '/status') + expect(await screen.findByRole('link', { name: /Manage Servers/i })).toHaveAttribute( 'href', '/resources/servers' ) - expect(screen.getByRole('link', { name: /WordPress/i })).toHaveAttribute('href', '/apps/app-1') - expect(screen.getAllByRole('link', { name: /Deploy App/i }).length).toBeGreaterThan(0) - expect(screen.getAllByRole('link', { name: /Review Credentials/i }).length).toBeGreaterThan(0) + expect(await screen.findByRole('link', { name: /WordPress/i })).toHaveAttribute('href', '/apps/app-1') + expect((await screen.findAllByRole('link', { name: /Deploy App/i })).length).toBeGreaterThan(0) + expect((await screen.findAllByRole('link', { name: /Review Credentials/i })).length).toBeGreaterThan(0) await waitFor(() => { expect(sendMock).toHaveBeenCalledWith('/api/apps', { method: 'GET' }) diff --git a/web/src/pages/system/MonitorOverview.test.tsx b/web/src/pages/system/MonitorOverview.test.tsx index 5c02c817..60caac4d 100644 --- a/web/src/pages/system/MonitorOverview.test.tsx +++ b/web/src/pages/system/MonitorOverview.test.tsx @@ -105,12 +105,12 @@ describe('MonitorOverviewContent', () => { expect(await screen.findByText('Monitor Overview')).toBeInTheDocument() expect(screen.getAllByText('Offline').length).toBeGreaterThan(0) - expect(screen.getByText('prod-01')).toBeInTheDocument() - expect(screen.getAllByText('AppOS Core').length).toBeGreaterThan(0) - expect(screen.getByText(/Uptime Seconds: 1.0h/i)).toBeInTheDocument() + expect(await screen.findByText('prod-01')).toBeInTheDocument() + expect((await screen.findAllByText('AppOS Core')).length).toBeGreaterThan(0) + expect(await screen.findByText(/Uptime Seconds: 1.0h/i)).toBeInTheDocument() expect(await screen.findByText('Platform Detail')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'AppOS Core' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Scheduler' })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'AppOS Core' })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Scheduler' })).toBeInTheDocument() expect(sendMock).toHaveBeenCalledWith('/api/monitor/overview', { method: 'GET' }) await waitFor(() => { expect(sendMock).toHaveBeenCalledWith('/api/monitor/targets/platform/appos-core', { diff --git a/web/src/pages/system/TunnelsPage.test.tsx b/web/src/pages/system/TunnelsPage.test.tsx index 41422547..5f0535a8 100644 --- a/web/src/pages/system/TunnelsPage.test.tsx +++ b/web/src/pages/system/TunnelsPage.test.tsx @@ -190,8 +190,8 @@ describe('TunnelsPage', () => { }) expect(await screen.findByText('Reconnect')).toBeInTheDocument() - expect(screen.getByText('Rejected while paused')).toBeInTheDocument() - expect(screen.getByText('Pause until')).toBeInTheDocument() + expect(await screen.findByText('Rejected while paused')).toBeInTheDocument() + expect(await screen.findByText('Pause until')).toBeInTheDocument() expect(screen.getAllByText('Reason').length).toBe(1) expect(screen.getAllByText('Remote').length).toBeGreaterThan(0) expect(screen.queryByText('Reason: —')).not.toBeInTheDocument() @@ -265,7 +265,7 @@ describe('TunnelsPage', () => { }) expect(await screen.findByText('Desired Forwards')).toBeInTheDocument() - expect(screen.getByText('Effective Mappings')).toBeInTheDocument() + expect(await screen.findByText('Effective Mappings')).toBeInTheDocument() const localPortInputs = screen.getAllByPlaceholderText('local port') fireEvent.change(localPortInputs[1], { target: { value: '4000' } }) From 67be486ba9d9dd2516a47101855cb2eab1c8b38e Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 03:14:20 +0000 Subject: [PATCH 3/4] fix test backend --- backend/domain/certs/hooks.go | 51 +- backend/domain/certs/hooks_expiry_test.go | 45 +- backend/domain/certs/hooks_test.go | 49 +- backend/domain/certs/resolve.go | 15 +- backend/domain/certs/resolve_test.go | 104 ++--- backend/domain/certs/test_fixture_test.go | 56 +++ backend/domain/config/sharedenv/query_test.go | 36 +- .../config/sharedenv/test_fixture_test.go | 56 +++ .../lifecycle/orchestration/runner_test.go | 7 +- .../orchestration/test_fixture_test.go | 40 ++ .../lifecycle/projection/updater_test.go | 83 +--- .../monitor/signals/agent/agent_test.go | 23 +- .../signals/agent/test_fixture_test.go | 50 ++ .../signals/checks/credential_sweep_test.go | 18 +- .../signals/checks/test_fixture_test.go | 50 ++ .../domain/monitor/signals/platform/model.go | 2 + .../monitor/signals/platform/observer.go | 10 +- .../monitor/signals/platform/observer_test.go | 28 +- .../signals/platform/test_fixture_test.go | 50 ++ backend/domain/monitor/status/query.go | 12 +- .../monitor/status/query_internal_test.go | 28 +- backend/domain/monitor/status/query_test.go | 26 +- .../domain/monitor/status/store/projection.go | 6 +- .../domain/monitor/status/store/store_test.go | 53 +-- .../monitor/status/store/test_fixture_test.go | 50 ++ .../resource/connectors/runtime_test.go | 6 +- .../resource/connectors/test_fixture_test.go | 40 ++ backend/domain/routes/resources_test.go | 62 ++- backend/domain/software/runtime_bindings.go | 4 + .../domain/software/runtime_bindings_test.go | 34 +- backend/domain/worker/appos_agent_bindings.go | 42 +- .../worker/appos_agent_bindings_test.go | 58 +-- .../worker/lifecycle_operations_test.go | 27 +- .../domain/worker/monitoring_checks_test.go | 27 +- .../domain/worker/software_delivery_test.go | 31 +- backend/domain/worker/test_fixture_test.go | 102 ++++ backend/domain/worker/worker_test.go | 30 +- backend/infra/migrations/migrations_test.go | 206 ++------- backend/infra/migrations/test_fixture_test.go | 88 ++++ .../persistence/connector_repository_test.go | 25 +- .../persistence/instance_repository_test.go | 13 +- .../provider_account_repository_test.go | 19 +- .../infra/persistence/test_fixture_test.go | 85 ++++ tests/README.md | 47 +- .../monitor/MonitorTargetPanel.test.tsx | 6 + web/src/pages/apps/AppDetailPage.test.tsx | 43 +- .../deploy/CreateDeploymentPage.test.tsx | 90 ++++ .../pages/system/PlatformStatusPage.test.tsx | 435 +++++++++--------- .../_app/_auth/resources/-servers.test.tsx | 56 +++ .../resources/-service-instances.test.tsx | 47 ++ 50 files changed, 1574 insertions(+), 997 deletions(-) create mode 100644 backend/domain/certs/test_fixture_test.go create mode 100644 backend/domain/config/sharedenv/test_fixture_test.go create mode 100644 backend/domain/lifecycle/orchestration/test_fixture_test.go create mode 100644 backend/domain/monitor/signals/agent/test_fixture_test.go create mode 100644 backend/domain/monitor/signals/checks/test_fixture_test.go create mode 100644 backend/domain/monitor/signals/platform/test_fixture_test.go create mode 100644 backend/domain/monitor/status/store/test_fixture_test.go create mode 100644 backend/domain/resource/connectors/test_fixture_test.go create mode 100644 backend/domain/worker/test_fixture_test.go create mode 100644 backend/infra/migrations/test_fixture_test.go create mode 100644 backend/infra/persistence/test_fixture_test.go diff --git a/backend/domain/certs/hooks.go b/backend/domain/certs/hooks.go index 697d50d2..e228ffaf 100644 --- a/backend/domain/certs/hooks.go +++ b/backend/domain/certs/hooks.go @@ -155,16 +155,27 @@ func RegisterHooks(app *pocketbase.PocketBase) { } func validatePrivateKeySecretRef(app core.App, secretID, userID string) error { + return validatePrivateKeySecretRefWith(secretID, userID, + func(secretID, userID string) error { + return secrets.ValidateRef(app, secretID, userID) + }, + func(secretID string) (*core.Record, error) { + return app.FindRecordById("secrets", secretID) + }, + ) +} + +func validatePrivateKeySecretRefWith(secretID, userID string, validateRef func(string, string) error, findSecret func(string) (*core.Record, error)) error { secretID = strings.TrimSpace(secretID) if secretID == "" { return nil } - if err := secrets.ValidateRef(app, secretID, userID); err != nil { + if err := validateRef(secretID, userID); err != nil { return fmt.Errorf("invalid private key secret") } - rec, err := app.FindRecordById("secrets", secretID) + rec, err := findSecret(secretID) if err != nil { return fmt.Errorf("invalid private key secret") } @@ -201,21 +212,20 @@ func runExpirySweepLoop(app core.App, stop <-chan struct{}) { } func expireDueCertificates(app core.App) error { - now := time.Now().UTC().Format(time.RFC3339) + now := time.Now().UTC() records, err := app.FindRecordsByFilter( "certificates", - "status = 'active' && expires_at != '' && expires_at <= {:now}", + "status = 'active' && expires_at != ''", "", 200, 0, - map[string]any{"now": now}, + nil, ) if err != nil { return err } - for _, record := range records { - record.Set("status", "expired") + for _, record := range markExpiredCertificates(records, now) { if saveErr := app.Save(record); saveErr != nil { log.Printf("[WARN] certs: failed to mark certificate %s expired: %v", record.Id, saveErr) } @@ -224,6 +234,33 @@ func expireDueCertificates(app core.App) error { return nil } +func markExpiredCertificates(records []*core.Record, now time.Time) []*core.Record { + expired := make([]*core.Record, 0, len(records)) + for _, record := range records { + if !certificateShouldExpire(record, now) { + continue + } + record.Set("status", "expired") + expired = append(expired, record) + } + return expired +} + +func certificateShouldExpire(record *core.Record, now time.Time) bool { + if record == nil || record.GetString("status") != "active" { + return false + } + expiresAt := strings.TrimSpace(record.GetString("expires_at")) + if expiresAt == "" { + return false + } + parsed, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return false + } + return !parsed.After(now) +} + // checkAndExpire updates status to "expired" if expires_at is in the past // and status is still "active". The save is async to avoid blocking the response. func actorID(auth *core.Record) string { diff --git a/backend/domain/certs/hooks_expiry_test.go b/backend/domain/certs/hooks_expiry_test.go index 7ac0d79e..749d4914 100644 --- a/backend/domain/certs/hooks_expiry_test.go +++ b/backend/domain/certs/hooks_expiry_test.go @@ -5,25 +5,15 @@ import ( "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestExpireDueCertificates(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - col, err := app.FindCollectionByNameOrId("certificates") - if err != nil { - t.Fatal(err) - } + col := core.NewBaseCollection("certificates") + now := time.Now().UTC() newCert := func(name, status string, expiresAt time.Time) *core.Record { rec := core.NewRecord(col) + rec.Id = name rec.Set("name", name) rec.Set("domain", "test.example.com") rec.Set("kind", "self_signed") @@ -31,35 +21,26 @@ func TestExpireDueCertificates(t *testing.T) { if !expiresAt.IsZero() { rec.Set("expires_at", expiresAt.UTC().Format(time.RFC3339)) } - if err := app.Save(rec); err != nil { - t.Fatalf("save certificate %s: %v", name, err) - } return rec } - expiredActive := newCert("expired-active", "active", time.Now().Add(-2*time.Hour)) - futureActive := newCert("future-active", "active", time.Now().Add(48*time.Hour)) - alreadyExpired := newCert("already-expired", "expired", time.Now().Add(-24*time.Hour)) + expiredActive := newCert("expired-active", "active", now.Add(-2*time.Hour)) + futureActive := newCert("future-active", "active", now.Add(48*time.Hour)) + alreadyExpired := newCert("already-expired", "expired", now.Add(-24*time.Hour)) noExpires := newCert("no-expires", "active", time.Time{}) - if err := expireDueCertificates(app); err != nil { - t.Fatalf("expireDueCertificates returned error: %v", err) - } + markExpiredCertificates([]*core.Record{expiredActive, futureActive, alreadyExpired, noExpires}, now) - assertStatus := func(id, want string) { + assertStatus := func(rec *core.Record, want string) { t.Helper() - rec, findErr := app.FindRecordById("certificates", id) - if findErr != nil { - t.Fatalf("find certificate %s: %v", id, findErr) - } got := rec.GetString("status") if got != want { - t.Fatalf("status mismatch for %s: want %q, got %q", id, want, got) + t.Fatalf("status mismatch for %s: want %q, got %q", rec.Id, want, got) } } - assertStatus(expiredActive.Id, "expired") - assertStatus(futureActive.Id, "active") - assertStatus(alreadyExpired.Id, "expired") - assertStatus(noExpires.Id, "active") + assertStatus(expiredActive, "expired") + assertStatus(futureActive, "active") + assertStatus(alreadyExpired, "expired") + assertStatus(noExpires, "active") } diff --git a/backend/domain/certs/hooks_test.go b/backend/domain/certs/hooks_test.go index 1055a555..22a9e0e5 100644 --- a/backend/domain/certs/hooks_test.go +++ b/backend/domain/certs/hooks_test.go @@ -1,56 +1,47 @@ package certs import ( + "errors" "testing" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestValidatePrivateKeySecretRef(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + secretCol := core.NewBaseCollection("secrets") - secretCol, err := app.FindCollectionByNameOrId("secrets") - if err != nil { - t.Fatal(err) - } - - newSecret := func(name, templateID, scope, createdBy, status string) string { + newSecret := func(name, templateID, status string) *core.Record { t.Helper() rec := core.NewRecord(secretCol) + rec.Id = name rec.Set("name", name) rec.Set("template_id", templateID) - rec.Set("scope", scope) - rec.Set("created_by", createdBy) rec.Set("status", status) - if err := app.Save(rec); err != nil { - t.Fatalf("save secret %s: %v", name, err) - } - return rec.Id + return rec } t.Run("allows empty relation", func(t *testing.T) { - if err := validatePrivateKeySecretRef(app, "", "user-1"); err != nil { + if err := validatePrivateKeySecretRefWith("", "user-1", func(string, string) error { return nil }, func(string) (*core.Record, error) { + return nil, nil + }); err != nil { t.Fatalf("expected nil error, got %v", err) } }) t.Run("accepts accessible tls private key secret", func(t *testing.T) { - secretID := newSecret("tls-key", "tls_private_key", "global", "owner-1", "active") - if err := validatePrivateKeySecretRef(app, secretID, "user-1"); err != nil { + secret := newSecret("tls-key", "tls_private_key", "active") + if err := validatePrivateKeySecretRefWith(secret.Id, "user-1", func(string, string) error { return nil }, func(string) (*core.Record, error) { + return secret, nil + }); err != nil { t.Fatalf("expected nil error, got %v", err) } }) t.Run("rejects non tls-private-key secret", func(t *testing.T) { - secretID := newSecret("token", "single_value", "global", "owner-1", "active") - err := validatePrivateKeySecretRef(app, secretID, "user-1") + secret := newSecret("token", "single_value", "active") + err := validatePrivateKeySecretRefWith(secret.Id, "user-1", func(string, string) error { return nil }, func(string) (*core.Record, error) { + return secret, nil + }) if err == nil { t.Fatal("expected error for non-tls-private-key secret") } @@ -60,8 +51,12 @@ func TestValidatePrivateKeySecretRef(t *testing.T) { }) t.Run("rejects inaccessible private secret", func(t *testing.T) { - secretID := newSecret("private-tls-key", "tls_private_key", "user_private", "owner-1", "active") - err := validatePrivateKeySecretRef(app, secretID, "other-user") + secret := newSecret("private-tls-key", "tls_private_key", "active") + err := validatePrivateKeySecretRefWith(secret.Id, "other-user", func(string, string) error { + return errors.New("denied") + }, func(string) (*core.Record, error) { + return secret, nil + }) if err == nil { t.Fatal("expected error for inaccessible secret") } diff --git a/backend/domain/certs/resolve.go b/backend/domain/certs/resolve.go index c67fcf7e..b2c393e9 100644 --- a/backend/domain/certs/resolve.go +++ b/backend/domain/certs/resolve.go @@ -28,11 +28,22 @@ var ( // // callerID is used only for audit/logging; pass "" for system calls. func ResolveCertificate(app core.App, certID string, callerID string) (*CertMaterial, error) { + return resolveCertificateWith(certID, callerID, + func(certID string) (*core.Record, error) { + return app.FindRecordById("certificates", certID) + }, + func(secretID, callerID string) (*secrets.ResolveResult, error) { + return secrets.Resolve(app, secretID, callerID) + }, + ) +} + +func resolveCertificateWith(certID string, callerID string, findCertificate func(string) (*core.Record, error), resolveSecret func(string, string) (*secrets.ResolveResult, error)) (*CertMaterial, error) { if strings.TrimSpace(callerID) == "" { callerID = secrets.CreatedSourceSystem } - record, err := app.FindRecordById("certificates", certID) + record, err := findCertificate(certID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrCertNotFound @@ -52,7 +63,7 @@ func ResolveCertificate(app core.App, certID string, callerID string) (*CertMate keyPEM := "" secretID := getPrivateKeySecretID(record) if secretID != "" { - result, err := secrets.Resolve(app, secretID, callerID) + result, err := resolveSecret(secretID, callerID) if err != nil { return nil, fmt.Errorf("resolving private key secret: %w", err) } diff --git a/backend/domain/certs/resolve_test.go b/backend/domain/certs/resolve_test.go index b5f00b97..e7c6d294 100644 --- a/backend/domain/certs/resolve_test.go +++ b/backend/domain/certs/resolve_test.go @@ -1,88 +1,59 @@ -package certs_test +package certs import ( - "encoding/base64" + "database/sql" "testing" - "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" - - // trigger init() migration registrations - _ "github.com/websoft9/appos/backend/infra/migrations" - - "github.com/websoft9/appos/backend/domain/certs" "github.com/websoft9/appos/backend/domain/secrets" ) func TestResolveCertificate(t *testing.T) { - key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) - t.Setenv(secrets.EnvSecretKey, key) - if err := secrets.LoadKeyFromEnv(); err != nil { - t.Fatalf("load secret key: %v", err) - } - - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - // Create a minimal active certificate record with cert_pem - certPEM, _, err := certs.GenerateSelfSigned("test.example.com", 2048, 365) + certPEM, _, err := GenerateSelfSigned("test.example.com", 2048, 365) if err != nil { t.Fatal("failed to generate self-signed cert:", err) } - col, err := app.FindCollectionByNameOrId("certificates") - if err != nil { - t.Fatal(err) - } + col := core.NewBaseCollection("certificates") + records := map[string]*core.Record{} + secretsByID := map[string]*secrets.ResolveResult{} newRecord := func(name, status, pem string) *core.Record { rec := core.NewRecord(col) + rec.Id = name rec.Set("name", name) rec.Set("domain", "test.example.com") rec.Set("kind", "self_signed") rec.Set("status", status) rec.Set("cert_pem", pem) - rec.Set("expires_at", time.Now().Add(365*24*time.Hour).Format(time.RFC3339)) - if err := app.Save(rec); err != nil { - t.Fatalf("save record: %v", err) - } + records[rec.Id] = rec return rec } newSecret := func(name string, payload map[string]any) string { - secretCol, err := app.FindCollectionByNameOrId("secrets") - if err != nil { - t.Fatalf("find secrets collection: %v", err) - } - rec := core.NewRecord(secretCol) - rec.Set("name", name) - rec.Set("template_id", "tls_private_key") - rec.Set("scope", "global") - rec.Set("access_mode", "use_only") - rec.Set("status", "active") - rec.Set("created_by", "") - rec.Set("version", 1) - - enc, err := secrets.EncryptPayload(payload) - if err != nil { - t.Fatalf("encrypt payload: %v", err) - } - rec.Set("payload_encrypted", enc) + secretsByID[name] = &secrets.ResolveResult{Payload: payload} + return name + } - if err := app.Save(rec); err != nil { - t.Fatalf("save secret: %v", err) + findCertificate := func(certID string) (*core.Record, error) { + rec, ok := records[certID] + if !ok { + return nil, sql.ErrNoRows } + return rec, nil + } - return rec.Id + resolveSecret := func(secretID, callerID string) (*secrets.ResolveResult, error) { + result, ok := secretsByID[secretID] + if !ok { + return nil, sql.ErrNoRows + } + return result, nil } t.Run("happy path — active with cert_pem, no key", func(t *testing.T) { rec := newRecord("test-resolve-active", "active", certPEM) - mat, err := certs.ResolveCertificate(app, rec.Id, "") + mat, err := resolveCertificateWith(rec.Id, "", findCertificate, resolveSecret) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -101,11 +72,8 @@ func TestResolveCertificate(t *testing.T) { secretID := newSecret("test-resolve-key", map[string]any{"private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"}) rec := newRecord("test-resolve-with-key", "active", certPEM) rec.Set("private_key_secret", secretID) - if err := app.Save(rec); err != nil { - t.Fatalf("save record with private_key_secret: %v", err) - } - mat, err := certs.ResolveCertificate(app, rec.Id, "") + mat, err := resolveCertificateWith(rec.Id, "", findCertificate, resolveSecret) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -118,11 +86,8 @@ func TestResolveCertificate(t *testing.T) { secretID := newSecret("test-resolve-legacy-key", map[string]any{"private_key": "-----BEGIN PRIVATE KEY-----\nlegacy\n-----END PRIVATE KEY-----"}) rec := newRecord("test-resolve-legacy-key", "active", certPEM) rec.Set("key", secretID) - if err := app.Save(rec); err != nil { - t.Fatalf("save record with legacy key: %v", err) - } - mat, err := certs.ResolveCertificate(app, rec.Id, "") + mat, err := resolveCertificateWith(rec.Id, "", findCertificate, resolveSecret) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -135,11 +100,8 @@ func TestResolveCertificate(t *testing.T) { secretID := newSecret("test-resolve-missing-private-key", map[string]any{"value": "x"}) rec := newRecord("test-resolve-bad-key", "active", certPEM) rec.Set("private_key_secret", secretID) - if err := app.Save(rec); err != nil { - t.Fatalf("save record with private_key_secret: %v", err) - } - _, err := certs.ResolveCertificate(app, rec.Id, "") + _, err := resolveCertificateWith(rec.Id, "", findCertificate, resolveSecret) if err == nil { t.Fatal("expected error when private_key is missing") } @@ -147,23 +109,23 @@ func TestResolveCertificate(t *testing.T) { t.Run("not active — expired returns ErrCertNotActive", func(t *testing.T) { rec := newRecord("test-resolve-expired", "expired", certPEM) - _, err := certs.ResolveCertificate(app, rec.Id, "") - if err != certs.ErrCertNotActive { + _, err := resolveCertificateWith(rec.Id, "", findCertificate, resolveSecret) + if err != ErrCertNotActive { t.Errorf("expected ErrCertNotActive, got %v", err) } }) t.Run("not ready — active but no cert_pem returns ErrCertNotReady", func(t *testing.T) { rec := newRecord("test-resolve-nokey", "active", "") - _, err := certs.ResolveCertificate(app, rec.Id, "") - if err != certs.ErrCertNotReady { + _, err := resolveCertificateWith(rec.Id, "", findCertificate, resolveSecret) + if err != ErrCertNotReady { t.Errorf("expected ErrCertNotReady, got %v", err) } }) t.Run("not found — unknown ID returns ErrCertNotFound", func(t *testing.T) { - _, err := certs.ResolveCertificate(app, "nonexistent000000", "") - if err != certs.ErrCertNotFound { + _, err := resolveCertificateWith("nonexistent000000", "", findCertificate, resolveSecret) + if err != ErrCertNotFound { t.Errorf("expected ErrCertNotFound, got %v", err) } }) diff --git a/backend/domain/certs/test_fixture_test.go b/backend/domain/certs/test_fixture_test.go new file mode 100644 index 00000000..d5555030 --- /dev/null +++ b/backend/domain/certs/test_fixture_test.go @@ -0,0 +1,56 @@ +package certs + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + certsTestBaselineOnce sync.Once + certsTestBaselineDir string + certsTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if certsTestBaselineDir != "" { + _ = os.RemoveAll(certsTestBaselineDir) + } + os.Exit(code) +} + +func certsTestBaselineDataDir() (string, error) { + certsTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + certsTestBaselineErr = err + return + } + + certsTestBaselineDir = app.DataDir() + certsTestBaselineErr = app.ResetBootstrapState() + }) + + return certsTestBaselineDir, certsTestBaselineErr +} + +func newCertsTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + baselineDir, err := certsTestBaselineDataDir() + if err != nil { + t.Fatal(err) + } + + app, err := tests.NewTestApp(baselineDir) + if err != nil { + t.Fatal(err) + } + + return app +} diff --git a/backend/domain/config/sharedenv/query_test.go b/backend/domain/config/sharedenv/query_test.go index 17569a78..ceda2524 100644 --- a/backend/domain/config/sharedenv/query_test.go +++ b/backend/domain/config/sharedenv/query_test.go @@ -5,11 +5,8 @@ import ( "testing" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/config/sharedenv" "github.com/websoft9/appos/backend/domain/secrets" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestGetSetAndListVars(t *testing.T) { @@ -19,12 +16,11 @@ func TestGetSetAndListVars(t *testing.T) { t.Fatalf("load secret key: %v", err) } - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newSharedEnvTestApp(t) defer app.Cleanup() + err := error(nil) + setCol, err := app.FindCollectionByNameOrId(sharedenv.SetCollection) if err != nil { t.Fatalf("find env_sets collection: %v", err) @@ -113,12 +109,11 @@ func TestGetSetAndListVars(t *testing.T) { } func TestFindVarSupportsQuotedKeyAndRejectsInconsistentLookup(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newSharedEnvTestApp(t) defer app.Cleanup() + err := error(nil) + setCol, err := app.FindCollectionByNameOrId(sharedenv.SetCollection) if err != nil { t.Fatalf("find env_sets collection: %v", err) @@ -164,17 +159,7 @@ func TestFindVarSupportsQuotedKeyAndRejectsInconsistentLookup(t *testing.T) { } func TestAttachedSetIDs(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - appCol, err := app.FindCollectionByNameOrId("apps") - if err != nil { - t.Fatalf("find apps collection: %v", err) - } - record := core.NewRecord(appCol) + record := core.NewRecord(&core.Collection{}) record.Set(sharedenv.AttachedSetsField, []string{"set-a", " set-b ", "set-a", ""}) ids := sharedenv.AttachedSetIDs(record) @@ -190,12 +175,11 @@ func TestAttachedSetIDs(t *testing.T) { } func TestLoadAttachedVars(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newSharedEnvTestApp(t) defer app.Cleanup() + err := error(nil) + setCol, err := app.FindCollectionByNameOrId(sharedenv.SetCollection) if err != nil { t.Fatalf("find env_sets collection: %v", err) diff --git a/backend/domain/config/sharedenv/test_fixture_test.go b/backend/domain/config/sharedenv/test_fixture_test.go new file mode 100644 index 00000000..4ca250ca --- /dev/null +++ b/backend/domain/config/sharedenv/test_fixture_test.go @@ -0,0 +1,56 @@ +package sharedenv_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + sharedEnvTestBaselineOnce sync.Once + sharedEnvTestBaselineDir string + sharedEnvTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if sharedEnvTestBaselineDir != "" { + _ = os.RemoveAll(sharedEnvTestBaselineDir) + } + os.Exit(code) +} + +func sharedEnvTestBaselineDataDir() (string, error) { + sharedEnvTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + sharedEnvTestBaselineErr = err + return + } + + sharedEnvTestBaselineDir = app.DataDir() + sharedEnvTestBaselineErr = app.ResetBootstrapState() + }) + + return sharedEnvTestBaselineDir, sharedEnvTestBaselineErr +} + +func newSharedEnvTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + baselineDir, err := sharedEnvTestBaselineDataDir() + if err != nil { + t.Fatal(err) + } + + app, err := tests.NewTestApp(baselineDir) + if err != nil { + t.Fatal(err) + } + + return app +} diff --git a/backend/domain/lifecycle/orchestration/runner_test.go b/backend/domain/lifecycle/orchestration/runner_test.go index b07f3ba6..5e2df3a0 100644 --- a/backend/domain/lifecycle/orchestration/runner_test.go +++ b/backend/domain/lifecycle/orchestration/runner_test.go @@ -132,7 +132,12 @@ func TestRunReturnsCancelledFromNodeError(t *testing.T) { func newRunnerTestContext(t *testing.T) (*tests.TestApp, *orchestration.ExecutionContext) { t.Helper() - app, err := tests.NewTestApp() + baselineDir, err := orchestrationTestBaselineDataDir() + if err != nil { + t.Fatal(err) + } + + app, err := tests.NewTestApp(baselineDir) if err != nil { t.Fatal(err) } diff --git a/backend/domain/lifecycle/orchestration/test_fixture_test.go b/backend/domain/lifecycle/orchestration/test_fixture_test.go new file mode 100644 index 00000000..5d389201 --- /dev/null +++ b/backend/domain/lifecycle/orchestration/test_fixture_test.go @@ -0,0 +1,40 @@ +package orchestration_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + orchestrationTestBaselineOnce sync.Once + orchestrationTestBaselineDir string + orchestrationTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if orchestrationTestBaselineDir != "" { + _ = os.RemoveAll(orchestrationTestBaselineDir) + } + os.Exit(code) +} + +func orchestrationTestBaselineDataDir() (string, error) { + orchestrationTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + orchestrationTestBaselineErr = err + return + } + + orchestrationTestBaselineDir = app.DataDir() + orchestrationTestBaselineErr = app.ResetBootstrapState() + }) + + return orchestrationTestBaselineDir, orchestrationTestBaselineErr +} diff --git a/backend/domain/lifecycle/projection/updater_test.go b/backend/domain/lifecycle/projection/updater_test.go index 10100bba..64ad1950 100644 --- a/backend/domain/lifecycle/projection/updater_test.go +++ b/backend/domain/lifecycle/projection/updater_test.go @@ -5,23 +5,14 @@ import ( "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/lifecycle/model" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestApplyOperationQueuedForNewInstall(t *testing.T) { - app := newProjectionTestApp(t) - defer app.Cleanup() - - appRecord := createTestAppInstance(t, app) - operationRecord := createTestOperation(t, app, appRecord, string(model.OperationTypeInstall)) + appRecord := newProjectionAppRecord() + operationRecord := newProjectionOperationRecord(appRecord, string(model.OperationTypeInstall)) ApplyOperationQueued(appRecord, operationRecord, QueueOptions{ExistingApp: false}) - if err := app.Save(appRecord); err != nil { - t.Fatal(err) - } if got := appRecord.GetString("last_operation"); got != operationRecord.Id { t.Fatalf("expected last_operation %q, got %q", operationRecord.Id, got) @@ -35,17 +26,11 @@ func TestApplyOperationQueuedForNewInstall(t *testing.T) { } func TestApplyOperationSucceededForInstall(t *testing.T) { - app := newProjectionTestApp(t) - defer app.Cleanup() - - appRecord := createTestAppInstance(t, app) - operationRecord := createTestOperation(t, app, appRecord, string(model.OperationTypeInstall)) + appRecord := newProjectionAppRecord() + operationRecord := newProjectionOperationRecord(appRecord, string(model.OperationTypeInstall)) now := time.Date(2026, time.March, 24, 10, 0, 0, 0, time.UTC) ApplyOperationSucceeded(appRecord, operationRecord, now) - if err := app.Save(appRecord); err != nil { - t.Fatal(err) - } if got := appRecord.GetString("last_operation"); got != operationRecord.Id { t.Fatalf("expected last_operation %q, got %q", operationRecord.Id, got) @@ -65,27 +50,15 @@ func TestApplyOperationSucceededForInstall(t *testing.T) { } func TestApplyOperationFailedMarksAttentionRequired(t *testing.T) { - app := newProjectionTestApp(t) - defer app.Cleanup() - - appRecord := createTestAppInstance(t, app) + appRecord := newProjectionAppRecord() appRecord.Set("lifecycle_state", string(model.AppStateUpdating)) appRecord.Set("health_summary", string(model.HealthHealthy)) - if err := app.Save(appRecord); err != nil { - t.Fatal(err) - } - operationRecord := createTestOperation(t, app, appRecord, string(model.OperationTypeUpgrade)) + operationRecord := newProjectionOperationRecord(appRecord, string(model.OperationTypeUpgrade)) operationRecord.Set("failure_reason", "verification_failed") operationRecord.Set("error_message", "probe failed") - if err := app.Save(operationRecord); err != nil { - t.Fatal(err) - } ApplyOperationFailed(appRecord, operationRecord) - if err := app.Save(appRecord); err != nil { - t.Fatal(err) - } if got := appRecord.GetString("last_operation"); got != operationRecord.Id { t.Fatalf("expected last_operation %q, got %q", operationRecord.Id, got) @@ -102,10 +75,7 @@ func TestApplyOperationFailedMarksAttentionRequired(t *testing.T) { } func TestReadAndApplyAppInstanceProjection(t *testing.T) { - app := newProjectionTestApp(t) - defer app.Cleanup() - - appRecord := createTestAppInstance(t, app) + appRecord := newProjectionAppRecord() now := time.Date(2026, time.March, 24, 12, 0, 0, 0, time.UTC) projection := model.AppInstanceProjection{ LifecycleState: model.AppStateRunningHealthy, @@ -142,26 +112,11 @@ func TestReadAndApplyAppInstanceProjection(t *testing.T) { } } -func newProjectionTestApp(t *testing.T) *tests.TestApp { - t.Helper() - - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - - return app -} - -func createTestAppInstance(t *testing.T, app core.App) *core.Record { - t.Helper() - - collection, err := app.FindCollectionByNameOrId("app_instances") - if err != nil { - t.Fatal(err) - } +func newProjectionAppRecord() *core.Record { + collection := core.NewBaseCollection("app_instances") record := core.NewRecord(collection) + record.Id = "app-1" record.Set("key", "test-app") record.Set("name", "test-app") record.Set("server_id", "server-1") @@ -169,31 +124,19 @@ func createTestAppInstance(t *testing.T, app core.App) *core.Record { record.Set("desired_state", string(model.DesiredStateRunning)) record.Set("health_summary", string(model.HealthUnknown)) record.Set("publication_summary", string(model.PublicationUnpublished)) - if err := app.Save(record); err != nil { - t.Fatal(err) - } - return record } -func createTestOperation(t *testing.T, app core.App, appRecord *core.Record, operationType string) *core.Record { - t.Helper() - - collection, err := app.FindCollectionByNameOrId("app_operations") - if err != nil { - t.Fatal(err) - } - +func newProjectionOperationRecord(appRecord *core.Record, operationType string) *core.Record { + collection := core.NewBaseCollection("app_operations") record := core.NewRecord(collection) + record.Id = "op-1" record.Set("app", appRecord.Id) record.Set("server_id", appRecord.GetString("server_id")) record.Set("operation_type", operationType) record.Set("trigger_source", string(model.TriggerSourceManualOps)) record.Set("phase", string(model.OperationPhaseQueued)) record.Set("queued_at", time.Now()) - if err := app.Save(record); err != nil { - t.Fatal(err) - } return record } diff --git a/backend/domain/monitor/signals/agent/agent_test.go b/backend/domain/monitor/signals/agent/agent_test.go index f442b31c..e7d91046 100644 --- a/backend/domain/monitor/signals/agent/agent_test.go +++ b/backend/domain/monitor/signals/agent/agent_test.go @@ -6,22 +6,16 @@ import ( "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/monitor" "github.com/websoft9/appos/backend/domain/monitor/signals/agent" "github.com/websoft9/appos/backend/domain/monitor/status/store" "github.com/websoft9/appos/backend/domain/secrets" "github.com/websoft9/appos/backend/infra/collections" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestGetOrIssueAgentTokenRoundTripAndRotate(t *testing.T) { prepareAgentSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newAgentTestApp(t) defer app.Cleanup() first, changed, err := agent.GetOrIssueAgentToken(app, "server-1", false) @@ -64,10 +58,7 @@ func TestGetOrIssueAgentTokenRoundTripAndRotate(t *testing.T) { func TestValidateAgentTokenScansBeyondFiveHundredSecrets(t *testing.T) { prepareAgentSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newAgentTestApp(t) defer app.Cleanup() var lastToken string @@ -93,10 +84,7 @@ func TestValidateAgentTokenScansBeyondFiveHundredSecrets(t *testing.T) { } func TestIngestHeartbeatProjectsServerStatus(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newAgentTestApp(t) defer app.Cleanup() receivedAt := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) @@ -136,10 +124,7 @@ func TestIngestHeartbeatProjectsServerStatus(t *testing.T) { } func TestIngestRuntimeStatusProjectsServerAndAppStatuses(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newAgentTestApp(t) defer app.Cleanup() seedAgentAppInstanceRecord(t, app, "appinstance0001", "Demo App") diff --git a/backend/domain/monitor/signals/agent/test_fixture_test.go b/backend/domain/monitor/signals/agent/test_fixture_test.go new file mode 100644 index 00000000..53384f38 --- /dev/null +++ b/backend/domain/monitor/signals/agent/test_fixture_test.go @@ -0,0 +1,50 @@ +package agent_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + agentTestBaselineOnce sync.Once + agentTestBaselineDir string + agentTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if agentTestBaselineDir != "" { + _ = os.RemoveAll(agentTestBaselineDir) + } + os.Exit(code) +} + +func newAgentTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + agentTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + agentTestBaselineErr = err + return + } + + agentTestBaselineDir = app.DataDir() + agentTestBaselineErr = app.ResetBootstrapState() + }) + if agentTestBaselineErr != nil { + t.Fatal(agentTestBaselineErr) + } + + app, err := tests.NewTestApp(agentTestBaselineDir) + if err != nil { + t.Fatal(err) + } + + return app +} diff --git a/backend/domain/monitor/signals/checks/credential_sweep_test.go b/backend/domain/monitor/signals/checks/credential_sweep_test.go index ac762021..9084444a 100644 --- a/backend/domain/monitor/signals/checks/credential_sweep_test.go +++ b/backend/domain/monitor/signals/checks/credential_sweep_test.go @@ -6,7 +6,6 @@ import ( "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/monitor" "github.com/websoft9/appos/backend/domain/monitor/signals/checks" "github.com/websoft9/appos/backend/domain/monitor/status/store" @@ -14,16 +13,11 @@ import ( "github.com/websoft9/appos/backend/domain/secrets" "github.com/websoft9/appos/backend/infra/collections" persistence "github.com/websoft9/appos/backend/infra/persistence" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestRunInstanceCredentialSweepProjectsCredentialInvalidWhenSecretMissing(t *testing.T) { prepareMonitorSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newChecksTestApp(t) defer app.Cleanup() secret := seedMonitorSecretRecord(t, app, "secretredis0001", secrets.StatusRevoked) @@ -55,10 +49,7 @@ func TestRunInstanceCredentialSweepProjectsCredentialInvalidWhenSecretMissing(t func TestRunInstanceCredentialSweepSkipsRedisWithoutCredential(t *testing.T) { prepareMonitorSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newChecksTestApp(t) defer app.Cleanup() seedMonitorSecretRecord(t, app, "secretredis0002", secrets.StatusActive) @@ -82,10 +73,7 @@ func TestRunInstanceCredentialSweepSkipsRedisWithoutCredential(t *testing.T) { func TestCheckInstanceCredentialReturnsCredentialInvalidForMissingSecret(t *testing.T) { prepareMonitorSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newChecksTestApp(t) defer app.Cleanup() item := instances.RestoreInstance(instances.Snapshot{ diff --git a/backend/domain/monitor/signals/checks/test_fixture_test.go b/backend/domain/monitor/signals/checks/test_fixture_test.go new file mode 100644 index 00000000..789ff577 --- /dev/null +++ b/backend/domain/monitor/signals/checks/test_fixture_test.go @@ -0,0 +1,50 @@ +package checks_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + checksTestBaselineOnce sync.Once + checksTestBaselineDir string + checksTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if checksTestBaselineDir != "" { + _ = os.RemoveAll(checksTestBaselineDir) + } + os.Exit(code) +} + +func newChecksTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + checksTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + checksTestBaselineErr = err + return + } + + checksTestBaselineDir = app.DataDir() + checksTestBaselineErr = app.ResetBootstrapState() + }) + if checksTestBaselineErr != nil { + t.Fatal(checksTestBaselineErr) + } + + app, err := tests.NewTestApp(checksTestBaselineDir) + if err != nil { + t.Fatal(err) + } + + return app +} diff --git a/backend/domain/monitor/signals/platform/model.go b/backend/domain/monitor/signals/platform/model.go index 1375be4c..c6ba55db 100644 --- a/backend/domain/monitor/signals/platform/model.go +++ b/backend/domain/monitor/signals/platform/model.go @@ -6,6 +6,7 @@ import ( "time" "github.com/pocketbase/pocketbase/core" + "github.com/websoft9/appos/backend/infra/supervisor" ) const ( @@ -31,6 +32,7 @@ type RuntimeSnapshot struct { type PlatformObserver struct { app core.App snapshotFn func() RuntimeSnapshot + resourceFn func([]int) map[int]supervisor.ResourceInfo nowFn func() time.Time mu sync.Mutex cancel context.CancelFunc diff --git a/backend/domain/monitor/signals/platform/observer.go b/backend/domain/monitor/signals/platform/observer.go index 655baf2a..64aeaa70 100644 --- a/backend/domain/monitor/signals/platform/observer.go +++ b/backend/domain/monitor/signals/platform/observer.go @@ -15,6 +15,7 @@ func NewPlatformObserver(app core.App, snapshotFn func() RuntimeSnapshot) *Platf return &PlatformObserver{ app: app, snapshotFn: snapshotFn, + resourceFn: supervisor.GetProcessResources, nowFn: func() time.Time { return time.Now().UTC() }, @@ -28,6 +29,13 @@ func (o *PlatformObserver) SetNowFunc(nowFn func() time.Time) { o.nowFn = nowFn } +func (o *PlatformObserver) SetResourceFunc(resourceFn func([]int) map[int]supervisor.ResourceInfo) { + if resourceFn == nil { + return + } + o.resourceFn = resourceFn +} + func (o *PlatformObserver) Start() { o.mu.Lock() defer o.mu.Unlock() @@ -54,7 +62,7 @@ func (o *PlatformObserver) Collect() error { if o.snapshotFn != nil { snapshot = o.snapshotFn() } - resources := supervisor.GetProcessResources([]int{os.Getpid()}) + resources := o.resourceFn([]int{os.Getpid()}) resource := resources[os.Getpid()] var mem runtime.MemStats runtime.ReadMemStats(&mem) diff --git a/backend/domain/monitor/signals/platform/observer_test.go b/backend/domain/monitor/signals/platform/observer_test.go index 207808b0..808586f7 100644 --- a/backend/domain/monitor/signals/platform/observer_test.go +++ b/backend/domain/monitor/signals/platform/observer_test.go @@ -5,20 +5,15 @@ import ( "testing" "time" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/monitor" monitormetrics "github.com/websoft9/appos/backend/domain/monitor/metrics" "github.com/websoft9/appos/backend/domain/monitor/signals/platform" "github.com/websoft9/appos/backend/infra/collections" - - _ "github.com/websoft9/appos/backend/infra/migrations" + "github.com/websoft9/appos/backend/infra/supervisor" ) func TestPlatformObserverCollectWritesPlatformTargets(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newPlatformTestApp(t) defer app.Cleanup() now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) @@ -32,6 +27,9 @@ func TestPlatformObserverCollectWritesPlatformTargets(t *testing.T) { } }) observer.SetNowFunc(func() time.Time { return now }) + observer.SetResourceFunc(func([]int) map[int]supervisor.ResourceInfo { + return map[int]supervisor.ResourceInfo{} + }) if err := observer.Collect(); err != nil { t.Fatal(err) @@ -53,10 +51,7 @@ func TestPlatformObserverCollectWritesPlatformTargets(t *testing.T) { } func TestPlatformObserverCollectMarksStaleSchedulerDegraded(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newPlatformTestApp(t) defer app.Cleanup() now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) @@ -70,6 +65,9 @@ func TestPlatformObserverCollectMarksStaleSchedulerDegraded(t *testing.T) { } }) observer.SetNowFunc(func() time.Time { return now }) + observer.SetResourceFunc(func([]int) map[int]supervisor.ResourceInfo { + return map[int]supervisor.ResourceInfo{} + }) if err := observer.Collect(); err != nil { t.Fatal(err) @@ -89,10 +87,7 @@ func TestPlatformObserverCollectMarksStaleSchedulerDegraded(t *testing.T) { } func TestPlatformObserverCollectWritesPlatformMetrics(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newPlatformTestApp(t) defer app.Cleanup() now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) @@ -114,6 +109,9 @@ func TestPlatformObserverCollectWritesPlatformMetrics(t *testing.T) { } }) observer.SetNowFunc(func() time.Time { return now }) + observer.SetResourceFunc(func([]int) map[int]supervisor.ResourceInfo { + return map[int]supervisor.ResourceInfo{} + }) if err := observer.Collect(); err != nil { t.Fatal(err) diff --git a/backend/domain/monitor/signals/platform/test_fixture_test.go b/backend/domain/monitor/signals/platform/test_fixture_test.go new file mode 100644 index 00000000..93e01c9d --- /dev/null +++ b/backend/domain/monitor/signals/platform/test_fixture_test.go @@ -0,0 +1,50 @@ +package platform_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + platformTestBaselineOnce sync.Once + platformTestBaselineDir string + platformTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if platformTestBaselineDir != "" { + _ = os.RemoveAll(platformTestBaselineDir) + } + os.Exit(code) +} + +func newPlatformTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + platformTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + platformTestBaselineErr = err + return + } + + platformTestBaselineDir = app.DataDir() + platformTestBaselineErr = app.ResetBootstrapState() + }) + if platformTestBaselineErr != nil { + t.Fatal(platformTestBaselineErr) + } + + app, err := tests.NewTestApp(platformTestBaselineDir) + if err != nil { + t.Fatal(err) + } + + return app +} diff --git a/backend/domain/monitor/status/query.go b/backend/domain/monitor/status/query.go index ae32acb3..f0394e42 100644 --- a/backend/domain/monitor/status/query.go +++ b/backend/domain/monitor/status/query.go @@ -17,6 +17,10 @@ func BuildOverview(app core.App) (*OverviewResponse, error) { if err != nil { return nil, err } + return buildOverviewFromRecords(records) +} + +func buildOverviewFromRecords(records []*core.Record) (*OverviewResponse, error) { counts := map[string]int{ monitor.StatusHealthy: 0, monitor.StatusDegraded: 0, @@ -161,6 +165,10 @@ func synthesizeAppTargetStatus(app core.App, targetID string, appEntry monitor.T if err != nil { return nil, err } + return synthesizeAppTargetStatusFromRecord(appRecord, appEntry), nil +} + +func synthesizeAppTargetStatusFromRecord(appRecord *core.Record, appEntry monitor.TargetRegistryEntry) *TargetStatusResponse { runtimeStatus := strings.ToLower(strings.TrimSpace(appRecord.GetString("runtime_status"))) lifecycleState := strings.TrimSpace(appRecord.GetString("lifecycle_state")) healthSummary := strings.TrimSpace(appRecord.GetString("health_summary")) @@ -184,7 +192,7 @@ func synthesizeAppTargetStatus(app core.App, targetID string, appEntry monitor.T return &TargetStatusResponse{ HasData: false, TargetType: monitor.TargetTypeApp, - TargetID: targetID, + TargetID: appRecord.Id, DisplayName: appRecord.GetString("name"), Status: status, Reason: nullableString(reason), @@ -204,7 +212,7 @@ func synthesizeAppTargetStatus(app core.App, targetID string, appEntry monitor.T "publication_summary": strings.TrimSpace(appRecord.GetString("publication_summary")), "server_id": strings.TrimSpace(appRecord.GetString("server_id")), }, - }, nil + } } func normalizeSummary(summary map[string]any) map[string]any { diff --git a/backend/domain/monitor/status/query_internal_test.go b/backend/domain/monitor/status/query_internal_test.go index 8c05de6a..d68e8d2d 100644 --- a/backend/domain/monitor/status/query_internal_test.go +++ b/backend/domain/monitor/status/query_internal_test.go @@ -4,20 +4,11 @@ import ( "testing" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/monitor" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestSynthesizeAppTargetStatusUsesRegistryPolicy(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - seedQueryAppInstanceRecord(t, app, "appinstance0002", "Registry App") + appRecord := seedQueryAppInstanceRecord("appinstance0002", "Registry App") entry := monitor.TargetRegistryEntry{ Checks: monitor.TargetCheckPolicies{ AppHealth: &monitor.AppHealthTargetPolicy{ @@ -34,10 +25,7 @@ func TestSynthesizeAppTargetStatusUsesRegistryPolicy(t *testing.T) { }, } - resp, err := synthesizeAppTargetStatus(app, "appinstance0002", entry) - if err != nil { - t.Fatal(err) - } + resp := synthesizeAppTargetStatusFromRecord(appRecord, entry) if resp.Status != monitor.StatusDegraded { t.Fatalf("expected custom synthesized app status, got %q", resp.Status) } @@ -46,13 +34,10 @@ func TestSynthesizeAppTargetStatusUsesRegistryPolicy(t *testing.T) { } } -func seedQueryAppInstanceRecord(t *testing.T, app core.App, id string, name string) *core.Record { - t.Helper() - col, err := app.FindCollectionByNameOrId("app_instances") - if err != nil { - t.Fatal(err) - } +func seedQueryAppInstanceRecord(id string, name string) *core.Record { + col := core.NewBaseCollection("app_instances") rec := core.NewRecord(col) + rec.Id = id rec.Set("id", id) rec.Set("key", name+"-key") rec.Set("server_id", "local") @@ -63,8 +48,5 @@ func seedQueryAppInstanceRecord(t *testing.T, app core.App, id string, name stri rec.Set("health_summary", "healthy") rec.Set("publication_summary", "unpublished") rec.Set("state_reason", "seeded for monitor status query test") - if err := app.Save(rec); err != nil { - t.Fatal(err) - } return rec } diff --git a/backend/domain/monitor/status/query_test.go b/backend/domain/monitor/status/query_test.go index 35c32744..1ae4c2df 100644 --- a/backend/domain/monitor/status/query_test.go +++ b/backend/domain/monitor/status/query_test.go @@ -1,4 +1,4 @@ -package status_test +package status import ( "fmt" @@ -6,40 +6,26 @@ import ( "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/monitor" - "github.com/websoft9/appos/backend/domain/monitor/status" - "github.com/websoft9/appos/backend/infra/collections" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestBuildOverviewReturnsMoreThanFiveHundredItems(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - col, err := app.FindCollectionByNameOrId(collections.MonitorLatestStatus) - if err != nil { - t.Fatal(err) - } + col := core.NewBaseCollection("monitor_latest_status") transitionAt := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) + records := make([]*core.Record, 0, 501) for index := 0; index < 501; index++ { rec := core.NewRecord(col) + rec.Id = fmt.Sprintf("status-%03d", index) rec.Set("target_type", monitor.TargetTypeResource) rec.Set("target_id", fmt.Sprintf("inst-%03d", index)) rec.Set("display_name", fmt.Sprintf("Instance %03d", index)) rec.Set("status", monitor.StatusOffline) rec.Set("signal_source", monitor.SignalSourceAppOS) rec.Set("last_transition_at", transitionAt.Add(time.Duration(index)*time.Second).Format(time.RFC3339)) - if err := app.Save(rec); err != nil { - t.Fatal(err) - } + records = append(records, rec) } - overview, err := status.BuildOverview(app) + overview, err := buildOverviewFromRecords(records) if err != nil { t.Fatal(err) } diff --git a/backend/domain/monitor/status/store/projection.go b/backend/domain/monitor/status/store/projection.go index 308aeb0d..9b231e42 100644 --- a/backend/domain/monitor/status/store/projection.go +++ b/backend/domain/monitor/status/store/projection.go @@ -7,7 +7,11 @@ import ( ) func LoadResourceCheckSummary(app core.App, targetType, targetID, checkKind, registryEntryID, resourceKind, templateID, endpoint string) map[string]any { - summary := LoadExistingSummary(app, targetType, targetID) + return BuildResourceCheckSummary(LoadExistingSummary(app, targetType, targetID), checkKind, registryEntryID, resourceKind, templateID, endpoint) +} + +func BuildResourceCheckSummary(summary map[string]any, checkKind, registryEntryID, resourceKind, templateID, endpoint string) map[string]any { + summary = CloneSummary(summary) summary["check_kind"] = strings.TrimSpace(checkKind) summary["registry_entry_id"] = registryEntryID summary["resource_kind"] = resourceKind diff --git a/backend/domain/monitor/status/store/store_test.go b/backend/domain/monitor/status/store/store_test.go index 6e465927..8cf37029 100644 --- a/backend/domain/monitor/status/store/store_test.go +++ b/backend/domain/monitor/status/store/store_test.go @@ -5,12 +5,9 @@ import ( "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/monitor" "github.com/websoft9/appos/backend/domain/monitor/status/store" "github.com/websoft9/appos/backend/infra/collections" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestApplyReasonCodeNormalizesAndClears(t *testing.T) { @@ -26,26 +23,14 @@ func TestApplyReasonCodeNormalizesAndClears(t *testing.T) { } func TestLoadResourceCheckSummaryMergesExistingSummary(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - _, err = store.UpsertLatestStatus(app, store.LatestStatusUpsert{ - TargetType: monitor.TargetTypeResource, - TargetID: "inst-1", - DisplayName: "redis-primary", - Status: monitor.StatusHealthy, - SignalSource: monitor.SignalSourceAppOS, - LastTransitionAt: time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC), - Summary: map[string]any{"existing": "value"}, - }) - if err != nil { - t.Fatal(err) - } - - summary := store.LoadResourceCheckSummary(app, monitor.TargetTypeResource, "inst-1", monitor.CheckKindReachability, "resource-redis-generic", "redis", "generic-redis", "127.0.0.1:6379") + summary := store.BuildResourceCheckSummary( + map[string]any{"existing": "value"}, + monitor.CheckKindReachability, + "resource-redis-generic", + "redis", + "generic-redis", + "127.0.0.1:6379", + ) if summary["existing"] != "value" { t.Fatalf("expected existing summary field to be preserved, got %+v", summary) } @@ -61,10 +46,7 @@ func TestLoadResourceCheckSummaryMergesExistingSummary(t *testing.T) { } func TestUpsertLatestStatusPreservesStrongerFailureAndTransition(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newStoreTestApp(t) defer app.Cleanup() initialTransition := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) @@ -112,16 +94,7 @@ func TestUpsertLatestStatusPreservesStrongerFailureAndTransition(t *testing.T) { } func TestSummaryFromRecordInvalidJSONReturnsError(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - col, err := app.FindCollectionByNameOrId(collections.MonitorLatestStatus) - if err != nil { - t.Fatal(err) - } + col := core.NewBaseCollection(collections.MonitorLatestStatus) record := core.NewRecord(col) record.Set("target_type", monitor.TargetTypeResource) record.Set("target_id", "inst-3") @@ -132,11 +105,9 @@ func TestSummaryFromRecordInvalidJSONReturnsError(t *testing.T) { } func TestPreviousFailureCountAndHasDifferentCheckKind(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } + app := newStoreTestApp(t) defer app.Cleanup() + var err error failures := 3 _, err = store.UpsertLatestStatus(app, store.LatestStatusUpsert{ diff --git a/backend/domain/monitor/status/store/test_fixture_test.go b/backend/domain/monitor/status/store/test_fixture_test.go new file mode 100644 index 00000000..fab1100d --- /dev/null +++ b/backend/domain/monitor/status/store/test_fixture_test.go @@ -0,0 +1,50 @@ +package store_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + storeTestBaselineOnce sync.Once + storeTestBaselineDir string + storeTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if storeTestBaselineDir != "" { + _ = os.RemoveAll(storeTestBaselineDir) + } + os.Exit(code) +} + +func newStoreTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + storeTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + storeTestBaselineErr = err + return + } + + storeTestBaselineDir = app.DataDir() + storeTestBaselineErr = app.ResetBootstrapState() + }) + if storeTestBaselineErr != nil { + t.Fatal(storeTestBaselineErr) + } + + app, err := tests.NewTestApp(storeTestBaselineDir) + if err != nil { + t.Fatal(err) + } + + return app +} diff --git a/backend/domain/resource/connectors/runtime_test.go b/backend/domain/resource/connectors/runtime_test.go index dd67765b..55fb2325 100644 --- a/backend/domain/resource/connectors/runtime_test.go +++ b/backend/domain/resource/connectors/runtime_test.go @@ -26,7 +26,11 @@ func setupRuntimeSecretKey(t *testing.T) { func newRuntimeTestApp(t *testing.T) *tests.TestApp { t.Helper() setupRuntimeSecretKey(t) - app, err := tests.NewTestApp() + baselineDir, err := connectorsTestBaselineDataDir() + if err != nil { + t.Fatal(err) + } + app, err := tests.NewTestApp(baselineDir) if err != nil { t.Fatal(err) } diff --git a/backend/domain/resource/connectors/test_fixture_test.go b/backend/domain/resource/connectors/test_fixture_test.go new file mode 100644 index 00000000..c1fe4a4d --- /dev/null +++ b/backend/domain/resource/connectors/test_fixture_test.go @@ -0,0 +1,40 @@ +package connectors_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + connectorsTestBaselineOnce sync.Once + connectorsTestBaselineDir string + connectorsTestBaselineErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if connectorsTestBaselineDir != "" { + _ = os.RemoveAll(connectorsTestBaselineDir) + } + os.Exit(code) +} + +func connectorsTestBaselineDataDir() (string, error) { + connectorsTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + connectorsTestBaselineErr = err + return + } + + connectorsTestBaselineDir = app.DataDir() + connectorsTestBaselineErr = app.ResetBootstrapState() + }) + + return connectorsTestBaselineDir, connectorsTestBaselineErr +} diff --git a/backend/domain/routes/resources_test.go b/backend/domain/routes/resources_test.go index 45d8e86b..132e9f65 100644 --- a/backend/domain/routes/resources_test.go +++ b/backend/domain/routes/resources_test.go @@ -7,7 +7,9 @@ import ( "net" "net/http" "net/http/httptest" + "os" "strings" + "sync" "testing" "github.com/pocketbase/pocketbase/apis" @@ -32,6 +34,52 @@ type testEnv struct { token string } +var ( + routesTestBaselineOnce sync.Once + routesTestBaselineDir string + routesTestBaselineErr error +) + +const routesTestAdminEmail = "admin@test.com" + +func TestMain(m *testing.M) { + code := m.Run() + if routesTestBaselineDir != "" { + _ = os.RemoveAll(routesTestBaselineDir) + } + os.Exit(code) +} + +func routesTestBaselineDataDir() (string, error) { + routesTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + routesTestBaselineErr = err + return + } + + suCol, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + if err != nil { + routesTestBaselineErr = err + app.Cleanup() + return + } + su := core.NewRecord(suCol) + su.Set("email", routesTestAdminEmail) + su.SetPassword("1234567890") + if err := app.Save(su); err != nil { + routesTestBaselineErr = err + app.Cleanup() + return + } + + routesTestBaselineDir = app.DataDir() + routesTestBaselineErr = app.ResetBootstrapState() + }) + + return routesTestBaselineDir, routesTestBaselineErr +} + func newTestEnv(t *testing.T) *testEnv { t.Helper() oldFilesBasePath := filesBasePath @@ -40,21 +88,19 @@ func newTestEnv(t *testing.T) *testEnv { filesBasePath = oldFilesBasePath }) - app, err := tests.NewTestApp() + baselineDir, err := routesTestBaselineDataDir() if err != nil { t.Fatal(err) } - // Seed a superuser for API auth - suCol, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + app, err := tests.NewTestApp(baselineDir) if err != nil { - app.Cleanup() t.Fatal(err) } - su := core.NewRecord(suCol) - su.Set("email", "admin@test.com") - su.SetPassword("1234567890") - if err := app.Save(su); err != nil { + + // Reuse the baseline superuser and mint a token in the cloned app. + su, err := app.FindFirstRecordByData(core.CollectionNameSuperusers, "email", routesTestAdminEmail) + if err != nil { app.Cleanup() t.Fatal(err) } diff --git a/backend/domain/software/runtime_bindings.go b/backend/domain/software/runtime_bindings.go index 751dffd5..319fe1c9 100644 --- a/backend/domain/software/runtime_bindings.go +++ b/backend/domain/software/runtime_bindings.go @@ -31,6 +31,10 @@ func ApplyRuntimeBindings(app core.App, entry CatalogEntry) CatalogEntry { func effectiveAppOSAgentInstallerURL(app core.App) string { defaults := settingscatalog.DefaultGroup(softwareConfigModule, softwareConfigKey) group, _ := sysconfig.GetGroup(app, softwareConfigModule, softwareConfigKey, defaults) + return effectiveAppOSAgentInstallerURLFromGroup(group, defaults) +} + +func effectiveAppOSAgentInstallerURLFromGroup(group, defaults map[string]any) string { url := strings.TrimSpace(sysconfig.String(group, apposAgentInstallerURLFieldName, "")) if url != "" { return url diff --git a/backend/domain/software/runtime_bindings_test.go b/backend/domain/software/runtime_bindings_test.go index 213b79d1..62ceaf5b 100644 --- a/backend/domain/software/runtime_bindings_test.go +++ b/backend/domain/software/runtime_bindings_test.go @@ -1,42 +1,26 @@ -package software_test +package software import ( "testing" - "github.com/pocketbase/pocketbase/tests" - "github.com/websoft9/appos/backend/domain/config/sysconfig" - "github.com/websoft9/appos/backend/domain/software" - - _ "github.com/websoft9/appos/backend/infra/migrations" + settingscatalog "github.com/websoft9/appos/backend/domain/config/sysconfig/catalog" ) func TestApplyRuntimeBindings_AppOSAgentUsesDefaultInstallerURL(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - bound := software.ApplyRuntimeBindings(app, software.CatalogEntry{ComponentKey: software.ComponentKeyAppOSAgent}) + defaults := settingscatalog.DefaultGroup(softwareConfigModule, softwareConfigKey) + bound := CatalogEntry{ComponentKey: ComponentKeyAppOSAgent} + bound.ScriptURL = effectiveAppOSAgentInstallerURLFromGroup(nil, defaults) if got, want := bound.ScriptURL, "https://artifact.websoft9.com/stable/appos/agent/appos-agent-install.sh"; got != want { t.Fatalf("expected default installer url %q, got %q", want, got) } } func TestApplyRuntimeBindings_AppOSAgentUsesCustomInstallerURL(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - if err := sysconfig.SetGroup(app, "software", "config", map[string]any{ + defaults := settingscatalog.DefaultGroup(softwareConfigModule, softwareConfigKey) + bound := CatalogEntry{ComponentKey: ComponentKeyAppOSAgent} + bound.ScriptURL = effectiveAppOSAgentInstallerURLFromGroup(map[string]any{ "apposAgentInstallerUrl": "https://example.com/custom/appos-agent-install.sh", - }); err != nil { - t.Fatal(err) - } - - bound := software.ApplyRuntimeBindings(app, software.CatalogEntry{ComponentKey: software.ComponentKeyAppOSAgent}) + }, defaults) if got, want := bound.ScriptURL, "https://example.com/custom/appos-agent-install.sh"; got != want { t.Fatalf("expected custom installer url %q, got %q", want, got) } diff --git a/backend/domain/worker/appos_agent_bindings.go b/backend/domain/worker/appos_agent_bindings.go index f18282de..ed89b625 100644 --- a/backend/domain/worker/appos_agent_bindings.go +++ b/backend/domain/worker/appos_agent_bindings.go @@ -23,42 +23,46 @@ func applyServerExecutionBindings(app core.App, serverID string, explicitBaseURL return resolved } - env := apposAgentManagedInstallEnv(app, serverID, explicitBaseURL) - if len(env) == 0 { - return resolved - } - resolved.Install.Env = mergeRuntimeEnv(resolved.Install.Env, env) - resolved.Upgrade.Env = mergeRuntimeEnv(resolved.Upgrade.Env, env) - return resolved -} - -func apposAgentManagedInstallEnv(app core.App, serverID string, explicitBaseURL string) map[string]string { baseURL := effectiveAppOSBaseURL(app, explicitBaseURL) if baseURL == "" { - return nil + return resolved } token, _, err := agentsignals.GetOrIssueAgentToken(app, serverID, false) if err != nil || strings.TrimSpace(token) == "" { - return nil + return resolved + } + return applyServerExecutionBindingsWithInputs(serverID, baseURL, token, resolved) +} + +func applyServerExecutionBindingsWithInputs(serverID string, baseURL string, token string, resolved software.ResolvedTemplate) software.ResolvedTemplate { + if strings.TrimSpace(serverID) == "" || strings.TrimSpace(baseURL) == "" || strings.TrimSpace(token) == "" { + return resolved } - return map[string]string{ + env := map[string]string{ apposAgentConfigEnvName: apposAgentConfigYAML(serverID, baseURL, token), apposAgentSystemdUnitEnvName: apposAgentSystemdUnit(), } + resolved.Install.Env = mergeRuntimeEnv(resolved.Install.Env, env) + resolved.Upgrade.Env = mergeRuntimeEnv(resolved.Upgrade.Env, env) + return resolved } func effectiveAppOSBaseURL(app core.App, explicitBaseURL string) string { - if normalized := software.NormalizeAppOSBaseURL(explicitBaseURL); normalized != "" { - return normalized - } if app == nil { - return "" + return effectiveAppOSBaseURLFromValue("", explicitBaseURL) } current, err := app.Settings().Clone() if err != nil || current == nil { - return "" + return effectiveAppOSBaseURLFromValue("", explicitBaseURL) + } + return effectiveAppOSBaseURLFromValue(current.Meta.AppURL, explicitBaseURL) +} + +func effectiveAppOSBaseURLFromValue(appURL string, explicitBaseURL string) string { + if normalized := software.NormalizeAppOSBaseURL(explicitBaseURL); normalized != "" { + return normalized } - return software.NormalizeAppOSBaseURL(current.Meta.AppURL) + return software.NormalizeAppOSBaseURL(appURL) } func apposAgentConfigYAML(serverID string, baseURL string, token string) string { diff --git a/backend/domain/worker/appos_agent_bindings_test.go b/backend/domain/worker/appos_agent_bindings_test.go index e794ce7d..a08b0e91 100644 --- a/backend/domain/worker/appos_agent_bindings_test.go +++ b/backend/domain/worker/appos_agent_bindings_test.go @@ -4,34 +4,11 @@ import ( "strings" "testing" - "github.com/pocketbase/pocketbase/tests" - "github.com/websoft9/appos/backend/domain/config/sysconfig" - settingscatalog "github.com/websoft9/appos/backend/domain/config/sysconfig/catalog" "github.com/websoft9/appos/backend/domain/software" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestApplyServerExecutionBindings_AppOSAgentInjectsManagedEnv(t *testing.T) { - prepareWorkerSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - basicEntry, ok := settingscatalog.FindEntry("basic") - if !ok { - t.Fatal("expected basic settings entry") - } - if _, err := sysconfig.PatchPocketBaseEntry(app, basicEntry, map[string]any{ - "appName": "AppOS", - "appURL": "https://console.example.com", - }); err != nil { - t.Fatal(err) - } - - resolved := applyServerExecutionBindings(app, "srv-123", "", software.ResolvedTemplate{ + resolved := applyServerExecutionBindingsWithInputs("srv-123", "https://console.example.com", "agent-token", software.ResolvedTemplate{ ComponentKey: software.ComponentKeyAppOSAgent, Install: software.InstallSpec{}, Upgrade: software.UpgradeSpec{}, @@ -47,6 +24,9 @@ func TestApplyServerExecutionBindings_AppOSAgentInjectsManagedEnv(t *testing.T) if !strings.Contains(configYAML, "ingest_base_url: https://console.example.com/api/monitor/ingest") { t.Fatalf("expected app url in config yaml, got %q", configYAML) } + if !strings.Contains(configYAML, "token: agent-token") { + t.Fatalf("expected token in config yaml, got %q", configYAML) + } unit := resolved.Install.Env[apposAgentSystemdUnitEnvName] if !strings.Contains(unit, "ExecStart=/usr/local/bin/appos-agent --config /etc/appos-agent.yaml") { t.Fatalf("expected managed unit content, got %q", unit) @@ -57,32 +37,8 @@ func TestApplyServerExecutionBindings_AppOSAgentInjectsManagedEnv(t *testing.T) } func TestApplyServerExecutionBindings_AppOSAgentPrefersExplicitBaseURL(t *testing.T) { - prepareWorkerSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() - - basicEntry, ok := settingscatalog.FindEntry("basic") - if !ok { - t.Fatal("expected basic settings entry") - } - if _, err := sysconfig.PatchPocketBaseEntry(app, basicEntry, map[string]any{ - "appName": "AppOS", - "appURL": "https://stale.example.com", - }); err != nil { - t.Fatal(err) - } - - resolved := applyServerExecutionBindings(app, "srv-123", "https://console.example.com:8443/", software.ResolvedTemplate{ - ComponentKey: software.ComponentKeyAppOSAgent, - Install: software.InstallSpec{}, - Upgrade: software.UpgradeSpec{}, - }) - - configYAML := resolved.Install.Env[apposAgentConfigEnvName] - if !strings.Contains(configYAML, "ingest_base_url: https://console.example.com:8443/api/monitor/ingest") { - t.Fatalf("expected explicit app url in config yaml, got %q", configYAML) + baseURL := effectiveAppOSBaseURLFromValue("https://stale.example.com", "https://console.example.com:8443/") + if baseURL != "https://console.example.com:8443" { + t.Fatalf("expected explicit app url to win, got %q", baseURL) } } \ No newline at end of file diff --git a/backend/domain/worker/lifecycle_operations_test.go b/backend/domain/worker/lifecycle_operations_test.go index d5835762..cdac5cc1 100644 --- a/backend/domain/worker/lifecycle_operations_test.go +++ b/backend/domain/worker/lifecycle_operations_test.go @@ -12,13 +12,10 @@ import ( "github.com/hibiken/asynq" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/lifecycle/model" lifecycleruntime "github.com/websoft9/appos/backend/domain/lifecycle/runtime" lifecyclesvc "github.com/websoft9/appos/backend/domain/lifecycle/service" "github.com/websoft9/appos/backend/infra/docker" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) type fakeDockerExecutor struct { @@ -93,11 +90,7 @@ func (f fakeOperationExecutor) Name() string { } func TestHandleRunOperationCreatesReleaseAndProjection(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := operationExecutorFactory oldHealthCheck := operationHealthCheck @@ -222,11 +215,7 @@ func TestHandleRunOperationStopRestartAndUninstallUseExistingReleaseState(t *tes for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := operationExecutorFactory oldHealthCheck := operationHealthCheck @@ -348,11 +337,7 @@ func TestHandleRunOperationStopRestartAndUninstallUseExistingReleaseState(t *tes } func TestExecuteNodeCreatesAndPromotesCandidateReleaseForSourceBuild(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := operationExecutorFactory oldHealthCheck := operationHealthCheck @@ -496,11 +481,7 @@ func TestExecuteNodeCreatesAndPromotesCandidateReleaseForSourceBuild(t *testing. } func TestHandleRunOperationCompletesSourceBuildPipeline(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := operationExecutorFactory oldHealthCheck := operationHealthCheck diff --git a/backend/domain/worker/monitoring_checks_test.go b/backend/domain/worker/monitoring_checks_test.go index 73493b5e..315aad6d 100644 --- a/backend/domain/worker/monitoring_checks_test.go +++ b/backend/domain/worker/monitoring_checks_test.go @@ -9,21 +9,14 @@ import ( "time" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/monitor" "github.com/websoft9/appos/backend/domain/monitor/status/store" "github.com/websoft9/appos/backend/domain/secrets" "github.com/websoft9/appos/backend/infra/collections" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestHandleMonitorReachabilitySweepProjectsInstanceStatuses(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -103,11 +96,7 @@ func TestEnqueueMonitorReachabilitySweepRequiresClient(t *testing.T) { } func TestHandleMonitorHeartbeatFreshnessProjectsOfflineStatus(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) col, err := app.FindCollectionByNameOrId(collections.MonitorLatestStatus) if err != nil { @@ -165,11 +154,7 @@ func TestEnqueueMonitorHeartbeatFreshnessRequiresClient(t *testing.T) { func TestHandleMonitorCredentialSweepProjectsCredentialInvalidWhenSecretMissing(t *testing.T) { prepareWorkerSecretKey(t) - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) secret := seedWorkerSecretRecord(t, app, "secretredis0003", secrets.StatusRevoked) item := seedInstanceRecord(t, app, "redis-with-missing-secret", "redis", "127.0.0.1:6379") @@ -210,11 +195,7 @@ func TestEnqueueMonitorCredentialSweepRequiresClient(t *testing.T) { } func TestHandleMonitorAppHealthSweepProjectsAppStatuses(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) healthy := seedAppInstanceRecord(t, app, "healthy-app", "running", "healthy") degraded := seedAppInstanceRecord(t, app, "degraded-app", "running", "degraded") diff --git a/backend/domain/worker/software_delivery_test.go b/backend/domain/worker/software_delivery_test.go index b89fecf0..80327789 100644 --- a/backend/domain/worker/software_delivery_test.go +++ b/backend/domain/worker/software_delivery_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/types" "github.com/websoft9/appos/backend/domain/software" ) @@ -247,11 +246,7 @@ func TestSoftwareActionPayloadRoundTrip(t *testing.T) { } func TestRunSoftwarePhaseLoopUsesExecutorPreflightAndAuditsTerminalFailure(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := softwareExecutorFactory defer func() { softwareExecutorFactory = oldFactory }() @@ -333,11 +328,7 @@ func TestRunSoftwarePhaseLoopUsesExecutorPreflightAndAuditsTerminalFailure(t *te } func TestRunSoftwarePhaseLoopRefreshesSnapshotOnSuccess(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := softwareExecutorFactory defer func() { softwareExecutorFactory = oldFactory }() @@ -405,11 +396,7 @@ func TestRunSoftwarePhaseLoopRefreshesSnapshotOnSuccess(t *testing.T) { } func TestRunSoftwarePhaseLoopFailsWhenPostActionVerifyIsDegraded(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := softwareExecutorFactory defer func() { softwareExecutorFactory = oldFactory }() @@ -494,11 +481,7 @@ func TestRunSoftwarePhaseLoopFailsWhenPostActionVerifyIsDegraded(t *testing.T) { } func TestRunSoftwarePhaseLoopFailsWhenUninstallTruthStillDetectsInstalled(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := softwareExecutorFactory defer func() { softwareExecutorFactory = oldFactory }() @@ -570,11 +553,7 @@ func TestRunSoftwarePhaseLoopFailsWhenUninstallTruthStillDetectsInstalled(t *tes } func TestRunSoftwarePhaseLoopMarksVerificationErrorCodeForVerifyErrors(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldFactory := softwareExecutorFactory defer func() { softwareExecutorFactory = oldFactory }() diff --git a/backend/domain/worker/test_fixture_test.go b/backend/domain/worker/test_fixture_test.go new file mode 100644 index 00000000..1c2fe512 --- /dev/null +++ b/backend/domain/worker/test_fixture_test.go @@ -0,0 +1,102 @@ +package worker + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + workerTestBaselineOnce sync.Once + workerTestBaselineDir string + workerTestBaselineErr error + workerTestSharedOnce sync.Once + workerTestSharedApp *tests.TestApp + workerTestSharedErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if workerTestSharedApp != nil { + workerTestSharedApp.Cleanup() + } + if workerTestBaselineDir != "" { + _ = os.RemoveAll(workerTestBaselineDir) + } + os.Exit(code) +} + +func workerTestBaselineDataDir() (string, error) { + workerTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + workerTestBaselineErr = err + return + } + + workerTestBaselineDir = app.DataDir() + workerTestBaselineErr = app.ResetBootstrapState() + }) + + return workerTestBaselineDir, workerTestBaselineErr +} + +func newWorkerTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + workerTestSharedOnce.Do(func() { + baselineDir, err := workerTestBaselineDataDir() + if err != nil { + workerTestSharedErr = err + return + } + + workerTestSharedApp, err = tests.NewTestApp(baselineDir) + if err != nil { + workerTestSharedErr = err + } + }) + if workerTestSharedErr != nil { + t.Fatal(workerTestSharedErr) + } + + resetWorkerTestState(t, workerTestSharedApp) + return workerTestSharedApp +} + +func resetWorkerTestState(t *testing.T, app *tests.TestApp) { + t.Helper() + + for _, collection := range []string{ + "monitor_latest_status", + "software_inventory_snapshots", + "audit_logs", + "pipeline_node_runs", + "app_exposures", + "app_operations", + "app_releases", + "pipeline_runs", + "software_operations", + "deployments", + "instances", + "secrets", + "app_instances", + } { + if _, err := app.FindCollectionByNameOrId(collection); err != nil { + continue + } + records, err := app.FindAllRecords(collection) + if err != nil { + t.Fatalf("reset %s: %v", collection, err) + } + for _, record := range records { + if err := app.Delete(record); err != nil { + t.Fatalf("delete %s/%s: %v", collection, record.Id, err) + } + } + } +} \ No newline at end of file diff --git a/backend/domain/worker/worker_test.go b/backend/domain/worker/worker_test.go index 6d986f07..9284bd51 100644 --- a/backend/domain/worker/worker_test.go +++ b/backend/domain/worker/worker_test.go @@ -6,19 +6,13 @@ import ( "testing" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/deploy" - - _ "github.com/websoft9/appos/backend/infra/migrations" ) func TestRecoverOrphanedDeploymentsMarksFailed(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) w := New(app) + var err error if _, err := app.FindCollectionByNameOrId("deployments"); err != nil { if err := w.recoverOrphanedDeployments(); err != nil { @@ -46,12 +40,9 @@ func TestRecoverOrphanedDeploymentsMarksFailed(t *testing.T) { } func TestRecoverOrphanedDeploymentsEscalatesSnapshotToManualIntervention(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) w := New(app) + var err error if _, err := app.FindCollectionByNameOrId("deployments"); err != nil { if err := w.recoverOrphanedDeployments(); err != nil { @@ -79,12 +70,9 @@ func TestRecoverOrphanedDeploymentsEscalatesSnapshotToManualIntervention(t *test } func TestClaimQueuedDeploymentRejectsActivePeer(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) w := New(app) + var err error if _, err := app.FindCollectionByNameOrId("deployments"); err != nil { claimed, claimErr := w.claimQueuedDeployment("legacy-id") @@ -114,11 +102,7 @@ func TestClaimQueuedDeploymentRejectsActivePeer(t *testing.T) { } func TestSyncAppInstanceFromDeploymentUsesLifecycleFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newWorkerTestApp(t) oldIACBasePath := deploymentComposeIACBasePath deploymentComposeIACBasePath = filepath.Join(t.TempDir(), "apps", "installed") defer func() { diff --git a/backend/infra/migrations/migrations_test.go b/backend/infra/migrations/migrations_test.go index 59beaa1a..e0fd0e33 100644 --- a/backend/infra/migrations/migrations_test.go +++ b/backend/infra/migrations/migrations_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tests" "github.com/websoft9/appos/backend/domain/config/sysconfig" "github.com/websoft9/appos/backend/domain/lifecycle/model" "github.com/websoft9/appos/backend/domain/secrets" @@ -17,11 +16,7 @@ import ( // TestResourceCollectionsCreated verifies that all resource collections // are created after running migrations. func TestResourceCollectionsCreated(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) expected := []string{ "secrets", @@ -56,11 +51,7 @@ func TestResourceCollectionsCreated(t *testing.T) { } func TestAppInstancesCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("app_instances") if err != nil { @@ -102,11 +93,7 @@ func TestAppInstancesCollectionFields(t *testing.T) { } func TestAppOperationsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("app_operations") if err != nil { @@ -161,11 +148,7 @@ func TestAppOperationsCollectionFields(t *testing.T) { } func TestAuditLogsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("audit_logs") if err != nil { @@ -182,11 +165,7 @@ func TestAuditLogsCollectionFields(t *testing.T) { } func TestAppReleasesCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("app_releases") if err != nil { @@ -215,11 +194,7 @@ func TestAppReleasesCollectionFields(t *testing.T) { } func TestAppExposuresCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("app_exposures") if err != nil { @@ -248,11 +223,7 @@ func TestAppExposuresCollectionFields(t *testing.T) { } func TestPipelineRunsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("pipeline_runs") if err != nil { @@ -277,11 +248,7 @@ func TestPipelineRunsCollectionFields(t *testing.T) { } func TestPipelineNodeRunsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("pipeline_node_runs") if err != nil { @@ -310,11 +277,7 @@ func TestPipelineNodeRunsCollectionFields(t *testing.T) { // TestSecretsCollectionFields verifies the secrets collection schema. func TestSecretsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("secrets") if err != nil { @@ -366,11 +329,7 @@ func TestSecretsCollectionFields(t *testing.T) { // TestServersCollectionFields verifies the servers collection schema and relations. func TestServersCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("servers") if err != nil { @@ -398,11 +357,7 @@ func TestServersCollectionFields(t *testing.T) { // TestEnvSetVarsCollectionFields verifies env_set_vars schema and relations. func TestEnvSetVarsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("env_set_vars") if err != nil { @@ -431,11 +386,7 @@ func TestEnvSetVarsCollectionFields(t *testing.T) { // TestDatabasesCollectionFields verifies databases schema and relations. func TestDatabasesCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("databases") if err != nil { @@ -456,11 +407,7 @@ func TestDatabasesCollectionFields(t *testing.T) { // TestCloudAccountsCollectionFields verifies cloud_accounts schema and relations. func TestCloudAccountsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("cloud_accounts") if err != nil { @@ -480,11 +427,7 @@ func TestCloudAccountsCollectionFields(t *testing.T) { // TestCertificatesCollectionFields verifies certificates schema and relations. func TestCertificatesCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("certificates") if err != nil { @@ -593,11 +536,7 @@ func assertSelectFieldValues(t *testing.T, col *core.Collection, fieldName strin // ═══════════════════════════════════════════════════════════ func TestAppsCollectionExists(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("apps") if err != nil { @@ -609,11 +548,7 @@ func TestAppsCollectionExists(t *testing.T) { } func TestAppsCollectionResourceFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("apps") if err != nil { @@ -641,11 +576,7 @@ func TestAppsCollectionResourceFields(t *testing.T) { // ═══════════════════════════════════════════════════════════ func TestGroupsCollectionExists(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("groups") if err != nil { @@ -657,11 +588,7 @@ func TestGroupsCollectionExists(t *testing.T) { } func TestGroupsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("groups") if err != nil { @@ -700,11 +627,7 @@ func TestGroupsCollectionFields(t *testing.T) { } func TestGroupItemsCollectionExists(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("group_items") if err != nil { @@ -716,11 +639,7 @@ func TestGroupItemsCollectionExists(t *testing.T) { } func TestGroupItemsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("group_items") if err != nil { @@ -771,11 +690,8 @@ func TestGroupItemsCollectionFields(t *testing.T) { // TestResourceGroupsCollectionRemoved verifies that the legacy resource_groups // collection no longer exists after the migration runs. func TestResourceGroupsCollectionRemoved(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) + var err error _, err = app.FindCollectionByNameOrId("resource_groups") if err == nil { @@ -786,11 +702,7 @@ func TestResourceGroupsCollectionRemoved(t *testing.T) { // TestResourceCollectionsHaveNoGroupsField verifies that the legacy groups // relation field has been removed from all 8 resource collections. func TestResourceCollectionsHaveNoGroupsField(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) collections := []string{ "servers", "secrets", "env_sets", @@ -812,11 +724,7 @@ func TestResourceCollectionsHaveNoGroupsField(t *testing.T) { // TestGroupsAndGroupItemsExistAfterMigration verifies that the new groups and // group_items collections are present (created by Story 21.1). func TestGroupsAndGroupItemsExistAfterMigration(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) if _, err := app.FindCollectionByNameOrId("groups"); err != nil { t.Error("groups collection not found after migration:", err) @@ -827,11 +735,7 @@ func TestGroupsAndGroupItemsExistAfterMigration(t *testing.T) { } func TestInstancesCollectionExistsAfterMigration(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("instances") if err != nil { @@ -847,11 +751,7 @@ func TestInstancesCollectionExistsAfterMigration(t *testing.T) { } func TestProviderAccountsCollectionExistsAfterMigration(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("provider_accounts") if err != nil { @@ -868,11 +768,7 @@ func TestProviderAccountsCollectionExistsAfterMigration(t *testing.T) { } func TestConnectorsCollectionHasProviderAccountRelation(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("connectors") if err != nil { @@ -885,11 +781,7 @@ func TestConnectorsCollectionHasProviderAccountRelation(t *testing.T) { } func TestAIProvidersCollectionExistsAfterMigration(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("ai_providers") if err != nil { @@ -912,11 +804,7 @@ func TestAIProvidersCollectionExistsAfterMigration(t *testing.T) { // TestLegacyEnvGroupsRemoved verifies that old env_groups / env_group_vars // collections no longer exist after the migration. func TestLegacyEnvGroupsRemoved(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) if _, err := app.FindCollectionByNameOrId("env_groups"); err == nil { t.Error("env_groups collection should not exist after Epic 24 migration") @@ -928,11 +816,7 @@ func TestLegacyEnvGroupsRemoved(t *testing.T) { // TestEnvSetsCollectionFields verifies the env_sets collection schema. func TestEnvSetsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("env_sets") if err != nil { @@ -965,11 +849,7 @@ func TestEnvSetsCollectionFields(t *testing.T) { // TestAppsEnvSetsField verifies that apps collection has env_sets relation field. func TestAppsEnvSetsField(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("apps") if err != nil { @@ -988,11 +868,7 @@ func TestAppsEnvSetsField(t *testing.T) { // TestEnvSetVarsCascadeDelete verifies that deleting an env_set cascades // to child env_set_vars records. func TestEnvSetVarsCascadeDelete(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newIsolatedMigrationsTestApp(t) // Create an env_set setCol, _ := app.FindCollectionByNameOrId("env_sets") @@ -1026,11 +902,7 @@ func TestEnvSetVarsCascadeDelete(t *testing.T) { // TestEnvSetVarsSecretExpandHidesPayload verifies that expanding the secret // relation on env_set_vars does NOT expose payload_encrypted. func TestEnvSetVarsSecretExpandHidesPayload(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) // Verify secrets.payload_encrypted is hidden secretsCol, err := app.FindCollectionByNameOrId("secrets") @@ -1047,11 +919,7 @@ func TestEnvSetVarsSecretExpandHidesPayload(t *testing.T) { } func TestSoftwareInventorySnapshotsCollectionFields(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) col, err := app.FindCollectionByNameOrId("software_inventory_snapshots") if err != nil { @@ -1084,11 +952,7 @@ func TestSoftwareInventorySnapshotsCollectionFields(t *testing.T) { } func TestSecretsPolicySeedExists(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newMigrationsTestApp(t) value, err := sysconfig.GetGroup(app, "secrets", "policy", nil) if err != nil { diff --git a/backend/infra/migrations/test_fixture_test.go b/backend/infra/migrations/test_fixture_test.go new file mode 100644 index 00000000..52be29ef --- /dev/null +++ b/backend/infra/migrations/test_fixture_test.go @@ -0,0 +1,88 @@ +package migrations_test + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + migrationsTestBaselineOnce sync.Once + migrationsTestBaselineDir string + migrationsTestBaselineErr error + migrationsTestSharedOnce sync.Once + migrationsTestSharedApp *tests.TestApp + migrationsTestSharedErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if migrationsTestSharedApp != nil { + migrationsTestSharedApp.Cleanup() + } + if migrationsTestBaselineDir != "" { + _ = os.RemoveAll(migrationsTestBaselineDir) + } + os.Exit(code) +} + +func migrationsTestBaselineDataDir() (string, error) { + migrationsTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + migrationsTestBaselineErr = err + return + } + + migrationsTestBaselineDir = app.DataDir() + migrationsTestBaselineErr = app.ResetBootstrapState() + }) + + return migrationsTestBaselineDir, migrationsTestBaselineErr +} + +func newMigrationsTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + migrationsTestSharedOnce.Do(func() { + baselineDir, err := migrationsTestBaselineDataDir() + if err != nil { + migrationsTestSharedErr = err + return + } + + migrationsTestSharedApp, err = tests.NewTestApp(baselineDir) + if err != nil { + migrationsTestSharedErr = err + } + }) + if migrationsTestSharedErr != nil { + t.Fatal(migrationsTestSharedErr) + } + + return migrationsTestSharedApp +} + +func newIsolatedMigrationsTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + baselineDir, err := migrationsTestBaselineDataDir() + if err != nil { + t.Fatal(err) + } + + app, err := tests.NewTestApp(baselineDir) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + app.Cleanup() + }) + + return app +} \ No newline at end of file diff --git a/backend/infra/persistence/connector_repository_test.go b/backend/infra/persistence/connector_repository_test.go index cd8dde7c..ec53e4ca 100644 --- a/backend/infra/persistence/connector_repository_test.go +++ b/backend/infra/persistence/connector_repository_test.go @@ -29,11 +29,7 @@ func createPersistenceProviderAccountRecord(t *testing.T, app *tests.TestApp, na } func TestConnectorRepositorySaveGetDelete(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) providerAccount := createPersistenceProviderAccountRecord(t, app, "ops-root") repo := NewConnectorRepository(app) @@ -75,11 +71,7 @@ func TestConnectorRepositorySaveGetDelete(t *testing.T) { } func TestConnectorRepositoryClearDefaultsByKind(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) repo := NewConnectorRepository(app) first, _ := repo.New() @@ -114,11 +106,8 @@ func TestConnectorRepositoryClearDefaultsByKind(t *testing.T) { } func TestConnectorRepositorySaveMapsDuplicateNameToConflict(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) + var err error repo := NewConnectorRepository(app) first, _ := repo.New() @@ -140,11 +129,7 @@ func TestConnectorRepositorySaveMapsDuplicateNameToConflict(t *testing.T) { } func TestConnectorRepositoryExistsByName(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) repo := NewConnectorRepository(app) item, _ := repo.New() diff --git a/backend/infra/persistence/instance_repository_test.go b/backend/infra/persistence/instance_repository_test.go index db18fb89..a361c272 100644 --- a/backend/infra/persistence/instance_repository_test.go +++ b/backend/infra/persistence/instance_repository_test.go @@ -29,11 +29,7 @@ func createProviderAccountRecord(t *testing.T, app *tests.TestApp, name string) } func TestInstanceRepositorySaveGetDelete(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) providerAccount := createProviderAccountRecord(t, app, "platform-root") repo := NewInstanceRepository(app) @@ -81,11 +77,8 @@ func TestInstanceRepositorySaveGetDelete(t *testing.T) { } func TestInstanceRepositorySaveMapsDuplicateNameToConflict(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) + var err error repo := NewInstanceRepository(app) first, err := repo.New() diff --git a/backend/infra/persistence/provider_account_repository_test.go b/backend/infra/persistence/provider_account_repository_test.go index eb48f169..19f8bedd 100644 --- a/backend/infra/persistence/provider_account_repository_test.go +++ b/backend/infra/persistence/provider_account_repository_test.go @@ -29,11 +29,7 @@ func createProviderAccountReferenceInstance(t *testing.T, app *tests.TestApp, ac } func TestProviderAccountRepositorySaveGetDelete(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) repo := NewProviderAccountRepository(app) item, err := repo.New() @@ -79,11 +75,8 @@ func TestProviderAccountRepositorySaveGetDelete(t *testing.T) { } func TestProviderAccountRepositorySaveMapsDuplicateNameToConflict(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) + var err error repo := NewProviderAccountRepository(app) first, err := repo.New() @@ -121,11 +114,7 @@ func TestProviderAccountRepositorySaveMapsDuplicateNameToConflict(t *testing.T) } func TestProviderAccountRepositoryHasReferences(t *testing.T) { - app, err := tests.NewTestApp() - if err != nil { - t.Fatal(err) - } - defer app.Cleanup() + app := newPersistenceTestApp(t) repo := NewProviderAccountRepository(app) item, err := repo.New() diff --git a/backend/infra/persistence/test_fixture_test.go b/backend/infra/persistence/test_fixture_test.go new file mode 100644 index 00000000..3bbd4ab5 --- /dev/null +++ b/backend/infra/persistence/test_fixture_test.go @@ -0,0 +1,85 @@ +package persistence + +import ( + "os" + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tests" + + _ "github.com/websoft9/appos/backend/infra/migrations" +) + +var ( + persistenceTestBaselineOnce sync.Once + persistenceTestBaselineDir string + persistenceTestBaselineErr error + persistenceTestSharedOnce sync.Once + persistenceTestSharedApp *tests.TestApp + persistenceTestSharedErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if persistenceTestSharedApp != nil { + persistenceTestSharedApp.Cleanup() + } + if persistenceTestBaselineDir != "" { + _ = os.RemoveAll(persistenceTestBaselineDir) + } + os.Exit(code) +} + +func persistenceTestBaselineDataDir() (string, error) { + persistenceTestBaselineOnce.Do(func() { + app, err := tests.NewTestApp() + if err != nil { + persistenceTestBaselineErr = err + return + } + + persistenceTestBaselineDir = app.DataDir() + persistenceTestBaselineErr = app.ResetBootstrapState() + }) + + return persistenceTestBaselineDir, persistenceTestBaselineErr +} + +func newPersistenceTestApp(t *testing.T) *tests.TestApp { + t.Helper() + + persistenceTestSharedOnce.Do(func() { + baselineDir, err := persistenceTestBaselineDataDir() + if err != nil { + persistenceTestSharedErr = err + return + } + + persistenceTestSharedApp, err = tests.NewTestApp(baselineDir) + if err != nil { + persistenceTestSharedErr = err + } + }) + if persistenceTestSharedErr != nil { + t.Fatal(persistenceTestSharedErr) + } + + resetPersistenceTestState(t, persistenceTestSharedApp) + return persistenceTestSharedApp +} + +func resetPersistenceTestState(t *testing.T, app *tests.TestApp) { + t.Helper() + + for _, collection := range []string{"connectors", "instances", "provider_accounts"} { + records, err := app.FindAllRecords(collection) + if err != nil { + t.Fatalf("reset %s: %v", collection, err) + } + for _, record := range records { + if err := app.Delete(record); err != nil { + t.Fatalf("delete %s/%s: %v", collection, record.Id, err) + } + } + } +} \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index cae503b9..9e9c389e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1 +1,46 @@ -# Tests \ No newline at end of file +# Tests + +## Overview + +This repository uses a mix of backend Go tests, frontend Vitest tests, and end-to-end coverage under `tests/e2e`. + +Common entrypoints: + +- `make test backend` +- `make test web` +- `make test e2e fast` + +## Backend Test Infrastructure + +The heaviest backend integration tests live in `backend/domain/routes`. Those tests depend on PocketBase test apps and route-level HTTP fixtures. + +### PocketBase baseline fixture for `backend/domain/routes` + +The `backend/domain/routes` test package uses a package-level baseline data directory in `backend/domain/routes/resources_test.go`. + +How it works: + +1. On first use, the test package creates one PocketBase test app and lets it finish bootstrapping and migrations. +2. The package keeps the migrated `DataDir` as a baseline template. +3. Each individual test still creates its own isolated `tests.TestApp`, but it is cloned from the migrated baseline instead of starting from scratch. + +This keeps test isolation intact while avoiding repeated migration cost for every `newTestEnv(t)` call. + +### Why this exists + +Before this fixture change, `backend/domain/routes` created a fresh PocketBase app for every test case, and each app reran the full migration set. That package has a large number of `newTestEnv(t)` calls, so repeated migrations became the dominant cost and eventually caused `go test ./domain/routes` and `make test backend` to time out. + +The baseline-clone approach reduces initialization cost enough to keep the package testable while preserving per-test isolation. + +### Guardrails + +- Do not replace the baseline-clone fixture with a single shared live app instance across the whole package. +- Shared live app state makes route tests order-dependent and breaks isolation. +- If you add more `newTestEnv(t)`-style helpers in heavy backend packages, prefer the same pattern: migrate once per package, clone per test. + +## When adding tests + +- Keep fixtures local to the package that owns the behavior. +- Prefer isolated app/data-dir state for backend route tests. +- Reuse existing helpers before introducing a second fixture style for the same package. +- If a package repeatedly bootstraps PocketBase in many tests, measure whether a migrated baseline directory should be introduced there too. \ No newline at end of file diff --git a/web/src/components/monitor/MonitorTargetPanel.test.tsx b/web/src/components/monitor/MonitorTargetPanel.test.tsx index f25ecdc8..99224a1c 100644 --- a/web/src/components/monitor/MonitorTargetPanel.test.tsx +++ b/web/src/components/monitor/MonitorTargetPanel.test.tsx @@ -10,6 +10,12 @@ vi.mock('@/lib/pb', () => ({ }, })) +vi.mock('@/components/monitor/TimeSeriesChart', () => ({ + TimeSeriesChart: ({ name }: { name: string }) => ( +
{name} chart
+ ), +})) + describe('MonitorTargetPanel', () => { beforeEach(() => { sendMock.mockReset() diff --git a/web/src/pages/apps/AppDetailPage.test.tsx b/web/src/pages/apps/AppDetailPage.test.tsx index c86ef7dc..2d2acfe6 100644 --- a/web/src/pages/apps/AppDetailPage.test.tsx +++ b/web/src/pages/apps/AppDetailPage.test.tsx @@ -25,6 +25,45 @@ vi.mock('@/lib/iac-api', () => ({ iacSaveFile: (...args: unknown[]) => iacSaveFileMock(...args), })) +vi.mock('@/pages/apps/AppDetailDisplaySection', () => ({ + AppDetailDisplaySection: ({ appName }: { appName: string }) => ( +
+
{appName}
+
+ ), +})) + +vi.mock('@/pages/apps/AppDetailActionHistoryTable', () => ({ + AppDetailActionHistoryTable: ({ + actions, + buildActionDetailHref, + }: { + actions: Array<{ id: string; compose_project_name?: string; pipeline_selector?: { operation_type?: string } }> + buildActionDetailHref: (actionId: string) => string + }) => ( +
+ {actions.map(action => ( +
+ + {action.pipeline_selector?.operation_type + ? action.pipeline_selector.operation_type.charAt(0).toUpperCase() + + action.pipeline_selector.operation_type.slice(1) + : action.id} + + Open Detail + {action.compose_project_name || '-'} +
+ ))} +
+ ), +})) + +vi.mock('@/components/monitor/TimeSeriesChart', () => ({ + TimeSeriesChart: ({ title }: { title?: string }) => ( +
{title || 'Time Series Chart'}
+ ), +})) + describe('AppDetailPage', () => { let windowOpenMock: ReturnType let appDetailResponse: Record @@ -514,7 +553,9 @@ describe('AppDetailPage', () => { expect(screen.getByRole('heading', { name: 'Demo App' })).toBeInTheDocument() }) - expect(screen.getByText('Server: Remote Demo Server')).toBeInTheDocument() + expect( + screen.getByText((_, node) => node?.textContent === 'Server: Remote Demo Server') + ).toBeInTheDocument() expect(screen.getByText('Connection summary: SSH access is reachable.')).toBeInTheDocument() expect(screen.getByText('Endpoint: 10.0.0.8')).toBeInTheDocument() expect(screen.getByText('Next server step: Open Terminal')).toBeInTheDocument() diff --git a/web/src/pages/deploy/CreateDeploymentPage.test.tsx b/web/src/pages/deploy/CreateDeploymentPage.test.tsx index 646b2b11..ed2f4c49 100644 --- a/web/src/pages/deploy/CreateDeploymentPage.test.tsx +++ b/web/src/pages/deploy/CreateDeploymentPage.test.tsx @@ -1,4 +1,5 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { CreateDeploymentPage } from './CreateDeploymentPage' @@ -42,6 +43,95 @@ vi.mock('@/lib/iac-api', () => ({ iacMkdir: (...args: unknown[]) => iacMkdirMock(...args), })) +vi.mock('@/pages/deploy/OrchestrationSection', () => ({ + OrchestrationSection: ({ + compose, + setCompose, + envVars, + setEnvVars, + setSrcFiles, + onYamlError, + onRuntimeEnvInputsChange, + }: { + compose: string + setCompose: (value: string) => void + envVars: Array<{ key: string; value: string }> + setEnvVars: React.Dispatch< + React.SetStateAction> + > + setSrcFiles: React.Dispatch> + onYamlError?: (error: string | null) => void + onRuntimeEnvInputsChange?: ( + inputs: Array<{ name: string; kind: 'sensitive'; generator_method?: string }> + ) => void + }) => { + useEffect(() => { + onYamlError?.(null) + }, [compose, onYamlError]) + + useEffect(() => { + const inputs = envVars + .filter(item => item.key.trim()) + .map(item => ({ + name: item.key.trim(), + kind: 'sensitive' as const, + generator_method: 'password_16', + })) + onRuntimeEnvInputsChange?.(inputs) + }, [envVars, onRuntimeEnvInputsChange]) + + const primaryEnv = envVars[0] ?? { key: '', value: '' } + + return ( +
+ +