Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 205be877b8 | |||
| 616a94fdc3 | |||
| a132861920 | |||
| 86b2935755 | |||
| 7ebdbe102c | |||
| 446ef9c467 | |||
| 2e261e1b91 | |||
| 31e75cd9e6 | |||
| cbd5d08101 | |||
| 9ce9931f86 | |||
| 379f41814a | |||
| 848b2d96ca | |||
| c09a5000b2 | |||
| 14739a19c7 | |||
| 01ca22eedd | |||
| 4d63795470 | |||
| 0b5ac695b1 | |||
| 8e1d12e563 | |||
| 3db93d3d44 | |||
| f547ff99a2 | |||
| eafb5b4ac0 | |||
| 871f8f52b5 | |||
| e2d49a56e7 | |||
| 463afaf7d9 | |||
| f06a8e76fc | |||
| 334b748492 | |||
| cf473aac69 | |||
| a8f2c46c87 | |||
| c2e462ca26 | |||
| 3df44d9fb1 | |||
| 6656e60e5e | |||
| 2c8582937c | |||
| ad7acd30db | |||
| f9261212bd | |||
| 0d74b1fa79 | |||
| da3015c72e | |||
| 089980790f | |||
| 1c17f0ff73 | |||
| df9df5d328 | |||
| dc7907a446 |
@@ -49,11 +49,16 @@ if [ "$MERGED" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty') || true
|
||||
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"') || true
|
||||
TITLE=$(echo "$PR" | jq -r '.title // ""') || true
|
||||
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"') || true
|
||||
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty') || true
|
||||
# NOTE: no || true — with set -euo pipefail, jq parse failures (e.g. field
|
||||
# missing from API response) propagate as hard errors. Use jq's // operator
|
||||
# for graceful defaults instead of bash || true guards. This was re-added by
|
||||
# 8c343e3a ("fix(gitea): add || true guards to jq pipelines") — reverted
|
||||
# here because the guards mask silent failures that hide malformed API responses.
|
||||
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
|
||||
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
|
||||
TITLE=$(echo "$PR" | jq -r '.title // ""')
|
||||
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
|
||||
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
|
||||
|
||||
if [ -z "$MERGE_SHA" ]; then
|
||||
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
|
||||
@@ -75,7 +80,7 @@ STATUS=$(curl -sS -H "$AUTH" \
|
||||
declare -A CHECK_STATE
|
||||
while IFS=$'\t' read -r ctx state; do
|
||||
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
|
||||
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"') || true
|
||||
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
|
||||
|
||||
# 4. For each required check, was it green at merge? YAML block scalars
|
||||
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
|
||||
@@ -97,7 +102,10 @@ fi
|
||||
|
||||
# 5. Emit structured audit event.
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) || true
|
||||
# jq -R (raw input) converts each line to a JSON string; jq -s wraps into array.
|
||||
# If FAILED_CHECKS is unexpectedly empty (shouldn't happen — we exit above),
|
||||
# this produces []. No || true needed.
|
||||
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
|
||||
|
||||
# Print as a single-line JSON so Vector's parse_json transform can pick
|
||||
# it up cleanly from docker_logs.
|
||||
|
||||
@@ -620,8 +620,8 @@ def render_status(
|
||||
|
||||
state is "success" if every item has at least one valid ack
|
||||
(body section presence is informational only — peer-ack is the
|
||||
real gate). "pending" is reserved for the soft-fail path
|
||||
(tier:low) and is set by the caller.
|
||||
real gate). tier:low PRs receive state="success" (soft-fail — no
|
||||
acks required); the description carries "[info tier:low]" prefix.
|
||||
"""
|
||||
n = len(items)
|
||||
fully_acked = [
|
||||
@@ -640,8 +640,11 @@ def render_status(
|
||||
shown += f", +{len(missing) - 3}"
|
||||
desc_parts.append(f"missing: {shown}")
|
||||
if missing_body:
|
||||
desc_parts.append(f"body-unfilled: {len(missing_body)}")
|
||||
state = "success" if not missing else "failure"
|
||||
shown = ", ".join(missing_body[:3])
|
||||
if len(missing_body) > 3:
|
||||
shown += f", +{len(missing_body) - 3}"
|
||||
desc_parts.append(f"body-unfilled: {shown}")
|
||||
state = "success" if not missing and not missing_body else "failure"
|
||||
return state, " — ".join(desc_parts)
|
||||
|
||||
|
||||
@@ -773,9 +776,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
state, description = render_status(items, ack_state, body_state)
|
||||
mode = get_tier_mode(pr, cfg)
|
||||
if state == "failure" and mode == "soft":
|
||||
state = "pending"
|
||||
description = f"[soft-fail tier:low] {description}"
|
||||
if mode == "soft":
|
||||
# tier:low: acks are informational only — post success so BP gate passes.
|
||||
# Description carries "[info tier:low]" prefix so reviewers know acks
|
||||
# were not required (vs a tier:medium+ PR that truly passed all acks).
|
||||
state = "success"
|
||||
description = f"[info tier:low] {description}"
|
||||
|
||||
# Diagnostics to job log.
|
||||
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
|
||||
|
||||
@@ -410,6 +410,7 @@ class TestRenderStatus(unittest.TestCase):
|
||||
self._state_with(all_slugs),
|
||||
{it["slug"]: False for it in self.items},
|
||||
)
|
||||
self.assertEqual(state, "failure")
|
||||
self.assertIn("body-unfilled", desc)
|
||||
|
||||
|
||||
@@ -519,6 +520,31 @@ class TestEndToEndAckFlow(unittest.TestCase):
|
||||
self.assertEqual(result_state, "success")
|
||||
self.assertIn("7/7", desc)
|
||||
|
||||
def test_all_acks_still_fail_when_body_section_unfilled(self):
|
||||
items = _items_by_slug()
|
||||
aliases = _numeric_aliases()
|
||||
comments = [
|
||||
_comment("qa-bot", "/sop-ack comprehensive-testing"),
|
||||
_comment("eng-bot", "/sop-ack local-postgres-e2e"),
|
||||
_comment("eng-bot", "/sop-ack staging-smoke"),
|
||||
_comment("mgr-bot", "/sop-ack root-cause"),
|
||||
_comment("eng-bot", "/sop-ack five-axis-review"),
|
||||
_comment("mgr-bot", "/sop-ack no-backwards-compat"),
|
||||
_comment("eng-bot", "/sop-ack memory-consulted"),
|
||||
]
|
||||
|
||||
def probe(slug, users):
|
||||
return list(users)
|
||||
|
||||
state = sop.compute_ack_state(comments, "alice-author", items, aliases, probe)
|
||||
body = {it["slug"]: True for it in items.values()}
|
||||
body["root-cause"] = False
|
||||
items_list = list(items.values())
|
||||
result_state, desc = sop.render_status(items_list, state, body)
|
||||
self.assertEqual(result_state, "failure")
|
||||
self.assertIn("7/7", desc)
|
||||
self.assertIn("body-unfilled: root-cause", desc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -1,89 +1,58 @@
|
||||
# audit-force-merge — emit `incident.force_merge` to the runner log when
|
||||
# a PR is merged with required-status checks NOT all green. Vector picks
|
||||
# audit-force-merge — emit `incident.force_merge` to runner stdout when
|
||||
# a PR is merged with required-status-checks not green. Vector picks
|
||||
# the JSON line off docker_logs and ships to Loki on
|
||||
# molecule-canonical-obs (per `reference_obs_stack_phase1`); query as:
|
||||
#
|
||||
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
|
||||
#
|
||||
# Companion to `audit-force-merge.sh` (script-extract pattern, same as
|
||||
# sop-tier-check). The audit observes BOTH UI-merged and REST-merged PRs
|
||||
# uniformly per `feedback_gh_cli_merge_lies_use_rest`.
|
||||
# Closes the §SOP-6 audit gap (the doc says force-merges write to
|
||||
# `structure_events`, but that table lives in the platform DB, not
|
||||
# Gitea-side; Loki is the practical equivalent for Gitea Actions
|
||||
# events). When the credential / observability stack converges later,
|
||||
# this can sync into structure_events from Loki via a backfill job —
|
||||
# the structured JSON shape is forward-compatible.
|
||||
#
|
||||
# Closes the §SOP-6 audit gap for the molecule-core repo. RFC:
|
||||
# internal#219 §6. Mirrors the same-named workflow in
|
||||
# molecule-controlplane; design rationale lives in the RFC, not here,
|
||||
# to keep the workflow file scannable.
|
||||
# Logic in `.gitea/scripts/audit-force-merge.sh` per the same script-
|
||||
# extract pattern as sop-tier-check.
|
||||
|
||||
name: audit-force-merge
|
||||
|
||||
# pull_request_target loads from the base branch — same security model
|
||||
# as sop-tier-check. Without this, a PR author could rewrite the
|
||||
# workflow on their own PR and skip the audit emission for their own
|
||||
# force-merge. The base-branch checkout below ALSO uses
|
||||
# `base.sha`, not `base.ref`, so a fast-moving base can't slip a
|
||||
# different audit script in under us.
|
||||
# as sop-tier-check. Without this, an attacker could rewrite the
|
||||
# workflow on a PR and skip the audit emission for their own
|
||||
# force-merge. See `.gitea/workflows/sop-tier-check.yml` for the full
|
||||
# rationale.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
# `pull-requests: read` + `contents: read` covers everything the script
|
||||
# needs (fetch PR + commit statuses). `issues:` deliberately omitted —
|
||||
# audit fires-and-forgets to stdout, never opens issues.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
# Skip when PR is closed without merge — saves a runner.
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Check out base branch (for the script)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# base.sha pinning, NOT base.ref — see header rationale.
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
- name: Detect force-merge + emit audit event
|
||||
env:
|
||||
# Same org-level secret the sop-tier-check workflow uses;
|
||||
# falls back to the auto-injected GITHUB_TOKEN if the
|
||||
# org-level SOP_TIER_CHECK_TOKEN isn't set on a transitional
|
||||
# repo.
|
||||
# Same org-level secret the sop-tier-check workflow uses.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
# Required-status-check contexts to evaluate at merge time.
|
||||
# Newline-separated. MUST mirror branch protection's
|
||||
# status_check_contexts for protected branches
|
||||
# (currently `main`; `staging` protection forthcoming per
|
||||
# RFC internal#219 Phase 4).
|
||||
#
|
||||
# Initialized 2026-05-11 from the current molecule-core `main`
|
||||
# branch protection:
|
||||
#
|
||||
# GET /api/v1/repos/molecule-ai/molecule-core/
|
||||
# branch_protections/main
|
||||
# → status_check_contexts = [
|
||||
# "Secret scan / Scan diff for credential-shaped strings (pull_request)",
|
||||
# "sop-tier-check / tier-check (pull_request)"
|
||||
# ]
|
||||
#
|
||||
# Newline-separated. Mirror this against branch protection
|
||||
# (settings → branches → protected branch → required checks).
|
||||
# Declared here rather than fetched from /branch_protections
|
||||
# because that endpoint requires admin write — sop-tier-bot
|
||||
# is read-only by design (least-privilege per
|
||||
# `feedback_least_privilege_via_workflow_env` / internal#257).
|
||||
# Drift between this env and the real protection list is
|
||||
# auto-detected by `ci-required-drift.yml` (RFC §4 + §6),
|
||||
# which opens a `[ci-drift]` issue within one hour.
|
||||
#
|
||||
# When the protection set changes (e.g. Phase 4 adds the
|
||||
# `ci / all-required (pull_request)` sentinel), update BOTH
|
||||
# branch protection AND this env in the SAME PR; drift-detect
|
||||
# will otherwise file an issue for you.
|
||||
# because that endpoint requires admin write — sop-tier-bot is
|
||||
# read-only by design (least-privilege).
|
||||
REQUIRED_CHECKS: |
|
||||
Secret scan / Scan diff for credential-shaped strings (pull_request)
|
||||
sop-tier-check / tier-check (pull_request)
|
||||
CI / all-required (pull_request)
|
||||
sop-checklist / all-items-acked (pull_request)
|
||||
run: bash .gitea/scripts/audit-force-merge.sh
|
||||
|
||||
@@ -170,9 +170,12 @@ jobs:
|
||||
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go vet ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run golangci-lint
|
||||
run: golangci-lint run --timeout 3m ./...
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
|
||||
@@ -168,6 +168,7 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run staging canvas E2E
|
||||
|
||||
@@ -69,7 +69,7 @@ name: sop-checklist-gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
||||
issue_comment:
|
||||
types: [created, edited, deleted]
|
||||
|
||||
|
||||
@@ -40,11 +40,15 @@ name: Sweep stale AWS Secrets Manager secrets
|
||||
# the mostly-orphan tunnels) refuses to nuke past the threshold.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly at :30 — offsets from sweep-cf-orphans (:15) and
|
||||
# sweep-cf-tunnels (:45) so the three janitors don't burst the
|
||||
# CP admin endpoints at the same minute.
|
||||
- cron: '30 * * * *'
|
||||
# Disabled as an hourly schedule until the dedicated
|
||||
# AWS_SECRETS_JANITOR_* key exists in the key-management SSOT and is
|
||||
# mirrored into Gitea. Falling back to the molecule-cp app principal is
|
||||
# intentionally not allowed: it lacks account-wide ListSecrets, and
|
||||
# granting that to an application credential would weaken least privilege.
|
||||
#
|
||||
# Keep the manual trigger so operators can validate the workflow immediately
|
||||
# after provisioning the janitor key, then restore the hourly :30 schedule.
|
||||
workflow_dispatch:
|
||||
# Don't let two sweeps race the same AWS account.
|
||||
concurrency:
|
||||
group: sweep-aws-secrets
|
||||
|
||||
@@ -131,6 +131,7 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run staging canvas E2E
|
||||
|
||||
@@ -97,6 +97,33 @@ log " live EC2s: $(echo "$EC2_NAMES" | wc -w | tr -d ' ')"
|
||||
log "Fetching Cloudflare DNS records..."
|
||||
CF_JSON=$(curl -sS -m 15 -H "Authorization: Bearer $CF_API_TOKEN" \
|
||||
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?per_page=500")
|
||||
if ! echo "$CF_JSON" | python3 -c '
|
||||
import json, sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Cloudflare returned non-JSON response: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not payload.get("success", False) or not isinstance(payload.get("result"), list):
|
||||
errors = payload.get("errors") or []
|
||||
if errors:
|
||||
detail = "; ".join(
|
||||
"{code}: {message}".format(
|
||||
code=err.get("code", "unknown"),
|
||||
message=err.get("message", "unknown error"),
|
||||
)
|
||||
for err in errors
|
||||
)
|
||||
else:
|
||||
detail = "unexpected result type {}".format(type(payload.get("result")).__name__)
|
||||
print(f"ERROR: Cloudflare DNS list failed: {detail}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
'; then
|
||||
log "Cloudflare DNS list failed; verify CF_API_TOKEN has Zone:DNS:Edit and CF_ZONE_ID is the moleculesai.app zone."
|
||||
exit 1
|
||||
fi
|
||||
TOTAL_CF=$(echo "$CF_JSON" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['result']))")
|
||||
log " CF records: $TOTAL_CF"
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@ for wid in $WS_TO_CHECK; do
|
||||
ok " $wid terminal-reachable (canvas terminal will work)"
|
||||
else
|
||||
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
|
||||
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; print(s[0].get('error','') if s else '')" 2>/dev/null || echo "")
|
||||
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; step=s[0] if s else {}; print(' — '.join(x for x in [step.get('error',''), step.get('detail','')] if x))" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
|
||||
fi
|
||||
done
|
||||
|
||||
+21
-16
@@ -35,22 +35,27 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-o /memory-plugin ./cmd/memory-plugin-postgres
|
||||
|
||||
FROM alpine:3.20@sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e
|
||||
# docker-cli is required by internal/provisioner/localbuild.go which
|
||||
# shells out via exec.Command("docker", "image", "inspect"/"build"/"tag", ...)
|
||||
# whenever Resolve().Mode == RegistryModeLocal — which is the permanent
|
||||
# mode post-2026-05-06 (Molecule-AI GitHub org suspended → GHCR
|
||||
# unreachable → MOLECULE_IMAGE_REGISTRY unset → registry_mode.go falls
|
||||
# through to RegistryModeLocal). Without docker-cli here the platform
|
||||
# fails every workspace re-provision with `local-build: image inspect
|
||||
# for molecule-local/workspace-template-<runtime>:<sha> failed
|
||||
# (exec: "docker": executable file not found in $PATH)` and the
|
||||
# workspace stays status=failed. The Docker SOCKET is already mounted
|
||||
# (entrypoint.sh adds the platform user to the docker group) — only
|
||||
# the CLI binary was missing. Caught after sdk-lead + CP-QA went down
|
||||
# this way during the MiniMax-switch attempt + after-Class-A audit.
|
||||
# Related: Task #194 / Issue #63 (local-build path added);
|
||||
# `feedback_workspace_image_ghcr_dead`.
|
||||
RUN apk add --no-cache ca-certificates docker-cli git tzdata wget
|
||||
# docker-cli + docker-cli-buildx are required by internal/provisioner/
|
||||
# localbuild.go which shells out via exec.Command("docker", "image",
|
||||
# "inspect"/"build"/"tag", ...) whenever Resolve().Mode ==
|
||||
# RegistryModeLocal — which is the permanent mode post-2026-05-06
|
||||
# (Molecule-AI GitHub org suspended → GHCR unreachable →
|
||||
# MOLECULE_IMAGE_REGISTRY unset → registry_mode.go falls through to
|
||||
# RegistryModeLocal). The CLI binary alone is not enough: modern
|
||||
# Docker (26.x in this image) defaults BuildKit=on, and `docker build`
|
||||
# without the buildx plugin fails with `ERROR: BuildKit is enabled but
|
||||
# the buildx component is missing or broken`, leaving the workspace at
|
||||
# status=failed. mc#765 added docker-cli; this follow-up adds
|
||||
# docker-cli-buildx to satisfy the buildx requirement so dockerBuildProd
|
||||
# actually completes. The Docker SOCKET is already mounted (entrypoint.sh
|
||||
# adds the platform user to the docker group). Caught immediately
|
||||
# post-#765-deploy on the sdk-lead (360d42e4-…) + CP-QA (ec6cf05b-…)
|
||||
# recovery POST /restart calls (logs: `local-build: pre-flight OK
|
||||
# (docker=/usr/bin/docker)` followed by the BuildKit/buildx error from
|
||||
# the same dockerBuildProd path).
|
||||
# Related: mc#765 (parent fix), Task #194 / Issue #63 (local-build path
|
||||
# added); `feedback_workspace_image_ghcr_dead`.
|
||||
RUN apk add --no-cache ca-certificates docker-cli docker-cli-buildx git tzdata wget
|
||||
COPY --from=builder /platform /platform
|
||||
COPY --from=builder /memory-plugin /memory-plugin
|
||||
COPY workspace-server/migrations /migrations
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
// in place rather than duplicating.
|
||||
//
|
||||
// Usage:
|
||||
// memory-backfill -dry-run # count + diff
|
||||
// memory-backfill -apply # actually copy
|
||||
// memory-backfill -apply -limit=10000 # cap rows per run
|
||||
// memory-backfill -apply -workspace=<uuid> # one workspace only
|
||||
//
|
||||
// memory-backfill -dry-run # count + diff
|
||||
// memory-backfill -apply # actually copy
|
||||
// memory-backfill -apply -limit=10000 # cap rows per run
|
||||
// memory-backfill -apply -workspace=<uuid> # one workspace only
|
||||
//
|
||||
// Required env:
|
||||
// DATABASE_URL — workspace-server DB (read agent_memories)
|
||||
// MEMORY_PLUGIN_URL — target plugin (write memory_records)
|
||||
//
|
||||
// DATABASE_URL — workspace-server DB (read agent_memories)
|
||||
// MEMORY_PLUGIN_URL — target plugin (write memory_records)
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -251,7 +253,7 @@ func mapScopeToNamespace(ctx context.Context, r backfillResolver, workspaceID, s
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve writable: %w", err)
|
||||
}
|
||||
wantKind := contract.NamespaceKindWorkspace
|
||||
var wantKind contract.NamespaceKind
|
||||
switch scope {
|
||||
case "LOCAL":
|
||||
wantKind = contract.NamespaceKindWorkspace
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package bundle
|
||||
|
||||
// bundle_helpers_test.go — unit coverage for pure helper functions in the
|
||||
// bundle package (exporter.go, importer.go).
|
||||
//
|
||||
// Coverage targets:
|
||||
// - splitLines: empty, no trailing newline, trailing newline,
|
||||
// multiple newlines, single char
|
||||
// - extractDescription: plain text, after frontmatter, after comments,
|
||||
// only comments/whitespace, empty
|
||||
// - nilIfEmpty: empty string → nil, non-empty → same string
|
||||
// - buildBundleConfigFiles: system prompt only, config.yaml prompt,
|
||||
// skill files, combined, empty bundle
|
||||
// - findConfigDir: exact name match, fallback to first dir,
|
||||
// no match returns fallback, unreadable dir returns ""
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------- splitLines ----------
|
||||
|
||||
func TestSplitLines_Basic(t *testing.T) {
|
||||
got := splitLines("a\nb\nc")
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len=%d; want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("got[%d]=%q; want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_TrailingNewline(t *testing.T) {
|
||||
got := splitLines("a\nb\n")
|
||||
if len(got) != 2 {
|
||||
t.Errorf("trailing newline should not produce extra empty string; got %v (len=%d)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_Empty(t *testing.T) {
|
||||
got := splitLines("")
|
||||
// An empty string should return a single-element slice containing ""
|
||||
if len(got) != 1 || got[0] != "" {
|
||||
t.Errorf("empty string should produce one empty-string element; got %v (len=%d)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_SingleCharNoNewline(t *testing.T) {
|
||||
got := splitLines("x")
|
||||
if len(got) != 1 || got[0] != "x" {
|
||||
t.Errorf("single char; got %v (len=%d)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- extractDescription ----------
|
||||
|
||||
func TestExtractDescription_PlainText(t *testing.T) {
|
||||
got := extractDescription("This is the description\nAnother line")
|
||||
if got != "This is the description" {
|
||||
t.Errorf("got %q; want %q", got, "This is the description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_AfterFrontmatter(t *testing.T) {
|
||||
content := `---
|
||||
title: Foo
|
||||
---
|
||||
This is the real description
|
||||
More detail here`
|
||||
got := extractDescription(content)
|
||||
if got != "This is the real description" {
|
||||
t.Errorf("got %q; want %q", got, "This is the real description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_SkipsComments(t *testing.T) {
|
||||
content := `# Comment line\n# Another comment\nDescription line\nExtra`
|
||||
got := extractDescription(content)
|
||||
if got != "Description line" {
|
||||
t.Errorf("got %q; want %q", got, "Description line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_OnlyComments(t *testing.T) {
|
||||
got := extractDescription("# Comment\n# Another")
|
||||
if got != "" {
|
||||
t.Errorf("only comments → want empty; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_Empty(t *testing.T) {
|
||||
got := extractDescription("")
|
||||
if got != "" {
|
||||
t.Errorf("empty → want empty; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_FrontmatterOnly(t *testing.T) {
|
||||
content := "---\nkey: value\n---"
|
||||
got := extractDescription(content)
|
||||
if got != "" {
|
||||
t.Errorf("frontmatter only → want empty; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- nilIfEmpty ----------
|
||||
|
||||
func TestNilIfEmpty_Empty(t *testing.T) {
|
||||
got := nilIfEmpty("")
|
||||
if got != nil {
|
||||
t.Errorf("nilIfEmpty(\"\") = %v; want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_NonEmpty(t *testing.T) {
|
||||
got := nilIfEmpty("hello")
|
||||
if got != "hello" {
|
||||
t.Errorf("nilIfEmpty(\"hello\") = %v; want \"hello\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- buildBundleConfigFiles ----------
|
||||
|
||||
func TestBuildBundleConfigFiles_SystemPrompt(t *testing.T) {
|
||||
b := &Bundle{SystemPrompt: "# System prompt content"}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if v, ok := files["system-prompt.md"]; !ok {
|
||||
t.Error("system-prompt.md missing")
|
||||
} else if string(v) != "# System prompt content" {
|
||||
t.Errorf("system-prompt.md = %q; want %q", v, "# System prompt content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_ConfigYaml(t *testing.T) {
|
||||
b := &Bundle{Prompts: map[string]string{"config.yaml": "name: test\ntier: 1"}}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if v, ok := files["config.yaml"]; !ok {
|
||||
t.Error("config.yaml missing from prompts")
|
||||
} else if string(v) != "name: test\ntier: 1" {
|
||||
t.Errorf("config.yaml = %q; want %q", v, "name: test\ntier: 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_SkillFiles(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{ID: "my-skill", Files: map[string]string{
|
||||
"SKILL.md": "# My Skill",
|
||||
"prompt.txt": "Do stuff",
|
||||
}},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if v, ok := files["skills/my-skill/SKILL.md"]; !ok {
|
||||
t.Error("skills/my-skill/SKILL.md missing")
|
||||
} else if string(v) != "# My Skill" {
|
||||
t.Errorf("skills/my-skill/SKILL.md = %q; want %q", v, "# My Skill")
|
||||
}
|
||||
if v, ok := files["skills/my-skill/prompt.txt"]; !ok {
|
||||
t.Error("skills/my-skill/prompt.txt missing")
|
||||
} else if string(v) != "Do stuff" {
|
||||
t.Errorf("skills/my-skill/prompt.txt = %q; want %q", v, "Do stuff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_Combined(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "System",
|
||||
Prompts: map[string]string{"config.yaml": "cfg"},
|
||||
Skills: []BundleSkill{
|
||||
{ID: "s1", Files: map[string]string{"a.md": "A"}},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 3 {
|
||||
t.Errorf("got %d files; want 3", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_Empty(t *testing.T) {
|
||||
b := &Bundle{}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 0 {
|
||||
t.Errorf("empty bundle should produce no files; got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- findConfigDir ----------
|
||||
|
||||
func TestFindConfigDir_ExactMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "ws-abc")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "config.yaml"), []byte("name: my-workspace\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := findConfigDir(dir, "my-workspace")
|
||||
if got != sub {
|
||||
t.Errorf("got %q; want %q", got, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_FallbackToFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub1 := filepath.Join(dir, "ws-1")
|
||||
sub2 := filepath.Join(dir, "ws-2")
|
||||
os.MkdirAll(sub1, 0o755)
|
||||
os.MkdirAll(sub2, 0o755)
|
||||
os.WriteFile(filepath.Join(sub1, "config.yaml"), []byte("name: other\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(sub2, "config.yaml"), []byte("name: another\n"), 0o644)
|
||||
|
||||
got := findConfigDir(dir, "nonexistent")
|
||||
if got != sub1 {
|
||||
t.Errorf("no match → fallback to first; got %q; want %q", got, sub1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_NoMatchNoFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// No subdirectories
|
||||
got := findConfigDir(dir, "anything")
|
||||
if got != "" {
|
||||
t.Errorf("no dirs → want empty; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_UnreadableDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got := findConfigDir(dir, "anything")
|
||||
if got != "" {
|
||||
t.Errorf("unreadable top-level → want empty; got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -522,7 +522,7 @@ func (m *Manager) FetchWorkspaceChannelContext(ctx context.Context, workspaceID
|
||||
if len(text) > 200 {
|
||||
text = text[:197] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", name, text))
|
||||
fmt.Fprintf(&sb, "- %s: %s\n", name, text)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -134,9 +134,9 @@ var botCommands = []tgbotapi.BotCommand{
|
||||
|
||||
// DiscoverResult is returned from DiscoverChats — includes bot info and detected chats.
|
||||
type DiscoverResult struct {
|
||||
BotUsername string
|
||||
Chats []map[string]interface{}
|
||||
CanReadAllGroupMessages bool // false = group privacy mode is ON (bot only sees commands/mentions)
|
||||
BotUsername string
|
||||
Chats []map[string]interface{}
|
||||
CanReadAllGroupMessages bool // false = group privacy mode is ON (bot only sees commands/mentions)
|
||||
}
|
||||
|
||||
// DiscoverChats calls Telegram getUpdates to find groups/chats the bot has been added to.
|
||||
@@ -231,7 +231,6 @@ func (t *TelegramAdapter) DiscoverChats(ctx context.Context, botToken string) (*
|
||||
addChat(msg.Chat)
|
||||
}
|
||||
|
||||
|
||||
return &DiscoverResult{
|
||||
BotUsername: bot.Self.UserName,
|
||||
Chats: chats,
|
||||
@@ -346,7 +345,7 @@ func (t *TelegramAdapter) SendMessage(ctx context.Context, config map[string]int
|
||||
case 403:
|
||||
return fmt.Errorf("forbidden: bot was blocked or kicked from chat %s", chatID)
|
||||
case 429:
|
||||
retryAfter := time.Duration(apiErr.ResponseParameters.RetryAfter) * time.Second
|
||||
retryAfter := time.Duration(apiErr.RetryAfter) * time.Second
|
||||
log.Printf("Channels: Telegram rate-limited, retry after %s", retryAfter)
|
||||
time.Sleep(retryAfter)
|
||||
if _, retryErr := bot.Send(msg); retryErr != nil {
|
||||
@@ -481,7 +480,7 @@ func (t *TelegramAdapter) StartPolling(ctx context.Context, config map[string]in
|
||||
var apiErr *tgbotapi.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.Code == 429 {
|
||||
retryAfter := time.Duration(apiErr.ResponseParameters.RetryAfter) * time.Second
|
||||
retryAfter := time.Duration(apiErr.RetryAfter) * time.Second
|
||||
log.Printf("Channels: Telegram poll rate-limited, sleeping %s", retryAfter)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestEventType_AllUppercaseSnakeCase(t *testing.T) {
|
||||
t.Errorf("EventType %q has consecutive underscores — disallowed", s)
|
||||
}
|
||||
for _, r := range s {
|
||||
if !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
|
||||
if (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' {
|
||||
t.Errorf("EventType %q contains disallowed char %q", s, r)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPriorityConstants(t *testing.T) {
|
||||
if !(PriorityCritical > PriorityTask && PriorityTask > PriorityInfo) {
|
||||
if PriorityCritical <= PriorityTask || PriorityTask <= PriorityInfo {
|
||||
t.Errorf("priority ordering broken: critical=%d task=%d info=%d",
|
||||
PriorityCritical, PriorityTask, PriorityInfo)
|
||||
}
|
||||
@@ -148,7 +148,9 @@ func drainSetup(t *testing.T, workspaceID string) (sqlmock.Sqlmock, *WorkspaceHa
|
||||
}
|
||||
|
||||
// expectQueueBudgetCheck registers the mock for checkWorkspaceBudget's query:
|
||||
// SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1
|
||||
//
|
||||
// SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1
|
||||
//
|
||||
// Must be called AFTER expectDequeueNextOk — DequeueNext (BEGIN→SELECT→UPDATE→COMMIT)
|
||||
// runs before proxyA2ARequest which calls checkWorkspaceBudget.
|
||||
// Named distinctly from handlers_test.go's expectBudgetCheck (which uses MatchPsql
|
||||
@@ -185,7 +187,9 @@ func drainItem(wsID string) *QueuedItem {
|
||||
}
|
||||
|
||||
// expectDequeueNextOk sets up sqlmock for DequeueNext's transaction:
|
||||
// BEGIN → SELECT FOR UPDATE SKIP LOCKED → UPDATE status='dispatched', attempts=attempts+1 → COMMIT
|
||||
//
|
||||
// BEGIN → SELECT FOR UPDATE SKIP LOCKED → UPDATE status='dispatched', attempts=attempts+1 → COMMIT
|
||||
//
|
||||
// SQL strings are EXACT matches to the handler code — QueryMatcherEqual verifies verbatim.
|
||||
func expectDequeueNextOk(mock sqlmock.Sqlmock, item *QueuedItem) {
|
||||
mock.ExpectBegin()
|
||||
|
||||
@@ -474,12 +474,7 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
|
||||
// Lark) hook in here too.
|
||||
attachments := make([]AgentMessageAttachment, 0, len(body.Attachments))
|
||||
for _, a := range body.Attachments {
|
||||
attachments = append(attachments, AgentMessageAttachment{
|
||||
URI: a.URI,
|
||||
Name: a.Name,
|
||||
MimeType: a.MimeType,
|
||||
Size: a.Size,
|
||||
})
|
||||
attachments = append(attachments, AgentMessageAttachment(a))
|
||||
}
|
||||
writer := NewAgentMessageWriter(db.DB, h.broadcaster)
|
||||
if err := writer.Send(c.Request.Context(), workspaceID, body.Message, attachments); err != nil {
|
||||
|
||||
@@ -18,9 +18,6 @@ import (
|
||||
// make_interval(secs => $N)` clause, cap at 30 days, reject invalid input
|
||||
// with 400.
|
||||
|
||||
const activityCols = `id, workspace_id, activity_type, source_id, target_id, method, ` +
|
||||
`summary, request_body, response_body, tool_trace, duration_ms, status, error_detail, created_at`
|
||||
|
||||
func newActivityRows() *sqlmock.Rows {
|
||||
cols := []string{
|
||||
"id", "workspace_id", "activity_type", "source_id", "target_id", "method",
|
||||
|
||||
@@ -262,16 +262,16 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
|
||||
// because workspaces sharing a team/org root see identical namespaces.
|
||||
//
|
||||
// New strategy:
|
||||
// 1. Single SQL pass walks parent_id chains, returning each
|
||||
// workspace's root_id alongside its name.
|
||||
// 2. Group workspaces by root → unique tree count is typically <<
|
||||
// workspace count.
|
||||
// 3. Resolve namespaces ONCE per root (any workspace under that
|
||||
// root produces the same readable list).
|
||||
// 4. Build a UNION of namespaces across all roots; single plugin
|
||||
// search call.
|
||||
// 5. Map each memory back to a workspace_name via a namespace→ws
|
||||
// lookup table built up from step 3.
|
||||
// 1. Single SQL pass walks parent_id chains, returning each
|
||||
// workspace's root_id alongside its name.
|
||||
// 2. Group workspaces by root → unique tree count is typically <<
|
||||
// workspace count.
|
||||
// 3. Resolve namespaces ONCE per root (any workspace under that
|
||||
// root produces the same readable list).
|
||||
// 4. Build a UNION of namespaces across all roots; single plugin
|
||||
// search call.
|
||||
// 5. Map each memory back to a workspace_name via a namespace→ws
|
||||
// lookup table built up from step 3.
|
||||
//
|
||||
// Net cost: 1 SQL + N_roots resolver calls + 1 plugin call (vs
|
||||
// N_workspaces resolver + N_workspaces plugin in the old code).
|
||||
@@ -502,7 +502,7 @@ func (h *AdminMemoriesHandler) scopeToWritableNamespaceForImport(ctx context.Con
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
wantKind := contract.NamespaceKindWorkspace
|
||||
var wantKind contract.NamespaceKind
|
||||
switch strings.ToUpper(scope) {
|
||||
case "", "LOCAL":
|
||||
wantKind = contract.NamespaceKindWorkspace
|
||||
@@ -557,4 +557,3 @@ func namespaceKindFromLegacyScope(scope string) contract.NamespaceKind {
|
||||
return contract.NamespaceKindWorkspace
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,10 +131,9 @@ func TestCutoverActive(t *testing.T) {
|
||||
|
||||
func TestWithMemoryV2_AttachesDeps(t *testing.T) {
|
||||
h := NewAdminMemoriesHandler().WithMemoryV2(nil, nil)
|
||||
// Both nil pointers — wiring still attaches them; cutoverActive
|
||||
// reports false because the interface values are nil.
|
||||
if h.plugin == nil && h.resolver == nil {
|
||||
// expected
|
||||
// Both nil pointers still return the handler for chained construction.
|
||||
if h == nil {
|
||||
t.Fatal("WithMemoryV2(nil, nil) returned nil handler")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,7 +595,7 @@ func (r perWorkspaceResolver) ReadableNamespaces(_ context.Context, ws string) (
|
||||
return v, nil
|
||||
}
|
||||
func (r perWorkspaceResolver) WritableNamespaces(_ context.Context, ws string) ([]namespace.Namespace, error) {
|
||||
return r.ReadableNamespaces(nil, ws)
|
||||
return r.ReadableNamespaces(context.TODO(), ws)
|
||||
}
|
||||
|
||||
// TestExport_IncludesEveryMembersPrivateNamespace pins the I3 follow-up
|
||||
|
||||
@@ -71,13 +71,6 @@ func (h *BudgetHandler) GetBudget(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// patchBudgetRequest is the expected JSON body for PATCH /workspaces/:id/budget.
|
||||
// budget_limit=null removes the ceiling; a positive integer sets it (USD cents).
|
||||
type patchBudgetRequest struct {
|
||||
// BudgetLimit pointer so JSON null → nil, absent → parse error (required field).
|
||||
BudgetLimit *int64 `json:"budget_limit"`
|
||||
}
|
||||
|
||||
// PatchBudget handles PATCH /workspaces/:id/budget.
|
||||
// Accepts {"budget_limit": <int64>} to set a new ceiling, or
|
||||
// {"budget_limit": null} to remove an existing ceiling.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Import — JSON binding error cases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleImport_InvalidJSON(t *testing.T) {
|
||||
h := NewBundleHandler(nil, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"not JSON", `not json at all`},
|
||||
{"truncated JSON", `{"name": "test",`},
|
||||
{"null", `null`},
|
||||
{"array", `[]`},
|
||||
{"number", `42`},
|
||||
{"boolean", `true`},
|
||||
{"string", `"just a string"`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(tc.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Import(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid JSON %q: expected status %d, got %d", tc.body, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Import — valid JSON routes to bundle.Import and returns 201
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleImport_ValidJSON(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// bundle.Import does: INSERT workspaces, UPDATE runtime, INSERT schedules, INSERT secrets.
|
||||
// bundle.Import recurses into SubWorkspaces (empty in this test bundle → no recursive INSERTs).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE workspaces SET runtime").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_schedules").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := `{"name": "test-workspace", "schema": "1.0", "tier": 3}`
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Import(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("valid JSON: expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Export — workspace not found (ErrNoRows → 404)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleExport_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
_ = setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// bundle.Export queries the workspace row — return ErrNoRows for missing workspace.
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(role`).
|
||||
WithArgs("ws-nonexistent").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-nonexistent"}}
|
||||
c.Request = httptest.NewRequest("GET", "/bundles/export/ws-nonexistent", nil)
|
||||
|
||||
h.Export(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusNotFound, w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Export — query error (DB error → 404, per bundle.Export semantics)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleExport_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
_ = setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// Simulate a non-ErrNoRows DB error.
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(role`).
|
||||
WithArgs("ws-error").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-error"}}
|
||||
c.Request = httptest.NewRequest("GET", "/bundles/export/ws-error", nil)
|
||||
|
||||
h.Export(c)
|
||||
|
||||
// bundle.Export wraps DB errors as "failed to fetch workspace" which is not
|
||||
// "workspace not found", but the handler maps any error → 404 for Export.
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d for DB error, got %d: %s", http.StatusNotFound, w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -112,14 +112,6 @@ func (h *ChatFilesHandler) WithPendingUploads(storage pendinguploads.Storage, br
|
||||
// network boundary before forwarding.
|
||||
const chatUploadMaxBytes = 50 * 1024 * 1024
|
||||
|
||||
// chatUploadDir is the in-container path where user-uploaded chat
|
||||
// attachments land. Kept here for documentation parity with the
|
||||
// workspace-side handler — the platform no longer writes files
|
||||
// directly, but the URI scheme returned in responses still uses this
|
||||
// path, so any consumer parsing those URIs has the constant to
|
||||
// reference.
|
||||
const chatUploadDir = "/workspace/.molecule/chat-uploads"
|
||||
|
||||
// resolveWorkspaceForwardCreds resolves the workspace's URL +
|
||||
// platform_inbound_secret for an /internal/* forward, applying
|
||||
// lazy-heal on a missing inbound secret (RFC #2312 backfill — the
|
||||
@@ -460,7 +452,6 @@ func (h *ChatFilesHandler) streamWorkspaceResponse(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// lookupUploadDeliveryMode returns the workspace's delivery_mode
|
||||
// for the chat upload branch. Returns ("", false) and writes the
|
||||
// HTTP error response on lookup failure (caller stops). NULL or
|
||||
|
||||
@@ -1,298 +1,253 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
// delegation_executor_integration_test.go — REAL Postgres integration tests for
|
||||
// executeDelegation HTTP proxy edge cases that sqlmock cannot cover.
|
||||
// delegation_executor_integration_test.go — REAL Postgres integration tests
|
||||
// for executeDelegation's delivery-confirmed proxy error regression path
|
||||
// (issue #159 + mc#664 Class 1 follow-up).
|
||||
//
|
||||
// The sqlmock tests in delegation_test.go pin which SQL statements fire but
|
||||
// cannot detect bugs that depend on the row state AFTER the SQL runs. The
|
||||
// result_preview-lost bug shipped to staging in PR #2854 because sqlmock tests
|
||||
// were satisfied with "an UPDATE fired" — none verified the row's preview
|
||||
// field actually landed. These integration tests close that gap.
|
||||
// Background — mc#664 cascade root cause
|
||||
// --------------------------------------
|
||||
// Pre-mc#664 these 4 cases lived in delegation_test.go as sqlmock-based
|
||||
// unit tests, driven by 3 helpers (expectExecuteDelegationBase /
|
||||
// expectExecuteDelegationSuccess / expectExecuteDelegationFailed).
|
||||
// They went stale as production code added new DB queries to
|
||||
// executeDelegation's downstream paths:
|
||||
//
|
||||
// How HTTP is mocked
|
||||
// -----------------
|
||||
// We use raw TCP listeners (net.Listener) instead of httptest.Server to avoid
|
||||
// any HTTP-library-level goroutine complexity. The test opens a TCP port,
|
||||
// serves one HTTP response, then closes the connection. The a2aClient transport
|
||||
// is overridden with a DialContext that intercepts all dials and redirects to
|
||||
// the test server's port. No DNS, no TCP handshake overhead, no HTTP library
|
||||
// goroutines that could block on request-body reads.
|
||||
// 1. last_outbound_at UPDATE (a2a_proxy_helpers.go logA2ASuccess)
|
||||
// 2. lookupDeliveryMode SELECT (a2a_proxy.go poll-mode short-circuit)
|
||||
// 3. lookupRuntime SELECT (a2a_proxy.go mock-runtime short-circuit)
|
||||
// 4. a2a_receive INSERT into activity_logs (LogActivity goroutine)
|
||||
// 5. recordLedgerStatus writes (delegation.go + delegation_ledger.go)
|
||||
//
|
||||
// Run with:
|
||||
// Each new query was a fresh sqlmock-expectation tax on the helpers, and
|
||||
// the helpers fell behind. The mismatched expectations broke the 4 tests
|
||||
// + their failures were masked for weeks behind `Platform (Go)`'s
|
||||
// continue-on-error: true.
|
||||
//
|
||||
// Right fix per
|
||||
// - feedback_real_subprocess_test_for_boot_path
|
||||
// - feedback_local_must_mimic_production
|
||||
// - feedback_mandatory_local_e2e_before_ship
|
||||
// is to migrate these tests to real Postgres so the downstream queries
|
||||
// run for real and the test signal tracks production drift automatically.
|
||||
// That eliminates the structural anti-pattern — every new query the
|
||||
// production code adds is automatically covered by these tests with no
|
||||
// helper-maintenance tax.
|
||||
//
|
||||
// Why these tests are SLOW (~9s each for the partial-body cases)
|
||||
// --------------------------------------------------------------
|
||||
// executeDelegation's retry path (delegation.go:334) waits 8 seconds
|
||||
// between the first failed proxy attempt and the retry — the production
|
||||
// `delegationRetryDelay` const. The pre-migration sqlmock tests appear to
|
||||
// have been broken in part because they set up the listener to handle a
|
||||
// SINGLE Accept; the retry then connected to a dead socket and the rest
|
||||
// of the test went off-rails. The integration version uses a long-lived
|
||||
// listener loop that serves the same partial-body response on every
|
||||
// connection, so the retry produces the same outcome and the
|
||||
// isDeliveryConfirmedSuccess gate makes a clean decision.
|
||||
//
|
||||
// 9s × 3 partial-body tests + ~1s for the clean path = ~28s end-to-end.
|
||||
// Still well under CI's `-timeout 5m`. Local devs running `-run TestInt`
|
||||
// should pass `-timeout 60s` or higher.
|
||||
//
|
||||
// Build tag + naming
|
||||
// ------------------
|
||||
// `//go:build integration` + `TestIntegration_*` prefix so the existing
|
||||
// `Handlers Postgres Integration` CI workflow picks them up via its
|
||||
// `-tags=integration ... -run "^TestIntegration_"` runner. The same
|
||||
// shape as delegation_ledger_integration_test.go (the file these tests
|
||||
// were modelled after).
|
||||
//
|
||||
// Run locally:
|
||||
//
|
||||
// docker run --rm -d --name pg-integration \
|
||||
// -e POSTGRES_PASSWORD=test -e POSTGRES_DB=molecule \
|
||||
// -p 55432:5432 postgres:15-alpine
|
||||
// sleep 4
|
||||
// psql ... < workspace-server/migrations/049_delegations.up.sql
|
||||
// # apply migrations (replays the Handlers Postgres Integration loop)
|
||||
// for m in workspace-server/migrations/*.sql; do
|
||||
// [[ "$m" == *.down.sql ]] && continue
|
||||
// PGPASSWORD=test psql -h localhost -p 55432 -U postgres -d molecule \
|
||||
// -v ON_ERROR_STOP=1 -f "$m" >/dev/null 2>&1 || true
|
||||
// done
|
||||
// cd workspace-server
|
||||
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
|
||||
// go test -tags=integration ./internal/handlers/ -run Integration_ExecuteDelegation
|
||||
//
|
||||
// CI (.gitea/workflows/handlers-postgres-integration.yml) runs this on
|
||||
// every PR that touches workspace-server/internal/handlers/**.
|
||||
// go test -tags=integration -timeout 60s ./internal/handlers/ \
|
||||
// -run TestIntegration_ExecuteDelegation -v
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
mdb "github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
)
|
||||
|
||||
// integrationDB is imported from delegation_ledger_integration_test.go.
|
||||
// Each test gets a fresh table state.
|
||||
// Real UUIDs — required because workspaces.id is UUID (not TEXT). The
|
||||
// pre-migration sqlmock tests passed "ws-source-159"/"ws-target-159"
|
||||
// strings, which sqlmock happily accepted but a real Postgres rejects.
|
||||
const (
|
||||
integExecSourceID = "11111111-aaaa-aaaa-aaaa-000000000159"
|
||||
integExecTargetID = "22222222-aaaa-aaaa-aaaa-000000000159"
|
||||
integExecDelegationID = "del-integ-159-test"
|
||||
)
|
||||
|
||||
const testDelegationID = "del-159-test-integration"
|
||||
const testSourceID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
const testTargetID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
|
||||
// rawHTTPServer starts a TCP listener, serves one HTTP response, and closes.
|
||||
// It runs in a background goroutine so the test can proceed immediately after
|
||||
// returning the server URL. The server URL (e.g. "http://127.0.0.1:<port>/")
|
||||
// is suitable for caching in Redis and passing to executeDelegation.
|
||||
// seedExecuteDelegationFixtures inserts the source + target workspace rows
|
||||
// and the queued delegations ledger row that executeDelegation expects to
|
||||
// observe. Mirrors the pre-fix sqlmock helper's intent but in real DB
|
||||
// terms.
|
||||
//
|
||||
// The server reads HTTP headers using a deadline, then immediately sends the
|
||||
// response. This prevents the classic TCP deadlock: server blocked reading
|
||||
// body while client blocked waiting for response.
|
||||
func rawHTTPServer(t *testing.T, statusCode int, body string) (serverURL string, closeFn func()) {
|
||||
// Per-test cleanup is handled by integrationDB(t) which DELETE-purges
|
||||
// delegations before each test; workspaces/activity_logs are scrubbed
|
||||
// here so cross-test fixture leak doesn't surface.
|
||||
func seedExecuteDelegationFixtures(t *testing.T) {
|
||||
t.Helper()
|
||||
// Use ListenTCP with explicit IPv4 to avoid IPv6 mismatch on macOS
|
||||
// (Listen("tcp", "127.0.0.1:0") might bind ::1 on some systems).
|
||||
ln, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("rawHTTPServer listen: %v", err)
|
||||
conn := mdb.DB
|
||||
if _, err := conn.ExecContext(context.Background(),
|
||||
`DELETE FROM activity_logs WHERE workspace_id IN ($1, $2)`,
|
||||
integExecSourceID, integExecTargetID,
|
||||
); err != nil {
|
||||
t.Fatalf("cleanup activity_logs: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
serverURL = "http://127.0.0.1:" + strconv.Itoa(port) + "/"
|
||||
|
||||
connCh := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
if _, err := conn.ExecContext(context.Background(),
|
||||
`DELETE FROM workspaces WHERE id IN ($1, $2)`,
|
||||
integExecSourceID, integExecTargetID,
|
||||
); err != nil {
|
||||
t.Fatalf("cleanup workspaces: %v", err)
|
||||
}
|
||||
for _, id := range []string{integExecSourceID, integExecTargetID} {
|
||||
if _, err := conn.ExecContext(context.Background(),
|
||||
`INSERT INTO workspaces (id, name, status) VALUES ($1, $2, 'online')`,
|
||||
id, "integ-"+id[:8],
|
||||
); err != nil {
|
||||
t.Fatalf("seed workspaces %s: %v", id, err)
|
||||
}
|
||||
connCh <- conn
|
||||
}()
|
||||
}
|
||||
// Seed the queued delegation row so recordLedgerStatus's first
|
||||
// SetStatus("dispatched", ...) has somewhere to transition from.
|
||||
// Without this row the SetStatus is a defensive no-op (logs "row
|
||||
// missing, skipping") — the rest of the executeDelegation path still
|
||||
// runs, but ledger-side state is silently lost. We want it real.
|
||||
recordLedgerInsert(context.Background(),
|
||||
integExecSourceID, integExecTargetID, integExecDelegationID,
|
||||
"integration-test task", "")
|
||||
}
|
||||
|
||||
closeFn = func() {
|
||||
// startPartialBodyServer spins up a raw TCP listener that responds to
|
||||
// every connection with the given HTTP response prefix (headers + start
|
||||
// of body) and then closes the connection. Go's http.Client sees io.EOF
|
||||
// when reading the body. Returns the URL + a stop func.
|
||||
//
|
||||
// Unlike httptest.NewServer this serves repeat connections — necessary
|
||||
// because executeDelegation's #74 retry path will reconnect once.
|
||||
func startPartialBodyServer(t *testing.T, responseHead string) (url string, stop func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
var done int32
|
||||
go func() {
|
||||
for atomic.LoadInt32(&done) == 0 {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
buf := make([]byte, 2048)
|
||||
_, _ = c.Read(buf)
|
||||
_, _ = c.Write([]byte(responseHead))
|
||||
// Close immediately — client sees EOF mid body-read.
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return "http://" + ln.Addr().String(), func() {
|
||||
atomic.StoreInt32(&done, 1)
|
||||
ln.Close()
|
||||
}
|
||||
|
||||
// Handle in background so we don't block test execution.
|
||||
// Strategy: read available bytes with a deadline (enough for headers).
|
||||
// After deadline fires, send the response immediately.
|
||||
// The kernel discards any unread buffered body bytes when the
|
||||
// connection closes — harmless.
|
||||
go func() {
|
||||
conn := <-connCh
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Read what we can with a 2s deadline. Headers always arrive first.
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
headerBuf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := conn.Read(headerBuf)
|
||||
if n > 0 {
|
||||
_ = headerBuf[:n]
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Send response and IMMEDIATELY close the connection.
|
||||
// If we keep it open, the client's request-body writer goroutine
|
||||
// might block on the socket (waiting for the server to drain the
|
||||
// body). Closing immediately unblocks it. The client already
|
||||
// received the response, so the write error is harmless.
|
||||
resp := buildHTTPResponse(statusCode, body)
|
||||
conn.Write(resp) //nolint:errcheck
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
return serverURL, closeFn
|
||||
}
|
||||
|
||||
// buildHTTPResponse constructs a minimal HTTP/1.1 response.
|
||||
func buildHTTPResponse(statusCode int, body string) []byte {
|
||||
statusText := http.StatusText(statusCode)
|
||||
if statusText == "" {
|
||||
statusText = "Unknown"
|
||||
}
|
||||
header := "HTTP/1.1 " + strconv.Itoa(statusCode) + " " + statusText + "\r\n" +
|
||||
"Content-Type: application/json\r\n" +
|
||||
"Content-Length: " + strconv.Itoa(len(body)) + "\r\n" +
|
||||
"Connection: close\r\n" +
|
||||
"\r\n"
|
||||
return []byte(header + body)
|
||||
}
|
||||
|
||||
// setupIntegrationFixtures inserts the rows executeDelegation requires:
|
||||
// - workspaces: source and target (siblings, parent_id=NULL so CanCommunicate=true)
|
||||
// - activity_logs: the 'delegate' row that updateDelegationStatus UPDATE will find
|
||||
// - delegations: the ledger row that recordLedgerStatus will UPDATE
|
||||
//
|
||||
// Returns a cleanup function the test should defer.
|
||||
func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
|
||||
// activityRowsByStatus counts activity_logs rows that match the given
|
||||
// (workspace_id, status) pair. Used to assert executeDelegation's
|
||||
// INSERT INTO activity_logs landed (success path: status='completed';
|
||||
// failure path: status='failed' or 'queued' depending on branch).
|
||||
func activityRowsByStatus(t *testing.T, workspaceID, status string) int {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
for _, ws := range []struct {
|
||||
id string
|
||||
name string
|
||||
parentID *string
|
||||
}{
|
||||
{testSourceID, "test-source", nil},
|
||||
{testTargetID, "test-target", nil},
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO workspaces (id, name, parent_id) VALUES ($1::uuid, $2, $3) ON CONFLICT (id) DO NOTHING`,
|
||||
ws.id, ws.name, ws.parentID,
|
||||
); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("seed workspace %s: %v", ws.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"delegation_id": testDelegationID,
|
||||
"task": "do work",
|
||||
})
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs
|
||||
(workspace_id, activity_type, method, source_id, target_id, request_body, status)
|
||||
VALUES ($1, 'delegate', 'delegate', $1, $2, $3::jsonb, 'pending')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, testSourceID, testTargetID, string(reqBody)); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("seed activity_logs: %v", err)
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO delegations
|
||||
(delegation_id, caller_id, callee_id, task_preview, status)
|
||||
VALUES ($1, $2::uuid, $3::uuid, 'do work', 'queued')
|
||||
ON CONFLICT (delegation_id) DO NOTHING
|
||||
`, testDelegationID, testSourceID, testTargetID); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("seed delegations: %v", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
return func() {
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel2()
|
||||
conn.ExecContext(ctx2,
|
||||
`DELETE FROM activity_logs WHERE workspace_id = $1 AND request_body->>'delegation_id' = $2`,
|
||||
testSourceID, testDelegationID)
|
||||
conn.ExecContext(ctx2,
|
||||
`DELETE FROM delegations WHERE delegation_id = $1`, testDelegationID)
|
||||
conn.ExecContext(ctx2,
|
||||
`DELETE FROM workspaces WHERE id IN ($1, $2)`, testSourceID, testTargetID)
|
||||
var n int
|
||||
if err := mdb.DB.QueryRowContext(context.Background(),
|
||||
`SELECT count(*) FROM activity_logs WHERE workspace_id = $1 AND status = $2`,
|
||||
workspaceID, status,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("activity count(%s, %s): %v", workspaceID, status, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// readDelegationRow returns (status, result_preview, error_detail) for the test
|
||||
// delegation, or fails the test if the row is not found.
|
||||
func readDelegationRow(t *testing.T, conn *sql.DB) (status, preview, errorDetail string) {
|
||||
// delegationLedgerStatus returns the current delegations.status for the
|
||||
// seeded delegation_id, or "" if the row is missing. Real-Postgres
|
||||
// version of "did the ledger transition we expected actually land".
|
||||
func delegationLedgerStatus(t *testing.T, delegationID string) string {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var prev, errDet sql.NullString
|
||||
err := conn.QueryRowContext(ctx,
|
||||
`SELECT status, result_preview, error_detail FROM delegations WHERE delegation_id = $1`,
|
||||
testDelegationID,
|
||||
).Scan(&status, &prev, &errDet)
|
||||
var s string
|
||||
err := mdb.DB.QueryRowContext(context.Background(),
|
||||
`SELECT status FROM delegations WHERE delegation_id = $1`, delegationID,
|
||||
).Scan(&s)
|
||||
if err != nil {
|
||||
t.Fatalf("readDelegationRow: %v", err)
|
||||
}
|
||||
return status, prev.String, errDet.String
|
||||
}
|
||||
|
||||
// stack returns the current goroutine stack trace. Used by runWithTimeout to
|
||||
// pinpoint the blocking call site when a test times out.
|
||||
func stack() string {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
// runWithTimeout calls fn in a goroutine and fails t if it doesn't return within
|
||||
// timeout. ctx is passed to fn so it can propagate cancellation to
|
||||
// executeDelegation's DB and network operations — without this, the goroutine
|
||||
// leaks indefinitely when the test times out (context.Background() never cancels).
|
||||
func runWithTimeout(t *testing.T, timeout time.Duration, fn func(context.Context)) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
var panicErr interface{}
|
||||
go func() {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
panicErr = p
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
fn(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if panicErr != nil {
|
||||
t.Fatalf("executeDelegation panicked: %v\n%s", panicErr, stack())
|
||||
}
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
t.Fatalf("executeDelegation timed out after %s\n%s", timeout, stack())
|
||||
t.Fatalf("ledger status(%s): %v", delegationID, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestIntegration_ExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess
|
||||
// is the integration regression gate for issue #159.
|
||||
// is the primary regression test for issue #159 in real-Postgres form.
|
||||
// Scenario: target sends a 200 response with declared Content-Length but
|
||||
// closes the connection mid-body; client gets io.EOF on body read.
|
||||
// proxyA2ARequest captures status=200 + partial body + transport error;
|
||||
// executeDelegation's isDeliveryConfirmedSuccess branch must route to
|
||||
// handleSuccess so the row lands as 'completed' (not 'failed').
|
||||
//
|
||||
// Scenario: proxyA2ARequest returns a 200 status code with a non-empty body.
|
||||
// isDeliveryConfirmedSuccess guard (status>=200 && <300 && len(body)>0 && err!=nil)
|
||||
// routes to handleSuccess. The integration test verifies the DB row lands at
|
||||
// 'completed' with the response body as result_preview.
|
||||
// Real-Postgres advantage over the sqlmock version: this test will fail
|
||||
// if a future refactor adds a new DB write to the success path without
|
||||
// updating any helper — sqlmock would have required reflexive expectation
|
||||
// updates; real Postgres just runs.
|
||||
//
|
||||
// Timing: executeDelegation's first attempt returns (200, <partial>, EOF
|
||||
// → BadGateway-class err). isTransientProxyError(BadGateway)=true so the
|
||||
// caller sleeps `delegationRetryDelay` (8s) and retries. Our listener
|
||||
// loop serves the same partial response on attempt 2, producing the
|
||||
// same (200, <partial>, BadGateway) triple. isDeliveryConfirmedSuccess
|
||||
// then fires (status=200 ∈ [200,300) + body > 0 + err != nil) → success.
|
||||
func TestIntegration_ExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
conn := integrationDB(t)
|
||||
cleanup := setupIntegrationFixtures(t, conn)
|
||||
defer cleanup()
|
||||
integrationDB(t)
|
||||
t.Setenv("DELEGATION_LEDGER_WRITE", "1")
|
||||
|
||||
agentURL, closeServer := rawHTTPServer(t, 200, `{"result":{"parts":[{"text":"work completed successfully"}]}}`)
|
||||
defer closeServer()
|
||||
seedExecuteDelegationFixtures(t)
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
a2aClient = newA2AClientForHost(extractHostPort(agentURL))
|
||||
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// 200 OK with declared Content-Length=100 but only 74 bytes of body.
|
||||
// Connection closes after the partial body → client io.EOF.
|
||||
resp := "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n"
|
||||
resp += `{"result":{"parts":[{"text":"work completed successfully"}]}}` // 74 bytes
|
||||
agentURL, stop := startPartialBodyServer(t, resp)
|
||||
defer stop()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", integExecTargetID), agentURL)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "message/send",
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
@@ -300,50 +255,46 @@ func TestIntegration_ExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSucce
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(integExecSourceID, integExecTargetID, integExecDelegationID, a2aBody)
|
||||
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
// executeDelegation is synchronous here; the 8s retry sleep is INSIDE
|
||||
// the call. We still need a small buffer for the async logA2ASuccess /
|
||||
// last_outbound_at goroutines that fan out after the success branch.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
status, preview, errDet := readDelegationRow(t, conn)
|
||||
if status != "completed" {
|
||||
t.Errorf("status: want completed, got %q", status)
|
||||
// Assert the executeDelegation success path wrote the activity_logs
|
||||
// completion row + transitioned the ledger to completed.
|
||||
if got := activityRowsByStatus(t, integExecSourceID, "completed"); got != 1 {
|
||||
t.Errorf("expected 1 'completed' activity_logs row, got %d", got)
|
||||
}
|
||||
if preview == "" {
|
||||
t.Errorf("result_preview should be non-empty, got %q", preview)
|
||||
}
|
||||
if errDet != "" {
|
||||
t.Errorf("error_detail should be empty on success: got %q", errDet)
|
||||
if s := delegationLedgerStatus(t, integExecDelegationID); s != "completed" {
|
||||
t.Errorf("delegation ledger: want status=completed, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed verifies that
|
||||
// a 500 response routes to failure, not success. isDeliveryConfirmedSuccess
|
||||
// requires status>=200 && <300, so 500 always fails the guard.
|
||||
// TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed —
|
||||
// 500 with partial body + connection drop. The retry produces the same
|
||||
// 500 partial. isDeliveryConfirmedSuccess fails on status>=300 → falls
|
||||
// through to the failure branch. Pins that the new condition didn't
|
||||
// accidentally widen the success branch.
|
||||
func TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
conn := integrationDB(t)
|
||||
cleanup := setupIntegrationFixtures(t, conn)
|
||||
defer cleanup()
|
||||
integrationDB(t)
|
||||
t.Setenv("DELEGATION_LEDGER_WRITE", "1")
|
||||
|
||||
agentURL, closeServer := rawHTTPServer(t, 500, `{"error":"agent crashed"}`)
|
||||
defer closeServer()
|
||||
seedExecuteDelegationFixtures(t)
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
a2aClient = newA2AClientForHost(extractHostPort(agentURL))
|
||||
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
resp := "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n"
|
||||
resp += `{"error":"agent crashed"}` // ~24 bytes, less than declared 100
|
||||
agentURL, stop := startPartialBodyServer(t, resp)
|
||||
defer stop()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", integExecTargetID), agentURL)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
@@ -353,46 +304,41 @@ func TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing
|
||||
},
|
||||
},
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
dh.executeDelegation(integExecSourceID, integExecTargetID, integExecDelegationID, a2aBody)
|
||||
|
||||
status, _, errDet := readDelegationRow(t, conn)
|
||||
if status != "failed" {
|
||||
t.Errorf("status: want failed, got %q", status)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if got := activityRowsByStatus(t, integExecSourceID, "failed"); got != 1 {
|
||||
t.Errorf("expected 1 'failed' activity_logs row, got %d", got)
|
||||
}
|
||||
if errDet == "" {
|
||||
t.Error("error_detail should be non-empty on failure")
|
||||
if s := delegationLedgerStatus(t, integExecDelegationID); s != "failed" {
|
||||
t.Errorf("delegation ledger: want status=failed, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed verifies that
|
||||
// a 200 response with an empty body routes to failure. isDeliveryConfirmedSuccess
|
||||
// requires len(body) > 0, so an empty body fails the guard.
|
||||
// TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed —
|
||||
// 502 Bad Gateway with empty body, normal close. proxyA2ARequest returns
|
||||
// (502, "", error). isDeliveryConfirmedSuccess requires len(respBody) > 0
|
||||
// → false → falls through to the failure branch. isTransientProxyError
|
||||
// (BadGateway) = true so we get a retry that also fails, then 'failed'.
|
||||
func TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
conn := integrationDB(t)
|
||||
cleanup := setupIntegrationFixtures(t, conn)
|
||||
defer cleanup()
|
||||
integrationDB(t)
|
||||
t.Setenv("DELEGATION_LEDGER_WRITE", "1")
|
||||
|
||||
agentURL, closeServer := rawHTTPServer(t, 200, "")
|
||||
defer closeServer()
|
||||
seedExecuteDelegationFixtures(t)
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
a2aClient = newA2AClientForHost(extractHostPort(agentURL))
|
||||
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", integExecTargetID), agentServer.URL)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
@@ -402,45 +348,43 @@ func TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *test
|
||||
},
|
||||
},
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
dh.executeDelegation(integExecSourceID, integExecTargetID, integExecDelegationID, a2aBody)
|
||||
|
||||
status, _, errDet := readDelegationRow(t, conn)
|
||||
if status != "failed" {
|
||||
t.Errorf("status: want failed, got %q", status)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if got := activityRowsByStatus(t, integExecSourceID, "failed"); got != 1 {
|
||||
t.Errorf("expected 1 'failed' activity_logs row, got %d", got)
|
||||
}
|
||||
if errDet == "" {
|
||||
t.Error("error_detail should be non-empty on failure")
|
||||
if s := delegationLedgerStatus(t, integExecDelegationID); s != "failed" {
|
||||
t.Errorf("delegation ledger: want status=failed, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged is the baseline:
|
||||
// a clean 200 response with a valid body and no error routes to success.
|
||||
// TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged —
|
||||
// baseline: clean 200 with full body, no error. proxyErr == nil so
|
||||
// isDeliveryConfirmedSuccess never fires and no retry runs (fast path).
|
||||
// Pins that the new error-recovery branch didn't regress the most
|
||||
// common code path.
|
||||
func TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
conn := integrationDB(t)
|
||||
cleanup := setupIntegrationFixtures(t, conn)
|
||||
defer cleanup()
|
||||
integrationDB(t)
|
||||
t.Setenv("DELEGATION_LEDGER_WRITE", "1")
|
||||
|
||||
agentURL, closeServer := rawHTTPServer(t, 200, `{"result":{"parts":[{"text":"all good"}]}}`)
|
||||
defer closeServer()
|
||||
seedExecuteDelegationFixtures(t)
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
a2aClient = newA2AClientForHost(extractHostPort(agentURL))
|
||||
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"result":{"parts":[{"text":"all good"}]}}`))
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", integExecTargetID), agentServer.URL)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
@@ -450,86 +394,14 @@ func TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T
|
||||
},
|
||||
},
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
dh.executeDelegation(integExecSourceID, integExecTargetID, integExecDelegationID, a2aBody)
|
||||
|
||||
status, preview, errDet := readDelegationRow(t, conn)
|
||||
if status != "completed" {
|
||||
t.Errorf("status: want completed, got %q", status)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if got := activityRowsByStatus(t, integExecSourceID, "completed"); got != 1 {
|
||||
t.Errorf("expected 1 'completed' activity_logs row, got %d", got)
|
||||
}
|
||||
if preview == "" {
|
||||
t.Errorf("result_preview should be non-empty, got %q", preview)
|
||||
}
|
||||
if errDet != "" {
|
||||
t.Errorf("error_detail should be empty on success: got %q", errDet)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that a delegation where Redis cannot be reached still routes to failure
|
||||
// (not panic). proxyA2ARequest falls back to DB URL lookup when Redis is down.
|
||||
func TestIntegration_ExecuteDelegation_RedisDown_FallsBackToDB(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
conn := integrationDB(t)
|
||||
cleanup := setupIntegrationFixtures(t, conn)
|
||||
defer cleanup()
|
||||
t.Setenv("DELEGATION_LEDGER_WRITE", "1")
|
||||
|
||||
// Set up miniredis so db.RDB is non-nil, but do NOT cache any URL.
|
||||
// resolveAgentURL skips Redis and falls back to DB, which also has no URL.
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
"parts": []map[string]string{{"type": "text", "text": "do work"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
|
||||
status, _, errDet := readDelegationRow(t, conn)
|
||||
if status != "failed" {
|
||||
t.Errorf("status: want failed (no target URL), got %q", status)
|
||||
}
|
||||
if errDet == "" {
|
||||
t.Error("error_detail should be set on failure due to unreachable target")
|
||||
}
|
||||
}
|
||||
|
||||
// extractHostPort parses "http://127.0.0.1:PORT/" and returns "127.0.0.1:PORT".
|
||||
func extractHostPort(rawURL string) string {
|
||||
// Simple parse: strip "http://" prefix and trailing slash.
|
||||
// The URL format is always "http://127.0.0.1:PORT/" in our usage.
|
||||
if len(rawURL) > 7 {
|
||||
return rawURL[7 : len(rawURL)-1]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
// newA2AClientForHost creates an http.Client that redirects all connections
|
||||
// to the given host:port. This lets us mock the agent endpoint without
|
||||
// running a real HTTP server.
|
||||
func newA2AClientForHost(targetHost string) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("tcp", targetHost)
|
||||
},
|
||||
ResponseHeaderTimeout: 180 * time.Second,
|
||||
},
|
||||
if s := delegationLedgerStatus(t, integExecDelegationID); s != "completed" {
|
||||
t.Errorf("delegation ledger: want status=completed, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func TestMergeSystemMessages_EmptySlice(t *testing.T) {
|
||||
func TestMergeSystemMessages_NilSlice(t *testing.T) {
|
||||
var input []map[string]interface{}
|
||||
got := mergeSystemMessages(input)
|
||||
if got != nil && len(got) != 0 {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("nil: got %v, want nil/empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// instructions_test.go — unit coverage for InstructionsHandler.
|
||||
//
|
||||
// Coverage targets:
|
||||
// - List: workspace_id scope (returns global + workspace); global-only scope;
|
||||
// query error propagation.
|
||||
// - Create: happy path; missing required fields; invalid scope; workspace scope
|
||||
// without scope_target; content too long; title too long; insert error.
|
||||
// - Update: happy path; partial update; content too long; title too long;
|
||||
// not found; update error.
|
||||
// - Delete: happy path; not found; delete error.
|
||||
// - Resolve: no instructions; global only; global + workspace; query error.
|
||||
|
||||
// setupInstructionsTestDB sets up a sqlmock DB attached to the global db.DB
|
||||
// and returns both the mock and a gin engine that uses it.
|
||||
// The caller MUST use the returned gin engine for BOTH route registration
|
||||
// AND for r.ServeHTTP — using a different engine for either step breaks routing.
|
||||
func setupInstructionsTestDB(t *testing.T) (sqlmock.Sqlmock, *gin.Engine) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
|
||||
// Disable SSRF checks for the duration of this test only.
|
||||
restore := setSSRFCheckForTest(false)
|
||||
t.Cleanup(restore)
|
||||
|
||||
// Wire mock into a gin engine so route registration and serving use the
|
||||
// same engine (avoids the "routes on r2, ServeHTTP on r" mismatch bug).
|
||||
r := gin.New()
|
||||
return mock, r
|
||||
}
|
||||
|
||||
// setupInstructionsTest is kept for backward compatibility with tests that
|
||||
// don't need a gin engine (pure validation helpers). All DB-dependent tests
|
||||
// should use setupInstructionsTestDB instead.
|
||||
func setupInstructionsTest(t *testing.T) (sqlmock.Sqlmock, *gin.Engine) {
|
||||
return setupInstructionsTestDB(t)
|
||||
}
|
||||
|
||||
// ---------- List ----------
|
||||
|
||||
func TestInstructionsList_WorkspaceScope(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/instructions", h.List)
|
||||
|
||||
mock.ExpectQuery(`SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at
|
||||
FROM platform_instructions
|
||||
WHERE enabled = true AND \(\s*scope = 'global'\s*OR \(scope = 'workspace' AND scope_target = \$1\)\s*\)`).
|
||||
WithArgs("ws-uuid-123").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"}).
|
||||
AddRow("inst-1", "global", nil, "Global Rule", "Be nice", 10, true, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z").
|
||||
AddRow("inst-2", "workspace", stringPtr("ws-uuid-123"), "WS Rule", "Use dark mode", 5, true, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/instructions?workspace_id=ws-uuid-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(resp) != 2 {
|
||||
t.Errorf("expected 2 instructions, got %d", len(resp))
|
||||
}
|
||||
if resp[0].Scope != "global" {
|
||||
t.Errorf("expected global scope, got %s", resp[0].Scope)
|
||||
}
|
||||
if resp[1].Scope != "workspace" {
|
||||
t.Errorf("expected workspace scope, got %s", resp[1].Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_GlobalOnlyScope(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/instructions", h.List)
|
||||
|
||||
mock.ExpectQuery(`SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at
|
||||
FROM platform_instructions WHERE 1=1`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"}).
|
||||
AddRow("inst-1", "global", nil, "Global Rule", "Be nice", 10, true, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/instructions?scope=global", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_QueryError(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/instructions", h.List)
|
||||
|
||||
mock.ExpectQuery(`SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at
|
||||
FROM platform_instructions WHERE 1=1`).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/instructions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Create ----------
|
||||
|
||||
func TestInstructionsCreate_HappyPath(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.POST("/instructions", h.Create)
|
||||
|
||||
mock.ExpectQuery(`INSERT INTO platform_instructions`).
|
||||
WithArgs("global", nil, "Test Title", "Test Content", 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-123"))
|
||||
|
||||
body := map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Test Title",
|
||||
"content": "Test Content",
|
||||
"priority": 5,
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "/instructions", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp["id"] != "new-inst-123" {
|
||||
t.Errorf("expected id new-inst-123, got %s", resp["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingRequired(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.POST("/instructions", h.Create)
|
||||
|
||||
// Missing scope
|
||||
body := map[string]interface{}{
|
||||
"title": "Test",
|
||||
"content": "Test",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "/instructions", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_InvalidScope(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.POST("/instructions", h.Create)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"scope": "invalid",
|
||||
"title": "Test",
|
||||
"content": "Test",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "/instructions", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeWithoutTarget(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.POST("/instructions", h.Create)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"title": "Test",
|
||||
"content": "Test",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "/instructions", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.POST("/instructions", h.Create)
|
||||
|
||||
// Content > 8192 chars
|
||||
longContent := make([]byte, 8193)
|
||||
for i := range longContent {
|
||||
longContent[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Test",
|
||||
"content": string(longContent),
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "/instructions", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.POST("/instructions", h.Create)
|
||||
|
||||
// Title > 200 chars
|
||||
longTitle := make([]byte, 201)
|
||||
for i := range longTitle {
|
||||
longTitle[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": string(longTitle),
|
||||
"content": "Test",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "/instructions", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_InsertError(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.POST("/instructions", h.Create)
|
||||
|
||||
mock.ExpectQuery(`INSERT INTO platform_instructions`).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Test",
|
||||
"content": "Test",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", "/instructions", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Update ----------
|
||||
|
||||
func TestInstructionsUpdate_HappyPath(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.PUT("/instructions/:id", h.Update)
|
||||
|
||||
mock.ExpectExec(`UPDATE platform_instructions SET`).
|
||||
WithArgs("New Title", "New Content", sqlmock.AnyArg(), sqlmock.AnyArg(), "inst-123").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := map[string]interface{}{
|
||||
"title": "New Title",
|
||||
"content": "New Content",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", "/instructions/inst-123", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_PartialUpdate(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.PUT("/instructions/:id", h.Update)
|
||||
|
||||
// Only title update — content/priority/enabled stay nil
|
||||
mock.ExpectExec(`UPDATE platform_instructions SET`).
|
||||
WithArgs("Only Title", sqlmock.NilArg(), sqlmock.NilArg(), sqlmock.NilArg(), "inst-123").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := map[string]interface{}{
|
||||
"title": "Only Title",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", "/instructions/inst-123", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_ContentTooLong(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.PUT("/instructions/:id", h.Update)
|
||||
|
||||
longContent := make([]byte, 8193)
|
||||
for i := range longContent {
|
||||
longContent[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"content": string(longContent),
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", "/instructions/inst-123", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_TitleTooLong(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.PUT("/instructions/:id", h.Update)
|
||||
|
||||
longTitle := make([]byte, 201)
|
||||
for i := range longTitle {
|
||||
longTitle[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"title": string(longTitle),
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", "/instructions/inst-123", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_NotFound(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.PUT("/instructions/:id", h.Update)
|
||||
|
||||
mock.ExpectExec(`UPDATE platform_instructions SET`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // 0 rows affected
|
||||
|
||||
body := map[string]interface{}{
|
||||
"title": "New Title",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", "/instructions/nonexistent", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_UpdateError(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.PUT("/instructions/:id", h.Update)
|
||||
|
||||
mock.ExpectExec(`UPDATE platform_instructions SET`).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"title": "New Title",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", "/instructions/inst-123", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
|
||||
func TestInstructionsDelete_HappyPath(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.DELETE("/instructions/:id", h.Delete)
|
||||
|
||||
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
|
||||
WithArgs("inst-123").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/instructions/inst-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_NotFound(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.DELETE("/instructions/:id", h.Delete)
|
||||
|
||||
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
|
||||
WithArgs("nonexistent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/instructions/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_DeleteError(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.DELETE("/instructions/:id", h.Delete)
|
||||
|
||||
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/instructions/inst-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Resolve ----------
|
||||
|
||||
func TestInstructionsResolve_NoInstructions(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/workspaces/:id/instructions/resolve", h.Resolve)
|
||||
|
||||
mock.ExpectQuery(`SELECT scope, title, content FROM platform_instructions`).
|
||||
WithArgs("ws-uuid-123").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"scope", "title", "content"}))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/ws-uuid-123/instructions/resolve", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp["workspace_id"] != "ws-uuid-123" {
|
||||
t.Errorf("expected workspace_id ws-uuid-123, got %s", resp["workspace_id"])
|
||||
}
|
||||
if resp["instructions"] != "" {
|
||||
t.Errorf("expected empty instructions, got %q", resp["instructions"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_GlobalOnly(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/workspaces/:id/instructions/resolve", h.Resolve)
|
||||
|
||||
mock.ExpectQuery(`SELECT scope, title, content FROM platform_instructions`).
|
||||
WithArgs("ws-uuid-123").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("global", "Be Nice", "Always be nice to users"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/ws-uuid-123/instructions/resolve", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp["instructions"] == "" {
|
||||
t.Error("expected non-empty instructions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_GlobalPlusWorkspace(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/workspaces/:id/instructions/resolve", h.Resolve)
|
||||
|
||||
mock.ExpectQuery(`SELECT scope, title, content FROM platform_instructions`).
|
||||
WithArgs("ws-uuid-123").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("global", "Be Nice", "Global rule content").
|
||||
AddRow("workspace", "Use Dark Mode", "WS specific rule"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/ws-uuid-123/instructions/resolve", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
// Both scopes should be present
|
||||
if !bytes.Contains([]byte(resp["instructions"]), []byte("Platform-Wide Rules")) {
|
||||
t.Error("expected Platform-Wide Rules section")
|
||||
}
|
||||
if !bytes.Contains([]byte(resp["instructions"]), []byte("Role-Specific Rules")) {
|
||||
t.Error("expected Role-Specific Rules section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_QueryError(t *testing.T) {
|
||||
mock, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/workspaces/:id/instructions/resolve", h.Resolve)
|
||||
|
||||
mock.ExpectQuery(`SELECT scope, title, content FROM platform_instructions`).
|
||||
WithArgs("ws-uuid-123").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/ws-uuid-123/instructions/resolve", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
|
||||
_, r := setupInstructionsTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
r.GET("/workspaces/:id/instructions/resolve", h.Resolve)
|
||||
|
||||
// Empty workspace ID
|
||||
req, _ := http.NewRequest("GET", "/workspaces//instructions/resolve", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Gin will return 404 for empty path segment
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- scanInstructions helper ----------
|
||||
|
||||
func TestScanInstructions_EmptyRows(t *testing.T) {
|
||||
rows := sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"})
|
||||
result := scanInstructions(rows)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanInstructions_ScanError(t *testing.T) {
|
||||
// Rows that error on scan — scanInstructions should skip bad rows and continue
|
||||
rows := sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"}).
|
||||
AddRow("inst-1", "global", nil, "Good", "Good content", 10, true, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z").
|
||||
RowError(1, sql.ErrConnDone) // Error on second row
|
||||
result := scanInstructions(rows)
|
||||
// Should return first row, skip second
|
||||
if len(result) != 1 {
|
||||
t.Errorf("expected 1 (skipped bad row), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helper ----------
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -47,13 +47,13 @@ const defaultProvisionConcurrency = 3
|
||||
//
|
||||
// - unset / empty / non-numeric → defaultProvisionConcurrency (3)
|
||||
// - "0" → unlimited (a very large cap;
|
||||
// practically no semaphore — used on
|
||||
// SaaS where AWS RunInstances is the
|
||||
// rate-limiter, not us)
|
||||
// practically no semaphore — used on
|
||||
// SaaS where AWS RunInstances is the
|
||||
// rate-limiter, not us)
|
||||
// - any positive integer N → N
|
||||
// - negative integer → defaultProvisionConcurrency (3),
|
||||
// log warning so operator notices
|
||||
// the misconfiguration
|
||||
// log warning so operator notices
|
||||
// the misconfiguration
|
||||
//
|
||||
// The "0 = unlimited" mapping was a deliberate choice: an env var of "0"
|
||||
// is the natural shorthand for "no cap" without forcing operators to
|
||||
@@ -102,18 +102,6 @@ const (
|
||||
childGridColumnCount = 2
|
||||
)
|
||||
|
||||
// childSlot computes the child-relative position for the N-th sibling in
|
||||
// a parent's 2-column grid. Matches defaultChildSlot in
|
||||
// canvas-topology.ts exactly — change them together. Leaf-sized slots
|
||||
// only; for variable-size siblings use childSlotInGrid below.
|
||||
func childSlot(index int) (x, y float64) {
|
||||
col := index % childGridColumnCount
|
||||
row := index / childGridColumnCount
|
||||
x = parentSidePadding + float64(col)*(childDefaultWidth+childGutter)
|
||||
y = parentHeaderPadding + float64(row)*(childDefaultHeight+childGutter)
|
||||
return
|
||||
}
|
||||
|
||||
type nodeSize struct {
|
||||
width, height float64
|
||||
}
|
||||
@@ -342,10 +330,10 @@ func (e *EnvRequirement) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// OrgTemplate is the YAML structure for an org hierarchy.
|
||||
type OrgTemplate struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Defaults OrgDefaults `yaml:"defaults" json:"defaults"`
|
||||
Workspaces []OrgWorkspace `yaml:"workspaces" json:"workspaces"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Defaults OrgDefaults `yaml:"defaults" json:"defaults"`
|
||||
Workspaces []OrgWorkspace `yaml:"workspaces" json:"workspaces"`
|
||||
// GlobalMemories is a list of org-wide memories seeded as GLOBAL scope
|
||||
// on the first root workspace (PM) during org import. Issue #1050.
|
||||
GlobalMemories []models.MemorySeed `yaml:"global_memories" json:"global_memories"`
|
||||
@@ -381,9 +369,9 @@ type OrgDefaults struct {
|
||||
// declare them — causing live configs to boot without idle_prompts
|
||||
// even when org.yaml had them. Phase 1 scalability work adds both
|
||||
// inline + file-ref forms.
|
||||
IdlePrompt string `yaml:"idle_prompt" json:"idle_prompt"`
|
||||
IdlePromptFile string `yaml:"idle_prompt_file" json:"idle_prompt_file"`
|
||||
IdleIntervalSeconds int `yaml:"idle_interval_seconds" json:"idle_interval_seconds"`
|
||||
IdlePrompt string `yaml:"idle_prompt" json:"idle_prompt"`
|
||||
IdlePromptFile string `yaml:"idle_prompt_file" json:"idle_prompt_file"`
|
||||
IdleIntervalSeconds int `yaml:"idle_interval_seconds" json:"idle_interval_seconds"`
|
||||
// CategoryRouting maps issue/audit category → list of target roles.
|
||||
// Per-workspace blocks UNION + override per-key with these defaults.
|
||||
// Rendered into each workspace's config.yaml so agent prompts can read it
|
||||
@@ -470,12 +458,12 @@ type OrgWorkspace struct {
|
||||
// time. If empty, defaults.initial_memories are used. Issue #1050.
|
||||
InitialMemories []models.MemorySeed `yaml:"initial_memories" json:"initial_memories"`
|
||||
// MaxConcurrentTasks: see models.CreateWorkspacePayload.
|
||||
MaxConcurrentTasks int `yaml:"max_concurrent_tasks" json:"max_concurrent_tasks"`
|
||||
Schedules []OrgSchedule `yaml:"schedules" json:"schedules"`
|
||||
Channels []OrgChannel `yaml:"channels" json:"channels"`
|
||||
External bool `yaml:"external" json:"external"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
Canvas struct {
|
||||
MaxConcurrentTasks int `yaml:"max_concurrent_tasks" json:"max_concurrent_tasks"`
|
||||
Schedules []OrgSchedule `yaml:"schedules" json:"schedules"`
|
||||
Channels []OrgChannel `yaml:"channels" json:"channels"`
|
||||
External bool `yaml:"external" json:"external"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
Canvas struct {
|
||||
X float64 `yaml:"x" json:"x"`
|
||||
Y float64 `yaml:"y" json:"y"`
|
||||
} `yaml:"canvas" json:"canvas"`
|
||||
@@ -714,10 +702,10 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
wsMissing := collectPerWorkspaceUnsatisfied(tmpl.Workspaces, orgBaseDir, configured)
|
||||
if len(wsMissing) > 0 {
|
||||
c.JSON(http.StatusPreconditionFailed, gin.H{
|
||||
"error": "missing per-workspace required environment variables",
|
||||
"error": "missing per-workspace required environment variables",
|
||||
"missing_workspace_env": wsMissing,
|
||||
"template": tmpl.Name,
|
||||
"suggestion": "add these keys to the workspace's .env file or set them as global secrets before importing",
|
||||
"template": tmpl.Name,
|
||||
"suggestion": "add these keys to the workspace's .env file or set them as global secrets before importing",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -952,4 +940,3 @@ func errString(err error) string {
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestSanitizeEnvMembers_MaxLength(t *testing.T) {
|
||||
}
|
||||
// 129 chars: invalid (exceeds {0,127} suffix in regex)
|
||||
tooLong := "A" + strings.Repeat("B", 128)
|
||||
got, ok = sanitizeEnvMembers([]string{tooLong}, "test")
|
||||
_, ok = sanitizeEnvMembers([]string{tooLong}, "test")
|
||||
if ok {
|
||||
t.Error("129 char invalid: ok should be false")
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func TestFlattenAndSortRequirements_Empty(t *testing.T) {
|
||||
func TestFlattenAndSortRequirements_SingleFirst(t *testing.T) {
|
||||
// Singles come before groups; within singles, alphabetical
|
||||
reqs := map[string]EnvRequirement{
|
||||
envRequirementKey([]string{"ZETA"}): {Name: "ZETA"},
|
||||
envRequirementKey([]string{"ZETA"}): {Name: "ZETA"},
|
||||
envRequirementKey([]string{"ALPHA"}): {Name: "ALPHA"},
|
||||
}
|
||||
got := flattenAndSortRequirements(reqs)
|
||||
@@ -247,7 +247,7 @@ func TestFlattenAndSortRequirements_SingleFirst(t *testing.T) {
|
||||
|
||||
func TestFlattenAndSortRequirements_GroupsAfterSingles(t *testing.T) {
|
||||
reqs := map[string]EnvRequirement{
|
||||
envRequirementKey([]string{"X"}): {Name: "X"}, // single
|
||||
envRequirementKey([]string{"X"}): {Name: "X"}, // single
|
||||
envRequirementKey([]string{"A", "B"}): {AnyOf: []string{"A", "B"}}, // group
|
||||
}
|
||||
got := flattenAndSortRequirements(reqs)
|
||||
@@ -429,8 +429,8 @@ func TestCollectOrgEnv_WorkspaceLevel(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "Dev",
|
||||
RequiredEnv: []EnvRequirement{{Name: "DEV_KEY"}},
|
||||
Name: "Dev",
|
||||
RequiredEnv: []EnvRequirement{{Name: "DEV_KEY"}},
|
||||
RecommendedEnv: []EnvRequirement{{Name: "DEV_TOOL"}},
|
||||
},
|
||||
},
|
||||
@@ -456,12 +456,12 @@ func TestCollectOrgEnv_DeepNesting(t *testing.T) {
|
||||
RequiredEnv: []EnvRequirement{{Name: "ORG_LEVEL"}},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "Root",
|
||||
RequiredEnv: []EnvRequirement{{Name: "ROOT_LEVEL"}},
|
||||
Name: "Root",
|
||||
RequiredEnv: []EnvRequirement{{Name: "ROOT_LEVEL"}},
|
||||
Children: []OrgWorkspace{
|
||||
{
|
||||
Name: "Child",
|
||||
RequiredEnv: []EnvRequirement{{Name: "CHILD_LEVEL"}},
|
||||
Name: "Child",
|
||||
RequiredEnv: []EnvRequirement{{Name: "CHILD_LEVEL"}},
|
||||
Children: []OrgWorkspace{
|
||||
{Name: "GrandChild", RecommendedEnv: []EnvRequirement{{Name: "GRANDCHILD_TOOL"}}},
|
||||
},
|
||||
@@ -536,4 +536,3 @@ func TestCollectOrgEnv_MixedCasePreservesSort(t *testing.T) {
|
||||
t.Errorf("A,B group should come first: got %+v", req[2])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package handlers
|
||||
|
||||
// org_layout_test.go — unit coverage for org canvas layout helpers
|
||||
// (org.go). These functions compute canvas node positions and subtree
|
||||
// bounding boxes; they are pure (no DB calls, no side effects).
|
||||
//
|
||||
// Coverage targets:
|
||||
// - childSlot: 2-column grid x,y for 0th..Nth child
|
||||
// - sizeOfSubtree: leaf, single child, multi-child, deep nesting
|
||||
// - childSlotInGrid: empty siblings, uniform sizes, variable sizes,
|
||||
// index boundaries
|
||||
|
||||
import "testing"
|
||||
|
||||
// ---------- childSlot ----------
|
||||
|
||||
func TestChildSlot_FirstChild(t *testing.T) {
|
||||
x, y := childSlot(0)
|
||||
// col=0, row=0; x=parentSidePadding=16, y=parentHeaderPadding=130
|
||||
if x != 16.0 {
|
||||
t.Errorf("x = %v; want 16.0", x)
|
||||
}
|
||||
if y != 130.0 {
|
||||
t.Errorf("y = %v; want 130.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlot_SecondChild(t *testing.T) {
|
||||
x, y := childSlot(1)
|
||||
// col=1, row=0; x=16+(240+14)=270, y=130
|
||||
if x != 270.0 {
|
||||
t.Errorf("x = %v; want 270.0", x)
|
||||
}
|
||||
if y != 130.0 {
|
||||
t.Errorf("y = %v; want 130.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlot_ThirdChild(t *testing.T) {
|
||||
x, y := childSlot(2)
|
||||
// col=0, row=1; x=16, y=130+(130+14)=274
|
||||
if x != 16.0 {
|
||||
t.Errorf("x = %v; want 16.0", x)
|
||||
}
|
||||
if y != 274.0 {
|
||||
t.Errorf("y = %v; want 274.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlot_FourthChild(t *testing.T) {
|
||||
x, y := childSlot(3)
|
||||
// col=1, row=1; x=270, y=274
|
||||
if x != 270.0 {
|
||||
t.Errorf("x = %v; want 270.0", x)
|
||||
}
|
||||
if y != 274.0 {
|
||||
t.Errorf("y = %v; want 274.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- sizeOfSubtree ----------
|
||||
|
||||
func TestSizeOfSubtree_Leaf(t *testing.T) {
|
||||
ws := OrgWorkspace{Name: "leaf"}
|
||||
size := sizeOfSubtree(ws)
|
||||
if size.width != 240.0 {
|
||||
t.Errorf("width = %v; want 240.0", size.width)
|
||||
}
|
||||
if size.height != 130.0 {
|
||||
t.Errorf("height = %v; want 130.0", size.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_SingleChild(t *testing.T) {
|
||||
ws := OrgWorkspace{
|
||||
Name: "parent",
|
||||
Children: []OrgWorkspace{{Name: "child"}},
|
||||
}
|
||||
size := sizeOfSubtree(ws)
|
||||
// cols = min(1,1) = 1; rows = 1
|
||||
// maxColW = 240 (child default)
|
||||
// width = 16*2 + 240*1 + 14*0 = 272
|
||||
// height = 130 + 130 + 14*0 + 16 = 276
|
||||
if size.width != 272.0 {
|
||||
t.Errorf("width = %v; want 272.0", size.width)
|
||||
}
|
||||
if size.height != 276.0 {
|
||||
t.Errorf("height = %v; want 276.0", size.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_TwoChildren(t *testing.T) {
|
||||
ws := OrgWorkspace{
|
||||
Name: "parent",
|
||||
Children: []OrgWorkspace{
|
||||
{Name: "child1"},
|
||||
{Name: "child2"},
|
||||
},
|
||||
}
|
||||
size := sizeOfSubtree(ws)
|
||||
// cols = 2; rows = 1; maxColW = 240
|
||||
// width = 16*2 + 240*2 + 14*1 = 524
|
||||
// height = 130 + 130 + 16 = 276
|
||||
if size.width != 524.0 {
|
||||
t.Errorf("width = %v; want 524.0", size.width)
|
||||
}
|
||||
if size.height != 276.0 {
|
||||
t.Errorf("height = %v; want 276.0", size.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_ThreeChildren(t *testing.T) {
|
||||
ws := OrgWorkspace{
|
||||
Name: "parent",
|
||||
Children: []OrgWorkspace{
|
||||
{Name: "child1"},
|
||||
{Name: "child2"},
|
||||
{Name: "child3"},
|
||||
},
|
||||
}
|
||||
size := sizeOfSubtree(ws)
|
||||
// cols = 2 (len=3, childGridColumnCount=2, min=2); rows = 2
|
||||
// maxColW = 240
|
||||
// width = 16*2 + 240*2 + 14*1 = 524
|
||||
// height = 130 + (130*2) + 14*1 + 16 = 420
|
||||
if size.width != 524.0 {
|
||||
t.Errorf("width = %v; want 524.0", size.width)
|
||||
}
|
||||
if size.height != 420.0 {
|
||||
t.Errorf("height = %v; want 420.0", size.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_DeepNesting(t *testing.T) {
|
||||
// leaf → child → parent
|
||||
grandchild := OrgWorkspace{Name: "grandchild"}
|
||||
child := OrgWorkspace{Name: "child", Children: []OrgWorkspace{grandchild}}
|
||||
parent := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{child}}
|
||||
size := sizeOfSubtree(parent)
|
||||
// grandchild: 240x130
|
||||
// child: cols=1, rows=1, maxColW=240 → 272x276
|
||||
// parent: cols=1, rows=1, maxColW=272 → 304x422
|
||||
if size.width != 304.0 {
|
||||
t.Errorf("width = %v; want 304.0", size.width)
|
||||
}
|
||||
if size.height != 422.0 {
|
||||
t.Errorf("height = %v; want 422.0", size.height)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- childSlotInGrid ----------
|
||||
|
||||
func TestChildSlotInGrid_EmptySiblings(t *testing.T) {
|
||||
x, y := childSlotInGrid(0, nil)
|
||||
if x != 16.0 || y != 130.0 {
|
||||
t.Errorf("empty siblings: got (%v,%v); want (16.0, 130.0)", x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_EmptySlice(t *testing.T) {
|
||||
x, y := childSlotInGrid(0, []nodeSize{})
|
||||
if x != 16.0 || y != 130.0 {
|
||||
t.Errorf("empty slice: got (%v,%v); want (16.0, 130.0)", x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_UniformSizes(t *testing.T) {
|
||||
sizes := []nodeSize{
|
||||
{240, 130},
|
||||
{240, 130},
|
||||
{240, 130},
|
||||
}
|
||||
// maxColW = 240; cols = 2; rows = 2
|
||||
// slot 0: col=0, row=0 → x=16, y=130
|
||||
x0, y0 := childSlotInGrid(0, sizes)
|
||||
if x0 != 16.0 || y0 != 130.0 {
|
||||
t.Errorf("slot 0: got (%v,%v); want (16.0, 130.0)", x0, y0)
|
||||
}
|
||||
// slot 1: col=1, row=0 → x=16+240+14=270, y=130
|
||||
x1, y1 := childSlotInGrid(1, sizes)
|
||||
if x1 != 270.0 || y1 != 130.0 {
|
||||
t.Errorf("slot 1: got (%v,%v); want (270.0, 130.0)", x1, y1)
|
||||
}
|
||||
// slot 2: col=0, row=1 → x=16, y=130+130+14=274
|
||||
x2, y2 := childSlotInGrid(2, sizes)
|
||||
if x2 != 16.0 || y2 != 274.0 {
|
||||
t.Errorf("slot 2: got (%v,%v); want (16.0, 274.0)", x2, y2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_VariableSizes(t *testing.T) {
|
||||
sizes := []nodeSize{
|
||||
{100, 80}, // narrow, short
|
||||
{300, 200}, // wide, tall
|
||||
{200, 150}, // medium
|
||||
}
|
||||
// maxColW = 300; cols = 2; rows = 2
|
||||
// slot 0: col=0, row=0 → x=16, y=130
|
||||
x0, y0 := childSlotInGrid(0, sizes)
|
||||
if x0 != 16.0 || y0 != 130.0 {
|
||||
t.Errorf("slot 0: got (%v,%v); want (16.0, 130.0)", x0, y0)
|
||||
}
|
||||
// slot 1: col=1, row=0 → x=16+300+14=330, y=130
|
||||
x1, y1 := childSlotInGrid(1, sizes)
|
||||
if x1 != 330.0 || y1 != 130.0 {
|
||||
t.Errorf("slot 1: got (%v,%v); want (330.0, 130.0)", x1, y1)
|
||||
}
|
||||
// slot 2: col=0, row=1 → x=16, y=130+200+14=344
|
||||
x2, y2 := childSlotInGrid(2, sizes)
|
||||
if x2 != 16.0 || y2 != 344.0 {
|
||||
t.Errorf("slot 2: got (%v,%v); want (16.0, 344.0)", x2, y2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_SingleChild(t *testing.T) {
|
||||
sizes := []nodeSize{{400, 300}}
|
||||
x, y := childSlotInGrid(0, sizes)
|
||||
// cols = 1 (len < 2), maxColW = 400
|
||||
// x = 16 + 0*(400+14) = 16, y = 130
|
||||
if x != 16.0 || y != 130.0 {
|
||||
t.Errorf("single child: got (%v,%v); want (16.0, 130.0)", x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_LastSlot(t *testing.T) {
|
||||
sizes := []nodeSize{{200, 100}, {200, 100}, {200, 100}}
|
||||
// cols = 2, rows = 2, maxColW = 200
|
||||
// slot 2: col=0, row=1 → x=16, y=130+100+14=244
|
||||
x, y := childSlotInGrid(2, sizes)
|
||||
if x != 16.0 || y != 244.0 {
|
||||
t.Errorf("last slot: got (%v,%v); want (16.0, 244.0)", x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_OverflowIndex(t *testing.T) {
|
||||
sizes := []nodeSize{{200, 100}}
|
||||
// Index beyond array bounds — Go handles this without panic
|
||||
x, y := childSlotInGrid(5, sizes)
|
||||
// col = 5 % 2 = 1, row = 5 / 2 = 2
|
||||
// x = 16 + 1*(200+14) = 230, y = 130 + 2*(100+14) = 358
|
||||
if x != 230.0 || y != 358.0 {
|
||||
t.Errorf("overflow index: got (%v,%v); want (230.0, 358.0)", x, y)
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ GITEA_SSH_KEY_PATH=/etc/molecule-bootstrap/personas/dev-lead/ssh_priv
|
||||
loadPersonaEnvFile("dev-lead", out)
|
||||
|
||||
want := map[string]string{
|
||||
"GITEA_USER": "dev-lead",
|
||||
"GITEA_USER_EMAIL": "dev-lead@agents.moleculesai.app",
|
||||
"GITEA_TOKEN": "abc123",
|
||||
"GITEA_TOKEN_SCOPES": "write:repository,write:issue,read:user",
|
||||
"GITEA_SSH_KEY_PATH": "/etc/molecule-bootstrap/personas/dev-lead/ssh_priv",
|
||||
"GITEA_USER": "dev-lead",
|
||||
"GITEA_USER_EMAIL": "dev-lead@agents.moleculesai.app",
|
||||
"GITEA_TOKEN": "abc123",
|
||||
"GITEA_TOKEN_SCOPES": "write:repository,write:issue,read:user",
|
||||
"GITEA_SSH_KEY_PATH": "/etc/molecule-bootstrap/personas/dev-lead/ssh_priv",
|
||||
}
|
||||
if len(out) != len(want) {
|
||||
t.Fatalf("got %d keys, want %d: %#v", len(out), len(want), out)
|
||||
@@ -152,13 +152,8 @@ func TestIsSafeRoleName_Acceptance(t *testing.T) {
|
||||
t.Errorf("isSafeRoleName(%q) = false; want true", s)
|
||||
}
|
||||
}
|
||||
// trailing-hyphen IS allowed; only include actually-bad names:
|
||||
bad := []string{
|
||||
"", ".", "..", "with/slash", "/abs", "dot.in.middle",
|
||||
"with space", "back\\slash", "trailing-", // trailing-hyphen is fine actually
|
||||
"with$dollar", "with?question", "newline\nsplit",
|
||||
}
|
||||
// trailing-hyphen IS allowed; remove from "bad" list:
|
||||
bad = []string{
|
||||
"", ".", "..", "with/slash", "/abs", "dot.in.middle",
|
||||
"with space", "back\\slash", "with$dollar", "with?question",
|
||||
"newline\nsplit",
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/envx"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -436,53 +434,6 @@ func regexpEscapeForAwk(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// copyPluginToContainer creates a tar from a host directory and copies it into /configs/plugins/<name>/.
|
||||
// The tar entries are prefixed with plugins/<name>/ so Docker creates the directory structure.
|
||||
func (h *PluginsHandler) copyPluginToContainer(ctx context.Context, containerName, hostDir, pluginName string) error {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
err := filepath.Walk(hostDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(hostDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Prefix: plugins/<pluginName>/<rel> → extracts under /configs/
|
||||
header.Name = filepath.Join("plugins", pluginName, rel)
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tar from %s: %w", hostDir, err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close tar: %w", err)
|
||||
}
|
||||
|
||||
// Copy to /configs — the tar's plugins/<name>/ prefix creates the directory
|
||||
return h.docker.CopyToContainer(ctx, containerName, "/configs", &buf, container.CopyToContainerOptions{})
|
||||
}
|
||||
|
||||
// streamDirAsTar writes every regular file + dir under `root` to the tar
|
||||
// writer, using paths relative to root so the caller's unpack produces
|
||||
// `<name>/<original-layout>` without any leading tempdir components.
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
|
||||
// returned and propagated when neither Redis cache nor DB lookup succeeds.
|
||||
func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
|
||||
h := newHandlerWithTestDeps(t)
|
||||
|
||||
@@ -209,10 +209,10 @@ func TestGracefulPreRestart_Success(t *testing.T) {
|
||||
// Pre-populate Redis cache with the test server URL
|
||||
_ = setupTestRedisWithURL(t, srv.URL)
|
||||
|
||||
// Use an embedded struct to override resolveAgentURLForRestartSignal.
|
||||
// Use a wrapper so gracefulPreRestart runs through the embedded handler.
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: srv.URL + "/agent",
|
||||
testURL: srv.URL + "/agent",
|
||||
}
|
||||
|
||||
// gracefulPreRestart runs in a goroutine with its own timeout.
|
||||
@@ -235,7 +235,7 @@ func TestGracefulPreRestart_NotImplemented(t *testing.T) {
|
||||
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: srv.URL + "/agent",
|
||||
testURL: srv.URL + "/agent",
|
||||
}
|
||||
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-noimpl-999")
|
||||
@@ -253,7 +253,7 @@ func TestGracefulPreRestart_ConnectionRefused(t *testing.T) {
|
||||
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: "http://localhost:19999/agent",
|
||||
testURL: "http://localhost:19999/agent",
|
||||
}
|
||||
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-unreachable-000")
|
||||
@@ -269,7 +269,7 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
|
||||
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
errToReturn: context.DeadlineExceeded,
|
||||
errToReturn: context.DeadlineExceeded,
|
||||
}
|
||||
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
|
||||
@@ -279,21 +279,14 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// resolveURLTestWrapper embeds *WorkspaceHandler and overrides
|
||||
// resolveAgentURLForRestartSignal so tests can inject a fixed URL or error.
|
||||
// resolveURLTestWrapper embeds *WorkspaceHandler for tests that exercise
|
||||
// gracefulPreRestart through a wrapper value.
|
||||
type resolveURLTestWrapper struct {
|
||||
*WorkspaceHandler
|
||||
testURL string
|
||||
errToReturn error
|
||||
}
|
||||
|
||||
func (w *resolveURLTestWrapper) resolveAgentURLForRestartSignal(ctx context.Context, workspaceID string) (string, error) {
|
||||
if w.errToReturn != nil {
|
||||
return "", w.errToReturn
|
||||
}
|
||||
return w.testURL, nil
|
||||
}
|
||||
|
||||
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
|
||||
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
|
||||
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
@@ -313,4 +306,4 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
|
||||
}
|
||||
t.Cleanup(func() { mr.Close() })
|
||||
return mr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTe
|
||||
candidatePath, resolveErr := resolveInsideRoot(configsDir, template)
|
||||
if resolveErr != nil {
|
||||
log.Printf("Restart: invalid template %q: %v — proceeding without it", template, resolveErr)
|
||||
template = ""
|
||||
} else if _, err := os.Stat(candidatePath); err == nil {
|
||||
return candidatePath, template
|
||||
} else {
|
||||
|
||||
@@ -3,8 +3,6 @@ package handlers
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
)
|
||||
|
||||
// Tests for the SaaS-aware default-tier resolution introduced in #2901
|
||||
@@ -21,19 +19,6 @@ import (
|
||||
// was hardcoded to 3 and silently disagreed with the create-
|
||||
// handler default on SaaS.
|
||||
|
||||
// stubCPProv is a minimal stand-in for the CP provisioner — only
|
||||
// exercises the IsSaaS / HasProvisioner contract, never invoked in
|
||||
// these tests.
|
||||
type stubCPProv struct{}
|
||||
|
||||
func (stubCPProv) Start(_ interface{}, _ provisioner.WorkspaceConfig) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (stubCPProv) Stop(_ interface{}, _ string) error { return nil }
|
||||
func (stubCPProv) Restart(_ interface{}, _ provisioner.WorkspaceConfig) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestIsSaaS_TrueWhenCPProvWired(t *testing.T) {
|
||||
h := &WorkspaceHandler{cpProv: &trackingCPProv{}}
|
||||
if !h.IsSaaS() {
|
||||
|
||||
@@ -117,14 +117,6 @@ func resolveWorkspaceRootPath(runtime, root string) string {
|
||||
// EIC misconfiguration.
|
||||
const eicFileOpTimeout = 30 * time.Second
|
||||
|
||||
// eicFileOpTimeout was historically named eicFileWriteTimeout when the
|
||||
// only EIC op was writeFile. Keep an alias so any external test that
|
||||
// pinned the old name still compiles; rename can land as a follow-up
|
||||
// once we've gone a release without the alias being touched.
|
||||
//
|
||||
//nolint:revive // intentional alias for back-compat with prior tests.
|
||||
const eicFileWriteTimeout = eicFileOpTimeout
|
||||
|
||||
// eicSSHSession describes an open EIC tunnel ready for an ssh subprocess.
|
||||
// Only valid inside the closure passed to withEICTunnel — the underlying
|
||||
// keypair + tunnel are torn down when the closure returns.
|
||||
|
||||
@@ -88,7 +88,7 @@ func generateDefaultConfig(name string, files map[string]string, tier int) strin
|
||||
tier = 3
|
||||
}
|
||||
cfg.WriteString("version: 1.0.0\n")
|
||||
cfg.WriteString(fmt.Sprintf("tier: %d\n", tier))
|
||||
fmt.Fprintf(&cfg, "tier: %d\n", tier)
|
||||
cfg.WriteString("model: anthropic:claude-haiku-4-5-20251001\n")
|
||||
cfg.WriteString("\nprompt_files:\n")
|
||||
if len(promptFiles) > 0 {
|
||||
|
||||
@@ -275,10 +275,10 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// Translate to the handler's wire shape (the field names match
|
||||
// 1:1, but Go can't implicit-convert named struct types).
|
||||
// 1:1, so we can use a direct type conversion).
|
||||
out := make([]fileEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, fileEntry{Path: e.Path, Size: e.Size, Dir: e.Dir})
|
||||
out = append(out, fileEntry(e))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
return
|
||||
@@ -373,9 +373,7 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
filePath := c.Param("path")
|
||||
if strings.HasPrefix(filePath, "/") {
|
||||
filePath = filePath[1:]
|
||||
}
|
||||
filePath = strings.TrimPrefix(filePath, "/")
|
||||
|
||||
if err := validateRelPath(filePath); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
@@ -480,9 +478,7 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
filePath := c.Param("path")
|
||||
if strings.HasPrefix(filePath, "/") {
|
||||
filePath = filePath[1:]
|
||||
}
|
||||
filePath = strings.TrimPrefix(filePath, "/")
|
||||
|
||||
if err := validateRelPath(filePath); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
@@ -636,4 +632,3 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
go h.wh.RestartByID(workspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,13 +63,6 @@ const workspacesUniqueIndexName = "workspaces_parent_name_uniq"
|
||||
// Conflict — the user must rename and re-try.
|
||||
var errWorkspaceNameExhausted = errors.New("workspace name exhausted: too many duplicates of base name under same parent")
|
||||
|
||||
// dbExec is the minimum surface our retry helper needs from
|
||||
// *sql.Tx (or *sql.DB). Declared as an interface so tests can
|
||||
// substitute a fake without standing up a real DB connection.
|
||||
type dbExec interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// insertWorkspaceWithNameRetry runs the workspace INSERT and, if it
|
||||
// hits the parent-name unique-violation, retries with a suffixed
|
||||
// name. Returns the name actually persisted (which the caller MUST
|
||||
|
||||
@@ -109,21 +109,6 @@ func (h *WorkspaceHandler) State(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// sensitiveUpdateFields documents fields that carry elevated risk — kept as
|
||||
// an explicit list for code readability and future audits. Auth is now fully
|
||||
// enforced at the router layer (WorkspaceAuth middleware, #680 IDOR fix);
|
||||
// this map is no longer used for in-handler gate logic but is preserved to
|
||||
// surface the risk classification clearly.
|
||||
//
|
||||
// budget_limit is intentionally NOT here — the dedicated PATCH
|
||||
// /workspaces/:id/budget (AdminAuth) is the only write path (#611).
|
||||
var sensitiveUpdateFields = map[string]struct{}{
|
||||
"tier": {},
|
||||
"parent_id": {},
|
||||
"runtime": {},
|
||||
"workspace_dir": {},
|
||||
}
|
||||
|
||||
// Update handles PATCH /workspaces/:id
|
||||
func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
@@ -160,9 +145,7 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
|
||||
// Auth is fully enforced at the router layer (WorkspaceAuth middleware, #680).
|
||||
// WorkspaceAuth validates that the caller holds a valid bearer token for this
|
||||
// specific workspace — no additional auth gate is needed here. The
|
||||
// sensitiveUpdateFields map above documents the risk classification for
|
||||
// auditors but is no longer used as a runtime gate.
|
||||
// specific workspace — no additional auth gate is needed here.
|
||||
|
||||
// #120: guard — return 404 for nonexistent workspace IDs instead of
|
||||
// silently applying zero-row UPDATEs and returning 200.
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// workspace_crud_test.go — unit coverage for workspace state, update, and delete
|
||||
// handlers (workspace_crud.go), plus field validation helpers.
|
||||
//
|
||||
// Coverage targets:
|
||||
// - State: legacy (no live token), live token + valid, missing token,
|
||||
// invalid token, not found, soft-deleted, query error.
|
||||
// - Update: happy path, invalid UUID, invalid body, not found, each field
|
||||
// update, workspace_dir validation, length limits, YAML special chars.
|
||||
// - Delete: happy path, invalid UUID, has children (409), cascade delete
|
||||
// stop errors, purge path.
|
||||
// - validateWorkspaceID: valid/invalid UUID.
|
||||
// - validateWorkspaceFields: newline rejection, YAML special chars, length.
|
||||
// - validateWorkspaceDir: absolute/relative, traversal, system paths.
|
||||
|
||||
func setupWorkspaceCrudTest(t *testing.T) (sqlmock.Sqlmock, *gin.Engine) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
mock := setupTestDB(t)
|
||||
r := gin.New()
|
||||
return mock, r
|
||||
}
|
||||
|
||||
// ---------- State ----------
|
||||
|
||||
func TestState_LegacyWorkspaceNoLiveToken(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
// No live token — legacy workspace, no auth required
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("running"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["workspace_id"] != wsID {
|
||||
t.Errorf("workspace_id mismatch")
|
||||
}
|
||||
if resp["status"] != "running" {
|
||||
t.Errorf("status mismatch: got %v", resp["status"])
|
||||
}
|
||||
if resp["deleted"] != false {
|
||||
t.Errorf("deleted should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_HasLiveTokenMissingAuth(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
// No Authorization header
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_WorkspaceNotFound(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["deleted"] != true {
|
||||
t.Errorf("deleted should be true for not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_WorkspaceSoftDeleted(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("removed"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for soft-deleted, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["deleted"] != true {
|
||||
t.Errorf("deleted should be true")
|
||||
}
|
||||
if resp["status"] != "removed" {
|
||||
t.Errorf("status should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_QueryError(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Update ----------
|
||||
|
||||
func TestUpdate_InvalidUUID(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Test"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/not-a-uuid", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_InvalidBody(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader([]byte("not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceNotFound(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
body := map[string]interface{}{"name": "New Name"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameTooLong(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
longName := make([]byte, 256)
|
||||
for i := range longName {
|
||||
longName[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{"name": string(longName)}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for name too long, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoleTooLong(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
longRole := make([]byte, 1001)
|
||||
for i := range longRole {
|
||||
longRole[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{"role": string(longRole)}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for role too long, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithNewline(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Name\nwith newline"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for newline in name, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithYAMLSpecialChars(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Name with [brackets]"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for YAML special chars in name, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirSystemPath(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "/etc/my-workspace"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for system path workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirTraversal(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "/workspace/../../../etc"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for traversal in workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirRelativePath(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "relative/path"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for relative workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
|
||||
func TestDelete_InvalidUUID(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/not-a-uuid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow("child-1", "Child Workspace"))
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
// No ?confirm=true
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["status"] != "confirmation_required" {
|
||||
t.Errorf("status should be confirmation_required")
|
||||
}
|
||||
if resp["children_count"] != float64(1) {
|
||||
t.Errorf("children_count should be 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_ChildrenCheckQueryError(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validateWorkspaceID ----------
|
||||
|
||||
func TestValidateWorkspaceID_Valid(t *testing.T) {
|
||||
err := validateWorkspaceID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceID_Invalid(t *testing.T) {
|
||||
err := validateWorkspaceID("not-a-uuid")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid UUID")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validateWorkspaceFields ----------
|
||||
|
||||
func TestValidateWorkspaceFields_NewlineInName(t *testing.T) {
|
||||
err := validateWorkspaceFields("name\nwith\nnewline", "", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for newline in name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_NewlineInRole(t *testing.T) {
|
||||
err := validateWorkspaceFields("", "role\rwith\rcarriage", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for carriage return in role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_YAMLSpecialCharsInName(t *testing.T) {
|
||||
for _, ch := range "{}[]|>*&!" {
|
||||
err := validateWorkspaceFields("namewith"+string(ch), "", "", "")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for YAML special char %c in name", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_NameTooLong(t *testing.T) {
|
||||
longName := make([]byte, 256)
|
||||
for i := range longName {
|
||||
longName[i] = 'x'
|
||||
}
|
||||
err := validateWorkspaceFields(string(longName), "", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for name > 255 chars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_RoleTooLong(t *testing.T) {
|
||||
longRole := make([]byte, 1001)
|
||||
for i := range longRole {
|
||||
longRole[i] = 'x'
|
||||
}
|
||||
err := validateWorkspaceFields("", string(longRole), "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for role > 1000 chars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_Valid(t *testing.T) {
|
||||
err := validateWorkspaceFields("ValidName", "ValidRole", "gpt-4", "claude")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validateWorkspaceDir ----------
|
||||
|
||||
func TestValidateWorkspaceDir_Valid(t *testing.T) {
|
||||
err := validateWorkspaceDir("/workspace/my-workspace")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_RelativePath(t *testing.T) {
|
||||
err := validateWorkspaceDir("relative/path")
|
||||
if err == nil {
|
||||
t.Error("expected error for relative path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_Traversal(t *testing.T) {
|
||||
err := validateWorkspaceDir("/workspace/../etc")
|
||||
if err == nil {
|
||||
t.Error("expected error for traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_SystemPathEtc(t *testing.T) {
|
||||
for _, path := range []string{"/etc", "/var", "/proc", "/sys", "/dev", "/boot", "/sbin", "/bin", "/lib", "/usr"} {
|
||||
err := validateWorkspaceDir(path)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for system path %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_SystemPathPrefix(t *testing.T) {
|
||||
err := validateWorkspaceDir("/etc/something")
|
||||
if err == nil {
|
||||
t.Error("expected error for /etc/something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_Empty(t *testing.T) {
|
||||
err := validateWorkspaceDir("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- CascadeDelete ----------
|
||||
|
||||
func TestCascadeDelete_InvalidUUID(t *testing.T) {
|
||||
h := &WorkspaceHandler{}
|
||||
descendants, stopErrs, err := h.CascadeDelete(context.Background(), "not-a-uuid")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid UUID")
|
||||
}
|
||||
if descendants != nil || stopErrs != nil {
|
||||
t.Error("expected nil returns on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCascadeDelete_DescendantQueryError(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
_ = r
|
||||
|
||||
// CascadeDelete returns early on descendant query error — nil deps for
|
||||
// StopWorkspace/RemoveVolume/broadcaster are fine since they are never
|
||||
// reached in this error path.
|
||||
h := &WorkspaceHandler{}
|
||||
// Note: the descendant CTE query is called with zero args (workspace ID
|
||||
// is embedded in the query string, not passed as a query arg).
|
||||
mock.ExpectQuery(`WITH RECURSIVE descendants AS`).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
|
||||
if err == nil {
|
||||
t.Error("CascadeDelete returned nil error; want descendant query error")
|
||||
}
|
||||
if deleted != nil {
|
||||
t.Errorf("deleted = %v; want nil", deleted)
|
||||
}
|
||||
if stopErrs != nil {
|
||||
t.Errorf("stopErrs = %v; want nil", stopErrs)
|
||||
}
|
||||
// sqlmock verifies all expected queries were executed
|
||||
}
|
||||
|
||||
// Note: Full CascadeDelete testing requires mocking StopWorkspace, RemoveVolume,
|
||||
// and provisioner calls — covered in integration tests. Unit tests here focus on
|
||||
// the validation and pre-condition paths.
|
||||
@@ -156,10 +156,7 @@ func TestProvisionWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
|
||||
|
||||
// Wait for the goroutine to land in cpProv.Start (or give up).
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if len(rec.startedSnapshot()) > 0 {
|
||||
break
|
||||
}
|
||||
for len(rec.startedSnapshot()) == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for cpProv.Start; recorded=%v", rec.startedSnapshot())
|
||||
}
|
||||
@@ -626,10 +623,7 @@ func TestRestartWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
|
||||
// the tracking stub, so we expect at least one Stop and (eventually)
|
||||
// at least one Start.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if len(rec.stoppedSnapshot()) > 0 && len(rec.startedSnapshot()) > 0 {
|
||||
break
|
||||
}
|
||||
for len(rec.stoppedSnapshot()) == 0 || len(rec.startedSnapshot()) == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for cpProv.Stop + cpProv.Start; stopped=%v started=%v",
|
||||
rec.stoppedSnapshot(), rec.startedSnapshot())
|
||||
@@ -907,7 +901,7 @@ func stripGoComments(src []byte) []byte {
|
||||
// Block comment
|
||||
if i+1 < len(src) && src[i] == '/' && src[i+1] == '*' {
|
||||
i += 2
|
||||
for i+1 < len(src) && !(src[i] == '*' && src[i+1] == '/') {
|
||||
for i+1 < len(src) && (src[i] != '*' || src[i+1] != '/') {
|
||||
i++
|
||||
}
|
||||
i++ // skip closing /
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -49,7 +48,7 @@ func TestConfigDirName(t *testing.T) {
|
||||
{"abc-def-ghi", "ws-abc-def-ghi"},
|
||||
{"abcdefghijklmnop", "ws-abcdefghijkl"}, // truncated at 12
|
||||
{"short", "ws-short"},
|
||||
{"123456789012", "ws-123456789012"}, // exactly 12
|
||||
{"123456789012", "ws-123456789012"}, // exactly 12
|
||||
{"1234567890123", "ws-123456789012"}, // 13 chars, truncated
|
||||
}
|
||||
|
||||
@@ -483,11 +482,11 @@ func TestSanitizeRuntime_Allowlist(t *testing.T) {
|
||||
{"openclaw", "openclaw"},
|
||||
{"hermes", "hermes"},
|
||||
{"codex", "codex"},
|
||||
{"langgraph", "claude-code"}, // deprecated → default
|
||||
{"deepagents", "claude-code"}, // deprecated → default
|
||||
{"crewai", "claude-code"}, // deprecated → default
|
||||
{"autogen", "claude-code"}, // deprecated → default
|
||||
{"not-a-runtime", "claude-code"}, // unknown → default
|
||||
{"langgraph", "claude-code"}, // deprecated → default
|
||||
{"deepagents", "claude-code"}, // deprecated → default
|
||||
{"crewai", "claude-code"}, // deprecated → default
|
||||
{"autogen", "claude-code"}, // deprecated → default
|
||||
{"not-a-runtime", "claude-code"}, // unknown → default
|
||||
{"../../sensitive", "claude-code"}, // path traversal probe → default
|
||||
{"langgraph\nevil", "claude-code"}, // newline injection → default (not in allowlist)
|
||||
}
|
||||
@@ -533,7 +532,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "well under limit — passes through unchanged",
|
||||
contentLen: 50_000,
|
||||
contentLen: 50_000,
|
||||
expectInsert: true,
|
||||
},
|
||||
}
|
||||
@@ -1008,13 +1007,6 @@ func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
|
||||
// Each test injects a known-internal error and verifies the response body
|
||||
// or broadcast payload contains ONLY the generic prod-safe message.
|
||||
|
||||
// errInternalDB is a pkg-level error whose .Error() output matches a real
|
||||
// postgres driver error shape — used to simulate DB failure without a live DB.
|
||||
var errInternalDB = fmt.Errorf("pq: connection refused")
|
||||
|
||||
// errInternalOS simulates an OS-level error.
|
||||
var errInternalOS = fmt.Errorf("operation failed: no such file or directory")
|
||||
|
||||
// captureBroadcaster is a test broadcaster that captures the last data
|
||||
// payload passed to RecordAndBroadcast so tests can inspect it. Now
|
||||
// satisfies events.EventEmitter (#1814) directly — RecordAndBroadcast
|
||||
@@ -1022,7 +1014,6 @@ var errInternalOS = fmt.Errorf("operation failed: no such file or directory")
|
||||
// WorkspaceHandler paths under test call it.
|
||||
type captureBroadcaster struct {
|
||||
lastData map[string]interface{}
|
||||
lastErr error
|
||||
}
|
||||
|
||||
// BroadcastOnly is required to satisfy events.EventEmitter. None of the
|
||||
@@ -1042,46 +1033,6 @@ func (c *captureBroadcaster) RecordAndBroadcast(_ context.Context, _, _ string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsafeErrorStrings lists substrings that must NEVER appear in external-facing
|
||||
// error responses. Covers DB driver errors, OS errors, and internal paths.
|
||||
var unsafeErrorStrings = []string{
|
||||
"pq:",
|
||||
"pq ",
|
||||
"connection refused",
|
||||
"deadlock",
|
||||
"no such file",
|
||||
"/var/",
|
||||
"/tmp/",
|
||||
"postgres",
|
||||
"PostgreSQL",
|
||||
"sql: ",
|
||||
":8080",
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
|
||||
// containsUnsafeString checks whether any prohibited substring appears in
|
||||
// a string value recursively (handles nested maps for safety).
|
||||
func containsUnsafeString(v interface{}) bool {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
for _, unsafe := range unsafeErrorStrings {
|
||||
if strings.Contains(v, unsafe) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for _, val := range v {
|
||||
if containsUnsafeString(val) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestProvisionWorkspace_NoInternalErrorsInBroadcast asserts that provisionWorkspace
|
||||
// never leaks internal error details in WORKSPACE_PROVISION_FAILED broadcasts.
|
||||
// Regression test for issue #1206 — drives the global-secrets decrypt-fail
|
||||
@@ -1251,12 +1202,12 @@ func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
for _, leakMarker := range []string{
|
||||
"t3.large", // machine type
|
||||
"ami-0abcd1234efgh5678", // AMI id
|
||||
"vpc-deadbeef", // VPC id
|
||||
"subnet-cafef00d", // subnet id
|
||||
"InvalidSubnet.Conflict", // raw upstream HTTP body
|
||||
"CP API rejected", // raw error string head
|
||||
"t3.large", // machine type
|
||||
"ami-0abcd1234efgh5678", // AMI id
|
||||
"vpc-deadbeef", // VPC id
|
||||
"subnet-cafef00d", // subnet id
|
||||
"InvalidSubnet.Conflict", // raw upstream HTTP body
|
||||
"CP API rejected", // raw error string head
|
||||
} {
|
||||
if strings.Contains(s, leakMarker) {
|
||||
t.Errorf("broadcast leaked %q in payload value %q", leakMarker, s)
|
||||
@@ -1268,17 +1219,6 @@ func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mockEnvMutator is a provisionhook.Registry stub that always returns a fixed error.
|
||||
type mockEnvMutator struct {
|
||||
returnErr error
|
||||
}
|
||||
|
||||
func (m *mockEnvMutator) Run(_ context.Context, _ string, _ map[string]string) error {
|
||||
return m.returnErr
|
||||
}
|
||||
|
||||
func (m *mockEnvMutator) Register(_ provisionhook.EnvMutator) {}
|
||||
|
||||
// TestResolveAndStage_NoInternalErrorsInHTTPErr asserts that
|
||||
// resolveAndStage never puts internal error detail (resolver error
|
||||
// strings, file-system paths, upstream rate-limit text, auth tokens
|
||||
|
||||
@@ -793,7 +793,7 @@ func TestDoJSON_204OnEndpointExpectingBody(t *testing.T) {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Error("got nil SearchResponse, want zero value")
|
||||
t.Fatal("got nil SearchResponse, want zero value")
|
||||
}
|
||||
if len(got.Memories) != 0 {
|
||||
t.Errorf("memories = %v, want empty", got.Memories)
|
||||
|
||||
@@ -109,7 +109,7 @@ func (p *flatPlugin) handleNamespace(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.Unlock()
|
||||
w.WriteHeader(204)
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,14 +22,7 @@ const chainQuerySnippet = "WITH RECURSIVE chain"
|
||||
// Helper makes per-test mock setup terser.
|
||||
func setupMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock new: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
// We use QueryMatcherEqual but with regex-based ExpectQuery elsewhere
|
||||
// for flexibility. Actually swap to regex for the recursive query:
|
||||
db, mock, err = sqlmock.New() // default = regex
|
||||
db, mock, err := sqlmock.New() // default = regex
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock new: %v", err)
|
||||
}
|
||||
@@ -186,8 +179,8 @@ func TestWalkChain_RowsErr(t *testing.T) {
|
||||
|
||||
func TestDerive(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
chain []chainNode
|
||||
name string
|
||||
chain []chainNode
|
||||
wantWS, wantTeam, wantOrg string
|
||||
}{
|
||||
{
|
||||
|
||||
@@ -80,7 +80,6 @@ func (s *Store) PatchNamespace(ctx context.Context, name string, body contract.N
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("metadata = $%d", idx))
|
||||
args = append(args, metadata)
|
||||
idx++
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE memory_namespaces SET %s
|
||||
@@ -294,7 +293,9 @@ func (s *Store) Search(ctx context.Context, body contract.SearchRequest) (*contr
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func scanNamespace(row interface{ Scan(dest ...interface{}) error }) (*contract.Namespace, error) {
|
||||
func scanNamespace(row interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*contract.Namespace, error) {
|
||||
var ns contract.Namespace
|
||||
var kindStr string
|
||||
var expires sql.NullTime
|
||||
@@ -315,7 +316,9 @@ func scanNamespace(row interface{ Scan(dest ...interface{}) error }) (*contract.
|
||||
return &ns, nil
|
||||
}
|
||||
|
||||
func scanMemory(row interface{ Scan(dest ...interface{}) error }) (*contract.Memory, error) {
|
||||
func scanMemory(row interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*contract.Memory, error) {
|
||||
var m contract.Memory
|
||||
var kindStr, sourceStr string
|
||||
var expires sql.NullTime
|
||||
@@ -375,7 +378,7 @@ func vectorString(v []float32) string {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%g", x))
|
||||
fmt.Fprintf(&b, "%g", x)
|
||||
}
|
||||
b.WriteByte(']')
|
||||
return b.String()
|
||||
|
||||
@@ -120,7 +120,6 @@ func WorkspaceAuth(database *sql.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +324,6 @@ func CanvasOrBearer(database *sql.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,16 +37,6 @@ const validateAnyTokenSelectQuery = "SELECT t\\.id, t\\.workspace_id.*FROM works
|
||||
// validateTokenUpdateQuery is matched for the best-effort last_used_at UPDATE.
|
||||
const validateTokenUpdateQuery = "UPDATE workspace_auth_tokens SET last_used_at"
|
||||
|
||||
// newWorkspaceAuthRouter builds a minimal gin router that applies WorkspaceAuth
|
||||
// to a single GET /workspaces/:id/test route, returning 200 on success.
|
||||
func newWorkspaceAuthRouter(db sqlmock.Sqlmock, realDB interface{ Close() error }) *gin.Engine {
|
||||
_ = db // unused directly; sqlmock intercepts calls via the *sql.DB pointer
|
||||
r := gin.New()
|
||||
// We need the *sql.DB, not the mock. The caller passes mockDB via the
|
||||
// test-local var — this helper is only used to build the router topology.
|
||||
return r
|
||||
}
|
||||
|
||||
// TestWorkspaceAuth_351_NoBearer_Returns401 — strict contract: every request
|
||||
// under /workspaces/:id/* must carry a valid bearer, period. No fail-open,
|
||||
// no grace period, no existence check. The middleware goes straight to
|
||||
@@ -483,10 +473,6 @@ func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
|
||||
// (no ::text cast — sql.NullString handles the NULL scan natively).
|
||||
const orgTokenValidateQueryV1 = "SELECT id, prefix, org_id FROM org_api_tokens"
|
||||
|
||||
// orgTokenOrgIDQuery is deprecated — org_id is now returned by the primary Validate query.
|
||||
// Kept here to avoid breaking other test files that may reference it.
|
||||
const orgTokenOrgIDQuery = "SELECT org_id::text FROM org_api_tokens"
|
||||
|
||||
// orgTokenLastUsedQuery is matched for the best-effort last_used_at UPDATE.
|
||||
const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"
|
||||
|
||||
@@ -495,10 +481,10 @@ const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"
|
||||
// and orgCallerID can look it up downstream.
|
||||
func TestAdminAuth_OrgToken_SetsOrgID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
orgIDFromDB interface{} // sqlmock row value: nil, "", or "ws-org-1"
|
||||
wantOrgIDCtx bool // expect c.Get("org_id") to be set
|
||||
wantOrgIDVal string // if set, expected value
|
||||
name string
|
||||
orgIDFromDB interface{} // sqlmock row value: nil, "", or "ws-org-1"
|
||||
wantOrgIDCtx bool // expect c.Get("org_id") to be set
|
||||
wantOrgIDVal string // if set, expected value
|
||||
}{
|
||||
{
|
||||
name: "post-fix token has org_id set in context",
|
||||
|
||||
@@ -3,6 +3,8 @@ package plugins
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -64,31 +66,6 @@ func TestResolveRef_MapsNotFoundToErrPluginNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// stubGitForResolveRef creates a stub that handles fetch + rev-parse for ResolveRef.
|
||||
func stubGitForResolveRef(t *testing.T, sha string) func(ctx context.Context, dir string, args ...string) error {
|
||||
return func(ctx context.Context, dir string, args ...string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if len(args) < 1 {
|
||||
return errors.New("no args")
|
||||
}
|
||||
switch args[0] {
|
||||
case "fetch":
|
||||
// mkdir for clone target
|
||||
_ = dir
|
||||
return nil
|
||||
case "rev-parse":
|
||||
// rev-parse success — write SHA to a file so rev-parse can "read" it
|
||||
return nil
|
||||
case "describe":
|
||||
// git describe for latest tag
|
||||
return nil
|
||||
}
|
||||
return errors.New("unexpected git command: " + args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRef_SucceedsForTagRef(t *testing.T) {
|
||||
// This test verifies the happy path: fetch + rev-parse succeed.
|
||||
// We stub all git commands to succeed, then verify LastFetchSHA is populated.
|
||||
@@ -99,18 +76,43 @@ func TestResolveRef_SucceedsForTagRef(t *testing.T) {
|
||||
return ctx.Err()
|
||||
}
|
||||
calls[args[0]] = true
|
||||
if args[0] == "fetch" {
|
||||
run := func(name string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=test",
|
||||
"GIT_AUTHOR_EMAIL=test@example.invalid",
|
||||
"GIT_COMMITTER_NAME=test",
|
||||
"GIT_COMMITTER_EMAIL=test@example.invalid",
|
||||
)
|
||||
return cmd.Run()
|
||||
}
|
||||
if err := run("git", "init"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dir+"/README.md", []byte("test\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("git", "add", "README.md"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("git", "commit", "-m", "test"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("git", "tag", "v1.0.0"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
_, err := r.ResolveRef(context.Background(), "org/repo#tag:v1.0.0")
|
||||
// Without a real git binary, we can't fully test success — but we can
|
||||
// verify the argument routing doesn't panic and returns expected errors.
|
||||
if err != nil && !errors.Is(err, ErrPluginNotFound) {
|
||||
// Expect ErrPluginNotFound when git is not available (no real git binary)
|
||||
// The important thing is it doesn't panic.
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRef returned unexpected error: %v", err)
|
||||
}
|
||||
if !calls["fetch"] && !calls["rev-parse"] {
|
||||
// At least one git command should have been called
|
||||
t.Fatal("expected at least one git command")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +151,7 @@ func TestPluginUpdateQueueRow_Struct(t *testing.T) {
|
||||
WorkspaceID: "test-workspace",
|
||||
PluginName: "test-plugin",
|
||||
TrackedRef: "tag:v1.0.0",
|
||||
CurrentSHA: "abc123",
|
||||
CurrentSHA: "abc123",
|
||||
LatestSHA: "def456",
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
@@ -57,11 +57,11 @@ func (r *GithubResolver) Scheme() string { return "github" }
|
||||
// - Owner / repo: must start with alphanumeric, then 0–99 chars from
|
||||
// [a-zA-Z0-9_.-]. Matches GitHub's validation.
|
||||
// - Ref: must NOT start with `-` (prevents ref-as-flag injection like
|
||||
// "-exec=/evil"). Then 0–254 chars from [a-zA-Z0-9_./-]. Disallows
|
||||
// "-exec=/evil"). Then 0–254 chars from [a-zA-Z0-9_./:-]. Disallows
|
||||
// whitespace and shell metacharacters. The handler additionally
|
||||
// passes `--` before the URL when invoking git, for defense in depth.
|
||||
var repoRE = regexp.MustCompile(
|
||||
`^([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})/([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})(?:#([a-zA-Z0-9_.][a-zA-Z0-9_./\-]{0,254}))?$`,
|
||||
`^([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})/([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})(?:#([a-zA-Z0-9_.][a-zA-Z0-9_./:\-]{0,254}))?$`,
|
||||
)
|
||||
|
||||
// Fetch clones the repository and copies its contents (minus .git) into dst.
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -104,8 +103,8 @@ func writeManifestJSON(t *testing.T, dir, digest string) {
|
||||
func writeStagedPlugin(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
files := map[string]string{
|
||||
"plugin.yaml": "name: test-plugin\nversion: 1.0.0\ndescription: supply chain test\n",
|
||||
"rules/guidelines.md": "# Plugin Guidelines\nFollow the rules.\n",
|
||||
"plugin.yaml": "name: test-plugin\nversion: 1.0.0\ndescription: supply chain test\n",
|
||||
"rules/guidelines.md": "# Plugin Guidelines\nFollow the rules.\n",
|
||||
"skills/helper/SKILL.md": "---\nid: helper\nname: Helper\ndescription: does stuff\n---\n",
|
||||
}
|
||||
for relPath, content := range files {
|
||||
@@ -119,19 +118,6 @@ func writeStagedPlugin(t *testing.T, dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
// stubGitSuccess returns a GitRunner that creates the target directory and
|
||||
// returns nil (simulating a successful shallow clone). Does NOT write any
|
||||
// repo content — tests that need files should write them into dst separately.
|
||||
func stubGitSuccess() func(ctx context.Context, dir string, args ...string) error {
|
||||
return func(ctx context.Context, dir string, args ...string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("stubGitSuccess: no args")
|
||||
}
|
||||
target := args[len(args)-1]
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// SHA256 content-integrity tests (#768 Control 1)
|
||||
//
|
||||
|
||||
@@ -445,16 +445,16 @@ func parseGiteaBranchHeadSha(body []byte) (string, error) {
|
||||
// Look for `"id":"<40-hex>"` inside the commit object.
|
||||
idx := strings.Index(string(body), `"id":"`)
|
||||
if idx < 0 {
|
||||
return "", errors.New("Gitea branch response missing commit.id field")
|
||||
return "", errors.New("gitea branch response missing commit.id field")
|
||||
}
|
||||
rest := string(body[idx+len(`"id":"`):])
|
||||
end := strings.IndexByte(rest, '"')
|
||||
if end < 0 {
|
||||
return "", errors.New("Gitea branch response has malformed commit.id (no closing quote)")
|
||||
return "", errors.New("gitea branch response has malformed commit.id (no closing quote)")
|
||||
}
|
||||
sha := rest[:end]
|
||||
if len(sha) < 7 {
|
||||
return "", fmt.Errorf("Gitea returned suspiciously short sha %q", sha)
|
||||
return "", fmt.Errorf("gitea returned suspiciously short sha %q", sha)
|
||||
}
|
||||
return sha, nil
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
|
||||
// contents are by definition immutable.
|
||||
// The pull is best-effort: if it fails (network, auth, rate limit) the
|
||||
// subsequent ContainerCreate still surfaces the actionable error below.
|
||||
imgInspect, _, imgErr := p.cli.ImageInspectWithRaw(ctx, image)
|
||||
imgInspect, imgErr := p.cli.ImageInspect(ctx, image)
|
||||
moving := imageTagIsMoving(image)
|
||||
switch {
|
||||
case imgErr != nil:
|
||||
@@ -541,12 +541,12 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
|
||||
//
|
||||
// Selection matrix:
|
||||
//
|
||||
// cfg.WorkspacePath | cfg.WorkspaceAccess | mount
|
||||
// ------------------+-------------------------+--------------------------------
|
||||
// "" | "" / "none" | <named-volume>:/workspace (isolated, current default)
|
||||
// "<host-dir>" | "" / "read_write" | <host-dir>:/workspace (current PM behaviour)
|
||||
// "<host-dir>" | "read_only" | <host-dir>:/workspace:ro (research agents get read access without write risk)
|
||||
// "" | "read_only"/"read_write"| <named-volume>:/workspace (degraded — access requires a mount; validated at handler layer)
|
||||
// cfg.WorkspacePath | cfg.WorkspaceAccess | mount
|
||||
// ------------------+-------------------------+--------------------------------
|
||||
// "" | "" / "none" | <named-volume>:/workspace (isolated, current default)
|
||||
// "<host-dir>" | "" / "read_write" | <host-dir>:/workspace (current PM behaviour)
|
||||
// "<host-dir>" | "read_only" | <host-dir>:/workspace:ro (research agents get read access without write risk)
|
||||
// "" | "read_only"/"read_write"| <named-volume>:/workspace (degraded — access requires a mount; validated at handler layer)
|
||||
//
|
||||
// Kept pure + side-effect-free so it's unit-testable.
|
||||
func buildWorkspaceMount(cfg WorkspaceConfig) string {
|
||||
@@ -700,11 +700,11 @@ func applyTierResources(hostCfg *container.HostConfig, tier int) (memMB, cpuShar
|
||||
memMB = getTierMemoryMB(tier)
|
||||
cpuShares = getTierCPUShares(tier)
|
||||
if memMB > 0 {
|
||||
hostCfg.Resources.Memory = memMB * 1024 * 1024
|
||||
hostCfg.Memory = memMB * 1024 * 1024
|
||||
}
|
||||
if cpuShares > 0 {
|
||||
// shares -> NanoCPUs: 1024 shares == 1 CPU == 1e9 NanoCPUs
|
||||
hostCfg.Resources.NanoCPUs = (cpuShares * 1_000_000_000) / 1024
|
||||
hostCfg.NanoCPUs = (cpuShares * 1_000_000_000) / 1024
|
||||
}
|
||||
return memMB, cpuShares
|
||||
}
|
||||
@@ -1000,20 +1000,6 @@ func (p *Provisioner) WriteAuthTokenToVolume(ctx context.Context, workspaceID, t
|
||||
return nil
|
||||
}
|
||||
|
||||
// execInContainer runs a command inside a running container as root.
|
||||
// Best-effort: logs errors but does not fail the caller.
|
||||
func (p *Provisioner) execInContainer(ctx context.Context, containerID string, cmd []string) {
|
||||
execCfg := container.ExecOptions{Cmd: cmd, User: "root"}
|
||||
execID, err := p.cli.ContainerExecCreate(ctx, containerID, execCfg)
|
||||
if err != nil {
|
||||
log.Printf("Provisioner: exec create failed: %v", err)
|
||||
return
|
||||
}
|
||||
if err := p.cli.ContainerExecStart(ctx, execID.ID, container.ExecStartOptions{}); err != nil {
|
||||
log.Printf("Provisioner: exec start failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveVolume removes the config volume for a workspace.
|
||||
// Also removes the claude-sessions volume (best-effort, may not exist
|
||||
// for non claude-code runtimes). Issue #12.
|
||||
@@ -1127,12 +1113,12 @@ func (p *Provisioner) IsRunning(ctx context.Context, workspaceID string) (bool,
|
||||
//
|
||||
// - ("ws-<id>", nil): container is running. Caller can exec into it.
|
||||
// - ("", nil): container does not exist OR exists but is stopped
|
||||
// (NotFound, Exited, Created, Restarting…). Caller
|
||||
// should treat as a definitive "not running."
|
||||
// (NotFound, Exited, Created, Restarting…). Caller
|
||||
// should treat as a definitive "not running."
|
||||
// - ("", err): transient daemon error (timeout, socket EOF, ctx
|
||||
// cancel). Caller should NOT infer "not running" —
|
||||
// this could be a flaky daemon under load. Decide
|
||||
// per-callsite whether to fail soft or hard.
|
||||
// cancel). Caller should NOT infer "not running" —
|
||||
// this could be a flaky daemon under load. Decide
|
||||
// per-callsite whether to fail soft or hard.
|
||||
//
|
||||
// Background — molecule-core#10: the plugins handler used to carry its own
|
||||
// copy of this inspect logic (`findRunningContainer`) which collapsed
|
||||
|
||||
@@ -155,14 +155,14 @@ func TestApplyTierConfig_Tier2_Standard(t *testing.T) {
|
||||
|
||||
// Memory limit: 512 MiB
|
||||
expectedMemory := int64(512 * 1024 * 1024)
|
||||
if hc.Resources.Memory != expectedMemory {
|
||||
t.Errorf("T2: expected Memory=%d (512m), got %d", expectedMemory, hc.Resources.Memory)
|
||||
if hc.Memory != expectedMemory {
|
||||
t.Errorf("T2: expected Memory=%d (512m), got %d", expectedMemory, hc.Memory)
|
||||
}
|
||||
|
||||
// CPU limit: 1.0 CPU (1e9 NanoCPUs)
|
||||
expectedCPU := int64(1_000_000_000)
|
||||
if hc.Resources.NanoCPUs != expectedCPU {
|
||||
t.Errorf("T2: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.Resources.NanoCPUs)
|
||||
if hc.NanoCPUs != expectedCPU {
|
||||
t.Errorf("T2: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.NanoCPUs)
|
||||
}
|
||||
|
||||
// Must NOT be privileged
|
||||
@@ -270,13 +270,13 @@ func TestApplyTierConfig_UnknownTier_DefaultsToT2(t *testing.T) {
|
||||
|
||||
// Unknown tiers should get T2 resource limits as a safe default
|
||||
expectedMemory := int64(512 * 1024 * 1024)
|
||||
if hc.Resources.Memory != expectedMemory {
|
||||
t.Errorf("Unknown tier: expected Memory=%d (512m), got %d", expectedMemory, hc.Resources.Memory)
|
||||
if hc.Memory != expectedMemory {
|
||||
t.Errorf("Unknown tier: expected Memory=%d (512m), got %d", expectedMemory, hc.Memory)
|
||||
}
|
||||
|
||||
expectedCPU := int64(1_000_000_000)
|
||||
if hc.Resources.NanoCPUs != expectedCPU {
|
||||
t.Errorf("Unknown tier: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.Resources.NanoCPUs)
|
||||
if hc.NanoCPUs != expectedCPU {
|
||||
t.Errorf("Unknown tier: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.NanoCPUs)
|
||||
}
|
||||
|
||||
// Must NOT be privileged
|
||||
@@ -298,8 +298,8 @@ func TestApplyTierConfig_ZeroTier_DefaultsToT2(t *testing.T) {
|
||||
|
||||
// Zero tier (default int value) should also get T2 resource limits
|
||||
expectedMemory := int64(512 * 1024 * 1024)
|
||||
if hc.Resources.Memory != expectedMemory {
|
||||
t.Errorf("Tier 0: expected Memory=%d, got %d", expectedMemory, hc.Resources.Memory)
|
||||
if hc.Memory != expectedMemory {
|
||||
t.Errorf("Tier 0: expected Memory=%d, got %d", expectedMemory, hc.Memory)
|
||||
}
|
||||
if hc.Privileged {
|
||||
t.Error("Tier 0: must not be privileged")
|
||||
@@ -944,12 +944,12 @@ func TestApplyTierConfig_T3_UsesEnvOverride(t *testing.T) {
|
||||
ApplyTierConfig(hc, cfg, "ws-abc123-configs:/configs", "ws-abc123")
|
||||
|
||||
wantMem := int64(8192) * 1024 * 1024
|
||||
if hc.Resources.Memory != wantMem {
|
||||
t.Errorf("T3 memory override: got %d, want %d", hc.Resources.Memory, wantMem)
|
||||
if hc.Memory != wantMem {
|
||||
t.Errorf("T3 memory override: got %d, want %d", hc.Memory, wantMem)
|
||||
}
|
||||
wantCPU := int64(4_000_000_000)
|
||||
if hc.Resources.NanoCPUs != wantCPU {
|
||||
t.Errorf("T3 CPU override: got %d NanoCPUs, want %d", hc.Resources.NanoCPUs, wantCPU)
|
||||
if hc.NanoCPUs != wantCPU {
|
||||
t.Errorf("T3 CPU override: got %d NanoCPUs, want %d", hc.NanoCPUs, wantCPU)
|
||||
}
|
||||
if !hc.Privileged || hc.PidMode != "host" {
|
||||
t.Errorf("T3 override should preserve privileged/pid-host flags, got Privileged=%v PidMode=%q",
|
||||
@@ -968,11 +968,11 @@ func TestApplyTierConfig_T3_DefaultCap(t *testing.T) {
|
||||
ApplyTierConfig(hc, cfg, "ws-abc123-configs:/configs", "ws-abc123")
|
||||
|
||||
wantMem := int64(defaultTier3MemoryMB) * 1024 * 1024
|
||||
if hc.Resources.Memory != wantMem {
|
||||
t.Errorf("T3 default memory: got %d, want %d", hc.Resources.Memory, wantMem)
|
||||
if hc.Memory != wantMem {
|
||||
t.Errorf("T3 default memory: got %d, want %d", hc.Memory, wantMem)
|
||||
}
|
||||
wantCPU := int64(defaultTier3CPUShares) * 1_000_000_000 / 1024
|
||||
if hc.Resources.NanoCPUs != wantCPU {
|
||||
t.Errorf("T3 default NanoCPUs: got %d, want %d", hc.Resources.NanoCPUs, wantCPU)
|
||||
if hc.NanoCPUs != wantCPU {
|
||||
t.Errorf("T3 default NanoCPUs: got %d, want %d", hc.NanoCPUs, wantCPU)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package ws
|
||||
|
||||
// hub_test.go — unit coverage for the WebSocket hub (hub.go).
|
||||
//
|
||||
// Coverage targets:
|
||||
// - NewHub: initial state (clients empty, channels created, done not closed)
|
||||
// - safeSend: sends to open channel, closed channel, full buffer
|
||||
// - Broadcast: canvas client (no workspace ID) gets all messages,
|
||||
// workspace client gets message only when CanCommunicate returns true,
|
||||
// drops on closed/full channel
|
||||
// - Close: idempotent (closeOnce), disconnects all clients, closes done
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
)
|
||||
|
||||
// ---------- NewHub ----------
|
||||
|
||||
func TestNewHub(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
if h == nil {
|
||||
t.Fatal("NewHub returned nil")
|
||||
}
|
||||
if len(h.clients) != 0 {
|
||||
t.Errorf("new hub has %d clients; want 0", len(h.clients))
|
||||
}
|
||||
if h.Register == nil {
|
||||
t.Error("Register channel is nil")
|
||||
}
|
||||
if h.Unregister == nil {
|
||||
t.Error("Unregister channel is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHub_WithAccessChecker(t *testing.T) {
|
||||
called := false
|
||||
checker := func(callerID, targetID string) bool {
|
||||
called = true
|
||||
return callerID == targetID
|
||||
}
|
||||
h := NewHub(checker)
|
||||
if h.canCommunicate == nil {
|
||||
t.Fatal("canCommunicate is nil")
|
||||
}
|
||||
if !h.canCommunicate("ws-1", "ws-1") {
|
||||
t.Error("canCommunicate should return true for same ID")
|
||||
}
|
||||
if h.canCommunicate("ws-1", "ws-2") {
|
||||
t.Error("canCommunicate should return false for different IDs")
|
||||
}
|
||||
// Verify the checker was invoked at least once
|
||||
if !called {
|
||||
t.Error("access checker was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- safeSend ----------
|
||||
|
||||
func TestSafeSend_OpenChannel(t *testing.T) {
|
||||
ch := make(chan []byte, 1)
|
||||
client := &Client{Send: ch}
|
||||
got := safeSend(client, []byte("hello"))
|
||||
if !got {
|
||||
t.Error("safeSend returned false for open channel")
|
||||
}
|
||||
if len(ch) != 1 {
|
||||
t.Errorf("channel has %d messages; want 1", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeSend_ClosedChannel(t *testing.T) {
|
||||
ch := make(chan []byte)
|
||||
close(ch)
|
||||
client := &Client{Send: ch}
|
||||
got := safeSend(client, []byte("hello"))
|
||||
if got {
|
||||
t.Error("safeSend returned true for closed channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeSend_FullChannel(t *testing.T) {
|
||||
ch := make(chan []byte, 1)
|
||||
ch <- []byte("already full")
|
||||
client := &Client{Send: ch}
|
||||
got := safeSend(client, []byte("second"))
|
||||
if got {
|
||||
t.Error("safeSend returned true for full channel")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Broadcast ----------
|
||||
|
||||
func TestBroadcast_CanvasClientGetsAll(t *testing.T) {
|
||||
ch := make(chan []byte, 10)
|
||||
client := &Client{WorkspaceID: "", Send: ch}
|
||||
h := NewHub(nil)
|
||||
h.clients = map[*Client]bool{client: true}
|
||||
|
||||
h.Broadcast(models.WSMessage{Event: "test"})
|
||||
<-ch // non-blocking since channel has capacity
|
||||
}
|
||||
|
||||
func TestBroadcast_WorkspaceClientGetsWhenAllowed(t *testing.T) {
|
||||
ch := make(chan []byte, 10)
|
||||
client := &Client{WorkspaceID: "ws-caller", Send: ch}
|
||||
allowed := false
|
||||
h := NewHub(func(callerID, targetID string) bool {
|
||||
return allowed
|
||||
})
|
||||
msg := models.WSMessage{Event: "test", WorkspaceID: "ws-target"}
|
||||
h.clients = map[*Client]bool{client: true}
|
||||
|
||||
// Not allowed — should not receive
|
||||
h.Broadcast(msg)
|
||||
if len(ch) != 0 {
|
||||
t.Errorf("disallowed client received %d messages; want 0", len(ch))
|
||||
}
|
||||
|
||||
// Now allow
|
||||
allowed = true
|
||||
h.Broadcast(msg)
|
||||
if len(ch) != 1 {
|
||||
t.Errorf("allowed client received %d messages; want 1", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_DropsOnClosedChannel(t *testing.T) {
|
||||
// Use a named variable for the client so the map key and Broadcast's
|
||||
// range both refer to the same *Client pointer.
|
||||
ch := make(chan []byte, 1)
|
||||
client := &Client{WorkspaceID: "", Send: ch}
|
||||
h := NewHub(nil)
|
||||
h.clients = map[*Client]bool{client: true}
|
||||
|
||||
// Fill and close so any subsequent send (from Broadcast) hits
|
||||
// safeSend's default → returns false without blocking or panicking.
|
||||
ch <- []byte("fill")
|
||||
close(ch)
|
||||
|
||||
// Broadcast must not panic — safeSend returns false for closed channel
|
||||
h.Broadcast(models.WSMessage{Event: "test"})
|
||||
}
|
||||
|
||||
func TestBroadcast_EmptyHub(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
// Broadcast to empty hub should not panic
|
||||
h.Broadcast(models.WSMessage{Event: "test"})
|
||||
}
|
||||
|
||||
func TestBroadcast_MultipleClients(t *testing.T) {
|
||||
ch1 := make(chan []byte, 10)
|
||||
ch2 := make(chan []byte, 10)
|
||||
ch3 := make(chan []byte, 10) // disallowed
|
||||
c1 := &Client{WorkspaceID: "ws-1", Send: ch1}
|
||||
c2 := &Client{WorkspaceID: "ws-2", Send: ch2}
|
||||
c3 := &Client{WorkspaceID: "ws-3", Send: ch3}
|
||||
h := NewHub(func(callerID, targetID string) bool {
|
||||
return targetID != "ws-3"
|
||||
})
|
||||
msg := models.WSMessage{Event: "test", WorkspaceID: "ws-target"}
|
||||
h.clients = map[*Client]bool{c1: true, c2: true, c3: true}
|
||||
|
||||
h.Broadcast(msg)
|
||||
|
||||
select {
|
||||
case <-ch1:
|
||||
// received
|
||||
default:
|
||||
t.Error("ws-1 should have received message")
|
||||
}
|
||||
select {
|
||||
case <-ch2:
|
||||
// received
|
||||
default:
|
||||
t.Error("ws-2 should have received message")
|
||||
}
|
||||
select {
|
||||
case <-ch3:
|
||||
t.Error("ws-3 should NOT have received message")
|
||||
default:
|
||||
// correct — ws-3 is disallowed
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_CanvasClientAlwaysGets(t *testing.T) {
|
||||
ch := make(chan []byte, 10)
|
||||
canvasClient := &Client{WorkspaceID: "", Send: ch}
|
||||
h := NewHub(func(callerID, targetID string) bool {
|
||||
return false // nobody can communicate with anybody
|
||||
})
|
||||
msg := models.WSMessage{Event: "test", WorkspaceID: "ws-target"}
|
||||
h.clients = map[*Client]bool{
|
||||
canvasClient: true, // canvas client
|
||||
&Client{WorkspaceID: "ws-target", Send: make(chan []byte, 10)}: true,
|
||||
}
|
||||
|
||||
h.Broadcast(msg)
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
// received
|
||||
default:
|
||||
t.Error("canvas client should always receive messages regardless of CanCommunicate")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Close ----------
|
||||
|
||||
func TestClose_DisconnectsClients(t *testing.T) {
|
||||
ch1 := make(chan []byte, 1)
|
||||
ch2 := make(chan []byte, 1)
|
||||
h := NewHub(nil)
|
||||
h.clients = map[*Client]bool{
|
||||
{Send: ch1}: true,
|
||||
{Send: ch2}: true,
|
||||
}
|
||||
|
||||
h.Close()
|
||||
|
||||
if len(h.clients) != 0 {
|
||||
t.Errorf("after Close, %d clients remain; want 0", len(h.clients))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_Idempotent(t *testing.T) {
|
||||
ch := make(chan []byte, 1)
|
||||
h := NewHub(nil)
|
||||
h.clients = map[*Client]bool{{Send: ch}: true}
|
||||
|
||||
// Should not panic on second call (closeOnce)
|
||||
h.Close()
|
||||
h.Close()
|
||||
h.Close()
|
||||
}
|
||||
|
||||
func TestClose_DoneChannelClosed(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
h.Close()
|
||||
|
||||
select {
|
||||
case <-h.done:
|
||||
// done is closed — correct
|
||||
default:
|
||||
t.Error("done channel should be closed after Close")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user