feat(managementapi): serve the upstream connection test (S17 Phase 6 plan: 22/42)
Why
Step 22 of the S17 Phase 6 plan: the connection test endpoint, POST /api/v1/:slug/repositories/:repository_name/test, per the spec's Connection test section. This is the S13 probe stack's first production wiring: the handler drives remote.HealthMonitor.Probe (manual trigger) through the Step 21 per-format target resolvers, renders the verdict with the stored health fields, and audits every probed request with its actor. Draining the operation's pending entry turns the contract sweep from "declared but unserved" to asserting the served arm.
Race window (inherited from the probe stack's AppSec review)
A URL change can interleave with the handler's two reads (URL snapshot, then the resolver's credential read). We document the write-ordering invariant instead of adding an atomic pair read. The write-side coupling holds in all three settings families. UpdateNpmRemoteRepository, UpdateMavenRemoteRepository, and UpdateContainerRemoteRepository each read the row FOR UPDATE and clear credentials in the same UPDATE as a url change (remote.CredentialManager.ApplyURLChange exposes the credential half for the URL-owning caller's transaction). Although the one route that reaches all three, the remote settings PATCH, wires no url and so changes none yet, every family's window is closed by its store rather than merely unreached. The handler reads URL-first, which eliminates old-credentials-to-new-host. The accepted residual: a rotation between the two reads sends new credentials to the previously stored host, one request wide, matching the sweep resolvers' documented window. Rejected alternatives: an atomic pair read needs a joint URL+credential snapshot seam remote.HealthTargetResolver deliberately does not expose (the scheduled sweep shares it), and new datastore write ordering adds nothing the three stores' UPDATEs do not already provide. The invariant's full statement is recorded here; under the comment-cap rules the code carries only the cap-sized doc on RemoteHealthSnapshotReader.
What (only the non-obvious parts)
- npm credential reads go through a cmd-side read-only adapter (
npmProbeCredentialReader) overNpmRemoteRepositoryStore.FindRemoteByRepositoryID, because datastore has no npmremote.CredentialRepostore yet. Swapping in a real store later is a one-line wiring change. - Audit vocabulary: the 400, 404, and pre-probe 500 arms emit no event. Every request from the target resolve onward emits exactly one: the verdict on a nil probe error,
unavailableon a resolver or probe error, canceled requests included. That reads the spec's "on every request" as "every request that reaches attributable work", consistent with acceptance criteria #85 through #88. - Unrecorded probes (counter-degraded or a lost compare-and-set) re-read and render the stored state. A re-read miss answers the existence-hiding 404. A re-read failure answers 500 with the true verdict already audited.
health_check.probe_timeouthas no upper bound while the monitor's detached record budget is 5 seconds against the 10-second write deadline. We record the coupling here rather than cross-validating configuration at load, which would refuse valid sweep-only configurations; under the comment-cap rules the handler carries only its cap-sized doc.- Manual probes are uncapped: the spec's SSRF bullet bounds each call by the probe timeout and audits it, and platform rate limiting stays out of scope. The scheduled sweep's 10-probe concurrency cap is the precedent for where in-service bounding lives.
Size
About 840 production and 2550 test LOC. The route, its wiring, and the suites cannot land separately: mounting the route without its sources panics the dependency guard, and draining the contract's pending entry without serving the arm fails the sweep.
Test plan
- Unit: handler tables over recording fakes (kind gate across every kind, body gate ordering, reachability taxonomy, audit vocabulary, render arms, fail-closed format dispatch).
- Integration: real per-format stores, monitors, Step 21 resolvers, a Redis-backed failure counter, and the
upstreamhttpcovers againsthttptestupstreams: per-format credential attachment, threshold and recovery, and durable read-back. The contract sweep asserts the served arm against the declared OpenAPI responses. Wiring tests pin the nil-dependency panic matrix and per-family monitor pairing. - Gates at head: build, gofmt, vet (default and integration tags), golangci-lint in both modes with
--max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false(zero findings in the files this diff touches), unit and integration suites green, and datastore'sTestEveryStatementIsInstrumented(this MR dispatches no direct statements: the cmd adapters call instrumented store methods). - Run recipe:
driver.sh smokeat this head reports 71 passed, 0 failed. No recipe edit, because.claude/skills/run-artifact-registry/SKILL.mdcarries no route inventory, so this branch's boot step and route make nothing in it false. - No e2e catalog scenario: the
maven.md,docker.md, andoci.mdcatalogs underdocs/testing/e2e/scope remote repositories out, andnpm.md's## Remote repositoriessection scopes its own coverage to the proxied packument read, seeding the remote row out of band because no management-API route creates one. The connection-test journey arrives with the remote-repository management wave.
Spec coverage
Spec: docs/specs/S17-rest-management-api.md
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-85 | POST .../test on a remote returns 200 with passed, http_status, and the stored health fields, updates the durable pair, and emits the audit event naming the actor, including when the durable write loses to a concurrent one; hosted or virtual returns 404; a non-empty body returns 400 |
TestConnectionTest_RecordedProbeAnswers200, TestConnectionTest_LostWriteRendersTheReread, TestConnectionTest_ActorRendering, TestConnectionTest_KindGate, TestConnectionTest_NonEmptyBodyIs400, TestConnectionTest_EmptyBodyProceeds, TestConnectionTestIntegration_ThresholdAndRecovery (durable read-back) |
| AC-86 | 401/404 report passed: true; a 5xx reports passed: false with that status; a transport failure reports http_status: null while a previously healthy remote stays healthy below the threshold; a success flips unhealthy to healthy in the same call |
TestConnectionTest_RecordedProbeAnswers200, TestConnectionTestIntegration_ReachabilityTaxonomy, TestConnectionTestIntegration_ThresholdAndRecovery |
| AC-87 | A probe that ran but whose health-column write failed answers 500 with the audit entry still naming the actor and recording unavailable; a counter-unreachable or CAS-lost probe answers 200 with the probe's verdict |
TestConnectionTest_ProbeFailureAuditsUnavailable, TestConnectionTest_ResolverFailureAuditsUnavailable, TestConnectionTest_CanceledProbeStillAudits, TestConnectionTest_DetachedDeadlineFailureIs500, TestConnectionTest_LostWriteRendersTheReread, TestConnectionTest_RereadFailureIs500WithTrueVerdictAudited |
| AC-88 | Credentials attach as the probe sends them: Basic for Maven, bearer for npm, nothing for container, whose 401/404 answer still passes | TestConnectionTestIntegration_CredentialAttachment, TestConnectionTestIntegration_ReachabilityTaxonomy, TestConnectionTest_FormatDispatch |
| AC-93 | The connection test on a repository of the wrong kind returns 404 (this MR owns the connection-test half; the association-route half belongs to the association steps) | TestConnectionTest_KindGate, TestConnectionTestIntegration_WrongKindIs404 |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Repository kind is not remote, or the repository is missing: 404 not_found |
TestConnectionTest_KindGate, TestConnectionTest_MissingRepositoryIs404, TestConnectionTest_UnknownSlugIs404, TestConnectionTest_CrossSlugIs404, TestConnectionTest_SoftDeletedRepositoryIs404, TestConnectionTest_RereadMissIs404, TestConnectionTestIntegration_WrongKindIs404 |
| E-2 | Non-empty request body: 400 bad_request |
TestConnectionTest_NonEmptyBodyIs400 (order pinned ahead of the repository read), TestConnectionTest_UnreadableBodyIs400, TestConnectionTest_EmptyBodyProceeds (boundary control) |
| E-3 | Probe ran but the health-column write failed: 500 internal_server_error, audit outcome unavailable |
TestConnectionTest_ProbeFailureAuditsUnavailable, TestConnectionTest_CanceledProbeStillAudits, TestConnectionTest_DetachedDeadlineFailureIs500 |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | SSRF: request-time covers on every use, per-call audit, bounded by the probe timeout | Integration probes run through the real upstreamhttp client (loopback allowlisted by the test-only remotetest.LoopbackClientConfig); the covers' own behavior is owned by internal/remote/upstreamhttp's suite. Per-call audit: assertOneAuditEvent across the unit suite and the audit walk in TestConnectionTestIntegration_ThresholdAndRecovery. The update_repository requirement runs behind the S09 stub (ADR-021 integration) and is not testable in this MR |
| S-2 | Existence hiding: wrong-kind and missing answer the same 404 | TestConnectionTest_KindGate, TestConnectionTest_MissingRepositoryIs404, TestConnectionTest_RereadMissIs404 (all assert the shared not_found envelope) |
| S-3 | Authentication and authorization | S08/S09 stub-owned; not tested in this MR. Actor attribution through the real middleware chain: TestConnectionTest_ActorRendering |
| S-4 | Upstream credentials stay off this surface and out of responses and logs | TestConnectionTest_ResolverReceivesTheSnapshotRef (the seam carries no credential material), TestConnectionTestIntegration_CredentialAttachment (credentials appear on the upstream wire only); in-resolver redaction is owned by the internal/format/* and internal/remote suites |
Related to #314