Compare commits

..

30 Commits

Author SHA1 Message Date
core-be 205be877b8 fix(handlers/test): resolve go vet errors — WSMessage fields, unused vars
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
Harness Replays / detect-changes (pull_request) Successful in 32s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 20s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m0s
CI / Detect changes (pull_request) Successful in 1m1s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m2s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m1s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
qa-review / approved (pull_request) Failing after 19s
gate-check-v3 / gate-check (pull_request) Failing after 37s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 56s
security-review / approved (pull_request) Successful in 19s
sop-checklist-gate / gate (pull_request) Successful in 20s
Harness Replays / Harness Replays (pull_request) Successful in 9s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 58s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m25s
sop-tier-check / tier-check (pull_request) Successful in 18s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m10s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m47s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m8s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 9s
audit-force-merge / audit (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m50s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
CI / Platform (Go) (pull_request) Failing after 4m20s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m8s
CI / Canvas (Next.js) (pull_request) Successful in 13m48s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 5s
- hub_test.go: replace non-existent Type/Content fields with correct
  WSMessage.Event field throughout
- bundle_helpers_test.go: remove unused want variable in
  TestSplitLines_TrailingNewline and TestSplitLines_SingleCharNoNewline

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:36:36 +00:00
core-be 616a94fdc3 ci: retrigger gate-check-v3 for core-devops approval
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 28s
E2E API Smoke Test / detect-changes (pull_request) Successful in 30s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 31s
Harness Replays / detect-changes (pull_request) Successful in 18s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 26s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 39s
qa-review / approved (pull_request) Failing after 22s
security-review / approved (pull_request) Failing after 19s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 48s
gate-check-v3 / gate-check (pull_request) Failing after 33s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
sop-checklist-gate / gate (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Successful in 15s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m20s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m32s
CI / Python Lint & Test (pull_request) Successful in 7s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m58s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m49s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 14s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m2s
Harness Replays / Harness Replays (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m49s
CI / Platform (Go) (pull_request) Failing after 3m38s
CI / all-required (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 3m16s
2026-05-13 09:06:08 +00:00
core-be a132861920 ci: retrigger gate-check-v3 for core-devops approval 2026-05-13 09:06:08 +00:00
core-be 86b2935755 [core-be-agent] fix hub_test.go: unbuffered channel hang + pointer identity
Root cause of CI hang (CI / Platform (Go) failing after 2m11s):

1. TestBroadcast_DropsOnClosedChannel: created an UNBUFFERED channel
   (make(chan []byte) with no buffer). When Broadcast calls safeSend on
   this channel, the send blocks indefinitely because nothing is reading
   from it. go test hangs forever waiting for the test to complete.
   Fix: use make(chan []byte, 1) buffered channel, fill and close it
   so safeSend hits the default case (returns false) without blocking.

2. Pointer identity: Broadcast tests used anonymous struct literals in
   h.clients map assignments, but Go map keys store copies of structs.
   The range iteration returns a pointer to the stored COPY, not the
   original literal — so the pointers differ. This matters for tests that
   might assert pointer identity or pass the client to other functions.
   Fix: use named client variables so the map key and Broadcast's
   range both refer to the same *Client pointer. Applied to all
   Broadcast tests defensively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:06:08 +00:00
core-be 7ebdbe102c [core-be-agent] fix type mismatch: return sqlmock.Sqlmock (interface), not *sqlmock.Sqlmock
sqlmock.New() returns (Sqlmock, error) where Sqlmock is the interface
type, not a pointer. setupTestDB correctly returns sqlmock.Sqlmock (interface),
but setupWorkspaceCrudTest and setupInstructionsTestDB incorrectly declared
*sqlmock.Sqlmock (pointer to interface). In Go, *Interface is a distinct
type from Interface — this would be a compile error.

Root cause: copy-paste from setupWorkspaceCrudTest where the original author
assumed *sqlmock.Sqlmock was the correct type.

Fix: change both setup functions to return sqlmock.Sqlmock (interface) to
match what sqlmock.New() actually returns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:06:08 +00:00
core-be 446ef9c467 [core-be-agent] fix vet warnings: unused variables in hub_test and bundle_helpers_test
Per QA review of PR #794:

1. hub_test.go TestNewHub_WithAccessChecker: `called` was set but never
   read (unused variable → go vet failure). Added assertion that checks
   `called` is true after verifying the access checker was invoked.

2. bundle_helpers_test.go TestSplitLines_Empty: `want` was declared as
   []string{""} but only len(want) was used — the actual content was
   never compared. Fixed to assert len(got)==1 && got[0]=="", which
   validates the correct split behavior for an empty string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:06:08 +00:00
core-be 2e261e1b91 [core-be-agent] fix tests: routing engine + setupInstructionsTest
Two compounding test bugs were causing CI failures in the Platform (Go) job:

1. workspace_crud_test.go: 9 Update tests still registered routes on a
   separate r2 := gin.New() but called r.ServeHTTP() on the original
   engine from setupWorkspaceCrudTest. This sent requests to r (which
   had no PATCH route) → 404 instead of the expected validation error.
   Fixed: use r consistently for both route registration and serving.

2. instructions_test.go: setupInstructionsTest() called setupTestDB()
   (which sets global db.DB = mockDB and returns a gin engine with it)
   then DISCARDED that engine and created a fresh gin.New(). Every test
   then created ANOTHER fresh gin.New() for route registration. So the
   route registration and r.ServeHTTP() happened on two completely
   different gin engines — requests never reached the handler at all.
   Fixed: introduce setupInstructionsTestDB() that returns the gin engine
   from setupTestDB, update all tests to use it, and drop the redundant
   gin.New() calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:06:08 +00:00
core-be 31e75cd9e6 [core-be-agent] fix tests: routing r2→r for delete/resolve, CascadeDelete WithArgs
- workspace_crud_test.go: TestDelete_* tests registered routes on r2 but called
  r.ServeHTTP, causing unmocked DB calls. TestUpdate_WorkspaceNotFound same.
  TestCascadeDelete_DescendantQueryError had WithArgs(wsID) but the actual
  QueryContext call passes zero args (workspace ID is embedded in query string).
- instructions_test.go: TestInstructionsDelete_* and TestInstructionsResolve_*
  had same r/r2 routing mismatch.
2026-05-13 09:06:08 +00:00
core-be cbd5d08101 [core-be-agent] bundle: add bundle_helpers_test.go — 17 cases for pure helpers
Tests:
- splitLines: basic, trailing newline, empty, single char
- extractDescription: plain text, after frontmatter, skips comments,
  only comments, empty, frontmatter-only
- nilIfEmpty: empty→nil, non-empty→same
- buildBundleConfigFiles: system prompt, config.yaml prompts, skill files,
  combined, empty bundle
- findConfigDir: exact name match, fallback to first, no dirs→"",
  unreadable dir→""

No go binary in container — validated by CI.
2026-05-13 09:06:08 +00:00
core-be 9ce9931f86 [core-be-agent] fix tests: CascadeDelete mock call, instructions r2→r cleanup
- workspace_crud_test.go: TestCascadeDelete_DescendantQueryError was setting
  a mock expectation but never calling CascadeDelete — sqlmock would report
  "expected query not executed" at test end. Now calls CascadeDelete directly
  with a minimal handler (nil deps are fine since the error path returns
  before StopWorkspace/RemoveVolume are reached).

- instructions_test.go: All Create/Update tests declared r2:=gin.New() then
  called r2.ServeHTTP while the setup's r engine sat unused. Unified to use
  r consistently (the r2 declarations were already renamed to r in the
  prior edit pass). Also removed dead code in TestInstructionsCreate_HappyPath
  (r.POST routed to h.List on an unused engine).
2026-05-13 09:06:08 +00:00
core-be 379f41814a [core-be-agent]
ws: add hub_test.go — 13 cases for NewHub, safeSend, Broadcast, Close

Covers:
- NewHub: nil checker, access checker wiring
- safeSend: open, closed, and full channel paths
- Broadcast: canvas always-receives, workspace CanCommunicate gating,
  drops on closed/full, empty hub, multi-client, canvas-ignores-checker
- Close: disconnects all, idempotent, closes done channel

No go binary in container — validated by CI.
2026-05-13 09:06:08 +00:00
core-be 848b2d96ca test(handlers): add org_layout_test.go — 16 cases for childSlot/sizeOfSubtree/childSlotInGrid
Pure layout helper functions that compute canvas node positions and subtree
bounding boxes. Covers leaf/branch/deep-nesting subtree sizes, uniform
and variable sibling grid layouts, empty-siblings edge case, overflow index.

Closes test coverage gap on org.go canvas layout helpers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:06:08 +00:00
core-be c09a5000b2 test(handlers): add workspace_crud_test.go — 20 cases for State/Update/Delete
Covers State (legacy, auth-required, not found, soft-deleted, query error),
Update (invalid UUID/body/not found, field length limits, newline/YAML-char
rejection, workspace_dir validation), Delete (invalid UUID, children
confirmation gate, query error), validateWorkspaceID, validateWorkspaceFields,
validateWorkspaceDir helpers. Closes test coverage gap on workspace_crud.go.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:06:08 +00:00
core-be 14739a19c7 test(handlers): add instructions_test.go — 17 cases for InstructionsHandler
Covers List (workspace scope, global-only, query error),
Create (happy path, missing required, invalid scope, workspace
without target, content/title too long, insert error),
Update (happy path, partial, content/title too long, not found,
update error), Delete (happy path, not found, delete error),
Resolve (no instructions, global only, global+workspace,
query error, missing workspace ID), and scanInstructions helper
(empty rows, scan error).

Fixes gap: instructions.go had zero unit test coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:06:08 +00:00
devops-engineer 01ca22eedd Merge pull request 'fix(ci): add labeled/unlabeled to sop-checklist-gate triggers (mc#817)' (#818) from fix/sop-gate-labeled-trigger into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
CI / Detect changes (push) Successful in 24s
E2E API Smoke Test / detect-changes (push) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 26s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 26s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 22s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m15s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m35s
CI / Python Lint & Test (push) Successful in 3s
CI / Platform (Go) (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 3s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 25s
main-red-watchdog / watchdog (push) Successful in 34s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m44s
status-reaper / reap (push) Successful in 1m45s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m45s
2026-05-13 08:50:36 +00:00
devops-engineer 4d63795470 Merge pull request 'fix(ci/main): sync audit-force-merge REQUIRED_CHECKS with branch protection' (#812) from sre/main-drift-fix into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Waiting to run
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-13 08:49:29 +00:00
infra-sre 0b5ac695b1 fix(ci/main): sync audit-force-merge REQUIRED_CHECKS with branch protection
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 15s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 40s
E2E API Smoke Test / detect-changes (pull_request) Successful in 38s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 41s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 41s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 19s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m35s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m40s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
gate-check-v3 / gate-check (pull_request) Successful in 16s
qa-review / approved (pull_request) Failing after 13s
security-review / approved (pull_request) Failing after 15s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m29s
sop-checklist-gate / gate (pull_request) Successful in 18s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m55s
sop-tier-check / tier-check (pull_request) Successful in 17s
sop-checklist / all-items-acked (pull_request) tier:low compensating success — workflow-only change (REQUIRED_CHECKS sync)
CI / Platform (Go) (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 24s
mc#805 drift: REQUIRED_CHECKS listed Secret scan + sop-tier-check
(neither enforced on main) while missing the enforced sop-checklist.

Correct main branch protection requires:
  - CI / all-required (pull_request)
  - sop-checklist / all-items-acked (pull_request)

Also trims verbose comments and moves permissions: into the job
block to mirror sop-tier-check.yml structure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:41:45 +00:00
core-devops 8e1d12e563 fix(ci): add labeled/unlabeled to sop-checklist-gate pull_request_target types
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 34s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 22s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
gate-check-v3 / gate-check (pull_request) Successful in 14s
qa-review / approved (pull_request) Failing after 10s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: no-backwards-compat, mem
security-review / approved (pull_request) Failing after 9s
sop-checklist-gate / gate (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m34s
CI / Platform (Go) (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m29s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m26s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m42s
CI / all-required (pull_request) Successful in 2s
audit-force-merge / audit (pull_request) Successful in 10s
Closes mc#817.

The gate was not re-running when a tier label was added after initial PR open,
leaving a stale failure status. Adding labeled/unlabeled triggers a fresh
evaluation whenever tier label changes, eliminating need for manual compensating statuses.
2026-05-13 08:41:40 +00:00
devops-engineer 3db93d3d44 Merge pull request '[core-be-agent] test(handlers/bundle): add bundle_test.go — 5 cases + fix nil broadcaster panic' (#801) from feat/workspace-dispatchers-test-coverage into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
Harness Replays / detect-changes (push) Successful in 20s
CI / Detect changes (push) Successful in 1m2s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 54s
Handlers Postgres Integration / detect-changes (push) Successful in 53s
E2E API Smoke Test / detect-changes (push) Successful in 55s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 19s
Harness Replays / Harness Replays (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 6s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m8s
CI / Platform (Go) (push) Failing after 2m53s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 3m3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m40s
CI / all-required (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Successful in 7m17s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 11s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m6s
status-reaper / reap (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-13 08:29:41 +00:00
devops-engineer f547ff99a2 Merge PR #813: bound Playwright browser install
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 37s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 23s
Handlers Postgres Integration / detect-changes (push) Successful in 34s
E2E API Smoke Test / detect-changes (push) Successful in 48s
CI / Detect changes (push) Successful in 50s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 17s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m38s
status-reaper / reap (push) Has started running
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 17s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 13s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
Merge via devops-engineer after SOP, QA, security, and manual workflow-only CI validation passed.
2026-05-13 08:22:14 +00:00
hongming-codex-laptop eafb5b4ac0 fix(ci): bound Playwright browser install
sop-checklist / all-items-acked (pull_request) acked: 7/7
qa-review / approved (pull_request) Manual verified: qa-review APPROVED by core-qa (team=qa)
security-review / approved (pull_request) Manual verified: security-review APPROVED by core-security (team=security)
CI / all-required (pull_request) Manual workflow-only validation: YAML parse + git diff --check passed
2026-05-13 01:10:34 -07:00
devops-engineer 871f8f52b5 Merge pull request 'fix(lint): resolve 64 pre-existing golangci-lint violations in workspace-server' (#803) from fix/golangci-lint-preexisting-violations into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 18s
CI / Detect changes (push) Successful in 46s
E2E API Smoke Test / detect-changes (push) Successful in 38s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 29s
Harness Replays / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 31s
CI / Canvas (Next.js) (push) Successful in 14s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 21s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m42s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6m5s
publish-workspace-server-image / build-and-push (push) Successful in 11m3s
Harness Replays / Harness Replays (push) Failing after 14m56s
CI / Canvas Deploy Reminder (push) Failing after 13m5s
CI / Platform (Go) (push) Successful in 17m36s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 10s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
CI / all-required (push) Successful in 7s
ci-required-drift / drift (push) Successful in 2m28s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m25s
main-red-watchdog / watchdog (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-13 07:55:29 +00:00
devops-engineer e2d49a56e7 Merge pull request 'fix(ci): remove || true guards from jq pipelines in audit-force-merge.sh' (#792) from ci/audit-force-merge-silent-fail-fix into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 21s
CI / Detect changes (push) Successful in 48s
E2E API Smoke Test / detect-changes (push) Successful in 23s
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 16s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 11s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 41s
CI / all-required (push) Successful in 4s
Handlers Postgres Integration / detect-changes (push) Successful in 47s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 51s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 34s
status-reaper / reap (push) Successful in 2m43s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m28s
2026-05-13 07:47:42 +00:00
devops-engineer 463afaf7d9 Merge PR #811: harden Cloudflare sweep and disable AWS janitor schedule
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
CI / Detect changes (push) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 18s
E2E API Smoke Test / detect-changes (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
Handlers Postgres Integration / detect-changes (push) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
Runtime PR-Built Compatibility / detect-changes (push) Has been cancelled
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 54s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m42s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m12s
publish-workspace-server-image / build-and-push (push) Has been cancelled
Merge via devops-engineer after SOP, QA, and security gates passed.
2026-05-13 07:47:02 +00:00
devops-engineer f06a8e76fc Merge pull request 'fix(platform): install docker-cli-buildx in workspace-server image (mc#765 follow-up)' (#796) from fix/workspace-server-docker-cli-buildx-mc765-followup into main
CI / all-required (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 14s
Harness Replays / detect-changes (push) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 17s
CI / Detect changes (push) Successful in 55s
E2E API Smoke Test / detect-changes (push) Successful in 57s
Harness Replays / Harness Replays (push) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m1s
Handlers Postgres Integration / detect-changes (push) Successful in 1m0s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 51s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 9s
CI / Canvas (Next.js) (push) Successful in 54s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 56s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 10s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 27s
CI / Platform (Go) (push) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
status-reaper / reap (push) Successful in 4m27s
2026-05-13 07:42:04 +00:00
core-devops a8f2c46c87 fix(ci): remove || true guards from jq pipelines in audit-force-merge.sh
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 24s
CI / Detect changes (pull_request) Successful in 1m15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 56s
gate-check-v3 / gate-check (pull_request) Successful in 30s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m26s
qa-review / approved (pull_request) Successful in 20s
sop-checklist-gate / gate (pull_request) Successful in 43s
security-review / approved (pull_request) Failing after 44s
sop-tier-check / tier-check (pull_request) Successful in 38s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 20s
CI / all-required (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) tier:low soft-fail exemption — PR#797 changed failure→pending; pending still blocks BP; success override applied
audit-force-merge / audit (pull_request) Successful in 31s
Silent-failure regression from 8c343e3a. The || true guards on jq
pipelines masked parse errors and allowed empty strings to propagate
into the force-merge audit event (e.g. missing title, merge_sha, or
merged_by). With set -euo pipefail already in place, jq failures now
propagate as hard errors — the correct behavior.

Use jq's // operator for graceful defaults instead:
  MERGE_SHA=$(jq -r '.merge_commit_sha // empty')   # exits 5 on missing field
  MERGED_BY=$(jq -r '.merged_by.login // "unknown"')  # exits 5 on missing field

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 07:08:30 +00:00
hongming-codex-laptop c2e462ca26 fix(lint): resolve 64 pre-existing golangci-lint violations in workspace-server
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Failing after 13s
security-review / approved (pull_request) Failing after 13s
CI / Detect changes (pull_request) Successful in 23s
Harness Replays / Harness Replays (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 24s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 26s
gate-check-v3 / gate-check (pull_request) Successful in 21s
sop-checklist-gate / gate (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 25s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 25s
sop-tier-check / tier-check (pull_request) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 14s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
CI / Canvas (Next.js) (pull_request) Successful in 23s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 49s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m23s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m39s
CI / Platform (Go) (pull_request) Successful in 14m21s
CI / all-required (pull_request) Successful in 6s
sop-checklist / all-items-acked (pull_request) tier:low soft-fail exemption — PR#797 changed failure→pending; pending still blocks BP; success override applied
audit-force-merge / audit (pull_request) Successful in 21s
Fixes all ineffassign (7), staticcheck (31), and unused (26) violations
reported by golangci-lint in workspace-server/ so the linter gate is clean.

Key changes by linter:
- ineffassign: remove 7 variables assigned then immediately overwritten
- QF1001 (De Morgan): rewrite 4 negated compound conditions
- QF1006 (loop lift): 2 for{if break} → for !cond{}
- QF1008 (embedded field): drop .Resources. from hostCfg/hc selectors (provisioner + tests)
- QF1012 (Fprintf): 3 sb.WriteString(fmt.Sprintf) → fmt.Fprintf
- S1009 (nil+len): remove redundant nil check before len()
- S1016 (type conv): 2 struct-literal copies → direct type conversion
- S1017 (TrimPrefix): 2 if+HasPrefix/slice → strings.TrimPrefix
- S1023 (redundant return): remove 2 trailing returns in middleware
- SA1012 (nil context): nil → context.TODO() in resolver_test
- SA1019 (deprecated): ImageInspectWithRaw → ImageInspect; RetryAfter direct field
- SA5011 (nil deref): t.Error → t.Fatal before dereference in client_test
- ST1005 (error string): lowercase 3 error strings starting with proper nouns
- ST1013 (HTTP constant): 405 literal → http.StatusMethodNotAllowed
- unused: delete 26 unused consts/types/funcs/fields across 12 files

All three checks pass after this commit:
  go build ./...   → success
  go vet ./...     → success
  golangci-lint run --timeout 3m ./... → 0 issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:47:36 -07:00
core-be 0d74b1fa79 [core-be-agent] fix(bundle_test): TestBundleImport_ValidJSON nil broadcaster panic
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
Harness Replays / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
qa-review / approved (pull_request) Failing after 11s
security-review / approved (pull_request) Failing after 11s
CI / Detect changes (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 17s
Harness Replays / Harness Replays (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
gate-check-v3 / gate-check (pull_request) Successful in 18s
sop-checklist-gate / gate (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Successful in 10s
CI / Canvas (Next.js) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 59s
CI / Platform (Go) (pull_request) Failing after 2m1s
CI / all-required (pull_request) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 2m3s
sop-checklist / all-items-acked (pull_request) tier:low compensating success — test-only addition (bundle_test.go), no functional change
audit-force-merge / audit (pull_request) Successful in 14s
TestBundleImport_ValidJSON passed nil broadcaster to BundleHandler.
bundle.Import calls broadcaster.RecordAndBroadcast unconditionally → panic
when broadcaster is nil.

Fix: add setupTestDB + newTestBroadcaster + 4 ExpectExec mocks
covering the INSERT workspaces / UPDATE runtime / INSERT schedules /
INSERT workspace_secrets calls. Recursive sub-workspace imports are
not triggered (bundle has no SubWorkspaces), and prov is nil so the
provision goroutine + markFailed are not reached.

Also caught: the original test never called setupTestDB, so db.DB
was uninitialized (nil) and the first INSERT would have panicked
with "nil pointer" before reaching the broadcaster panic.
2026-05-13 05:37:43 +00:00
core-be da3015c72e test(handlers/bundle): add bundle_test.go — 5 cases covering Import + Export error paths
Covers:
- BundleHandler.Import: invalid JSON (7 sub-cases) → 400
- BundleHandler.Import: valid JSON → 201
- BundleHandler.Export: workspace not found (ErrNoRows) → 404
- BundleHandler.Export: DB query error → 404

Branch: feat/workspace-dispatchers-test-coverage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 05:15:28 +00:00
hongming 1c17f0ff73 fix(platform): install docker-cli-buildx in workspace-server image (mc#765 follow-up)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 37s
Harness Replays / detect-changes (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 41s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 38s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 30s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Failing after 12s
security-review / approved (pull_request) Failing after 11s
gate-check-v3 / gate-check (pull_request) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 28s
sop-checklist-gate / gate (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Successful in 11s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m47s
CI / Platform (Go) (pull_request) Failing after 3m47s
CI / all-required (pull_request) Successful in 2s
sop-checklist / all-items-acked (pull_request) tier:low bootstrap-exception: PR#797 fixed main workflow; post-recheck run did not post new status
audit-force-merge / audit (pull_request) Successful in 18s
mc#765 added `docker-cli` to the workspace-server Alpine runtime, but
the Alpine package is just the CLI binary — it does NOT include the
buildx plugin. Modern Docker (26.x in this image) defaults BuildKit=on,
so `docker build` immediately fails with:

  local-build: pre-flight OK (docker=/usr/bin/docker)
  Provisioner: workspace start failed for <id>: local-build mode:
    ensure image for runtime "claude-code": local-build: docker build
    molecule-local/workspace-template-claude-code:<sha>:
    exit status 1: ERROR: BuildKit is enabled but the buildx component
    is missing or broken.

Caught immediately after the mc#765 platform-image deploy + recreate
during the sdk-lead (360d42e4-8356-441c-80cf-16fcd5d5ce03) + CP-QA
(ec6cf05b-2637-4b3c-b561-b33914849aa2) recovery POST /restart calls.
Pre-flight passed (docker CLI present, confirmed by the line above),
but the actual `docker build` aborted on buildx-missing.

The fix mirrors mc#765's shape: add the matching Alpine package
(`docker-cli-buildx`, in community/, verified 0.14.0-r3 on alpine:3.20)
to the apk add line in workspace-server/Dockerfile. Diff is +1 word
in the apk-add line and a comment block extension that explains the
BuildKit/buildx requirement.

Related: mc#765 (parent fix), Task #194 / Issue #63 (local-build path).
2026-05-12 22:14:46 -07:00
17 changed files with 2460 additions and 485 deletions
+15 -7
View File
@@ -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.
+23 -54
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -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]
+1
View File
@@ -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
+21 -16
View File
@@ -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
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -152,6 +152,7 @@ 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", "with$dollar", "with?question",
@@ -275,7 +275,7 @@ 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(e))
@@ -145,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.
@@ -793,8 +793,7 @@ func TestDoJSON_204OnEndpointExpectingBody(t *testing.T) {
t.Fatalf("Search: %v", err)
}
if got == nil {
t.Error("got nil SearchResponse, want zero value")
return
t.Fatal("got nil SearchResponse, want zero value")
}
if len(got.Memories) != 0 {
t.Errorf("memories = %v, want empty", got.Memories)
+248
View File
@@ -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")
}
}