feat(managementapi): serve the namespace connection test (S17 Namespace Connection Test plan: 2/2)
Stacked on chore(remote): extract the record-free reachabi... (!2212 - merged) • Hayley Swimelar • 19.4, merges after it. The diff shown is the delta over that branch.
Why
The monolith remote create form shows a Test connection button before any repository exists, and no AR surface can serve it. Create validates the URL statically, and the existing connection test is repository-scoped.
This MR adds POST /api/v1/{slug}/connection_test. It probes a body-supplied format, url, and optional credentials through the repository-scoped test's machinery (shared reject set, resolved-address covers, probe timeout, per-format credential attachment, sub-500 rule), returns 200 with passed and http_status, persists nothing, and audits every request that reaches the probe machinery. The contract is the merged spec patch docs(specs): spec the namespace connection test... (!1847 - merged) • Hayley Swimelar • 19.4, and the plan is docs(plans): plan the namespace connection test (!1933 - merged) • Hayley Swimelar • 19.4.
What
The choices the diff cannot surface on its own:
- The route declares the
create_repositorynamespace-scoped binding through the live S09 registration helper, and S09's management enforcement step (merged while this MR was in flight) checks every bound route at theslugMiddlewareseam, this one included. A deployment without aniam:block still gets the allow-all evaluator until S09's fail-closed boot step retires it. - Audit gets a new
remote.NamespaceConnectionTestEventcarryingformatandcredentials_supplied(submission, not attachment: the two together say whether the probe sent the credentials) rather than a widened row-keyedConnectionTestEvent. - The
settings.govalidators gain a field-path parameter, so this endpoint's 400s nameurlandcredentials.usernamewhile create keepssettings.url. The settings callers' messages stay byte-identical. - The handler marks every probe request
Closebefore it probes, at the one consumer of the builder sources, so no source can skip it and endpoint probes never park in the shared idle pool. The plan located the mark in the cmd-side builder sources. Review moved it into the handler, and the plan file stays as written. Redirect hops do not inherit the mark. The plan names that residue as accepted and bounded bymax_redirects, with the per-request audit event as the detection control.
Size
2,978 added lines over the parent branch, 101 deleted:
| Group | Added lines |
|---|---|
| Tests (unit, contract, integration, cmd) | 2,255 |
Production Go (internal/managementapi, internal/remote) |
354 |
| cmd wiring | 79 |
| OpenAPI + Bruno | 275 |
| Docs | 15 |
The plan's Acceptance pre-declares the size: contract, handler, audit, and wiring are one reviewable behavior, and the Bruno-alongside-OpenAPI guardrail binds the contract files to the same MR. Splitting would not help.
Spec coverage
| # | Criterion | Tests |
|---|---|---|
| AC #122 (closed) | OpenAPI defines the namespace connection test (per-format request sub-schemas, write-only credentials, two-field response, each failure response) and validates in CI | TestContract_NamespaceConnectionTest_DeclaresItsOperation, TestContract_NamespaceConnectionTestRequest_OneOfClosedBranches, TestContract_NamespaceConnectionTestRequest_DiscriminatesFormatPayloads, TestContract_NamespaceConnectionTestResult_TwoFieldShape; every contract test re-validates the whole document through loadContractDoc, and the docs:api CI job stays the document-wide gate |
| AC #123 (closed) | A valid body of each format probes and returns 200 with passed + http_status only; nothing persisted; a repository created after with the same url reads unknown/null health | TestNamespaceConnectionTest_FormatDispatch (all four formats as positive hits), TestNamespaceConnectionTest_VerdictMapping (body carries exactly two keys), TestNamespaceConnectionTestIntegration_ProbeOnTheWire, TestNamespaceConnectionTestIntegration_NothingPersisted |
| AC #124 (closed) | Credential attachment per format: Basic for maven, bearer for npm, nothing for docker/oci even when submitted; 401/404 still passed:true | TestNamespaceConnectionTest_FormatDispatch (attachment asserted on the request the real exported builder composes; container asserts no Authorization header at all), TestNamespaceConnectionTestIntegration_ProbeOnTheWire (on the wire), TestNamespaceConnectionTestIntegration_ReachabilityTaxonomy (401 and 404 passed rows) |
| AC #125 (closed) | A reject-set url returns 400 with no DNS and no outbound request; a covers-denied url at probe time returns 200 passed:false http_status:null with no request reaching the upstream | TestNamespaceConnectionTest_ValidationMatrix (every url row asserts zero builder-source and checker calls), TestNamespaceConnectionTestIntegration_CoversDeniedURL |
| AC #126 (closed) | The body validation matrix 400s; absent or null credentials probes unauthenticated | TestNamespaceConnectionTest_ValidationMatrix (byte-exact messages under the top-level field paths), TestNamespaceConnectionTest_AbsentAndNullCredentialsProbeUnauthenticated, TestNamespaceConnectionTestIntegration_StrictBody400 |
| AC #127 (closed) | Requires create_repository; standard 401/403/404; 5xx passed:false with status, sub-500 passed:true (405, 429 included); one audit event per request naming actor + normalized URL (probe-onward boundary per the plan's narrowing); credentials absent from every response, log, audit payload | Binding: the wantRouteBindings golden row + four TestWireManagementAPIWithDeps_PanicsOnNilDependencies rows. 401: TestAssembleApp_NamespaceConnectionTestRequiresAuth. 403: TestNamespaceConnectionTest_SuspendedNamespaceIs403 + the TestGatingIntegration_ManagementSuspendedIsReadOnly row (also proves no audit event beside the 403). 404: TestNamespaceConnectionTest_UnknownSlugIs404, TestNamespaceConnectionTestIntegration_UnknownSlugIs404. Verdicts: TestNamespaceConnectionTest_VerdictMapping, TestNamespaceConnectionTestIntegration_ReachabilityTaxonomy. Audit: TestNamespaceConnectionTest_AuditPayload, _ActorRendering, _NoVerdictExitsAuditUnavailable, _CanceledProbeStillAudits, TestRemoteAuditLogSink_NamespaceConnectionTestPayload. Non-echo: TestNamespaceConnectionTest_CredentialNonEcho (three surfaces, both credential shapes). S09's management enforcement step merged while this MR was in flight, so the create_repository denial is exercised too: main's TestAuthorizeRoute_EveryBoundRouteEnforcesItsPermission sweeps every registered binding, this route included |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Namespace connection test: body missing format or url, format outside the four values, a wrong-typed top-level field, invalid url (create's reject set), invalid credentials shape, unknown field, or trailing data -> 400 bad_request | TestNamespaceConnectionTest_ValidationMatrix (one row per rejection class), TestNamespaceConnectionTestIntegration_StrictBody400 |
| E-2 | All: slug resolves to no namespace -> 404; malformed JSON -> 400; body over the size cap -> 413; auth missing -> 401; not permitted -> 403 | The AC #127 (closed) rows above; the malformed-JSON, empty-body, non-object, and trailing-data rows sit in TestNamespaceConnectionTest_ValidationMatrix; the 413 rows for this route sit in TestWriteHandlers_OversizedChunkedBody_Returns413 (chunked body, both decoder arms) |
| E-3 | All: unexpected server failure -> 500 | TestNamespaceConnectionTest_NoVerdictExitsAuditUnavailable, TestNamespaceConnectionTest_DetachedDeadlineFailureIs500 |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | SSRF: the static reject set runs before any probe machinery, the resolved-address covers run at probe time, and every probe is audited per call | TestNamespaceConnectionTest_ValidationMatrix (zero-call assertions), TestNamespaceConnectionTestIntegration_CoversDeniedURL, the AC #127 (closed) audit rows; the probe-timeout latency bound lives in remote.NewReachabilityChecker, pinned by step 1's suite |
| S-2 | Transport-pool eviction: every endpoint probe request is marked Close so a finished probe never parks in the shared idle pool | TestNamespaceConnectionTest_FormatDispatch (the handler's mark on every format's composed builder, over a fake builder that marks nothing), TestNamespaceConnectionTestIntegration_ProbeOnTheWire (Connection: close observed by the upstream through the real builders) |
| S-3 | Submitted credentials appear in no response, log, or audit payload | TestNamespaceConnectionTest_CredentialNonEcho (three surfaces, pair and token shapes, the interpolating 400s included), TestContract_NamespaceConnectionTestRequest_OneOfClosedBranches (write-only marking), TestRemoteAuditLogSink_NamespaceConnectionTestPayload (redactAuditURL's userinfo and query arms, and no remote_repository_id on the line) |
| S-4 | Existence hiding: an unknown slug answers the indistinguishable 404 | TestNamespaceConnectionTest_UnknownSlugIs404, TestNamespaceConnectionTestIntegration_UnknownSlugIs404 |
| S-5 | Phase 6 permissions: the create_repository namespace-scoped binding | The wantRouteBindings golden row; enforcement runs at the slugMiddleware seam since S09's management enforcement step merged, and main's binding sweep covers this route (see AC #127 (closed)) |
E2e scenarios
No e2e catalog row is added or affected, as a decision rather than for lack of a home. The endpoint persists nothing, so a scenario has no cross-system state to observe beyond the response the integration suite already drives through the real covers against httptest upstreams. The create-form flow that consumes the endpoint end to end is the monolith S07 workstream's e2e surface. A row would sit in docs/testing/e2e/npm.md under Remote repositories, beside its existing API-interface row.
Test plan
- Unit and contract suites in
internal/managementapiandcmd/artifact-registry. - Integration suite through the real covers against httptest upstreams (testcontainers).
- The gating suite's suspended-namespace row and the assembled-app 401.
- Full CI.
Related to #812 (closed)
Context for LLM agents
Rationale
- Widening the row-keyed
remote.ConnectionTestEvent. Rejected: the event names a remote repository row, and consumers could not tell which test emitted.NamespaceConnectionTestEventcarriesformatandcredentials_suppliedinstead. - Copying the settings validators into the new handler. Rejected: the reject sets must not drift, so the validators gained a field-path parameter and both callers share one set.
- A no-record mode on
remote.HealthMonitor. Rejected in Step 1: the record-free seam is the standalone reachability checker. - A concurrency cap on the endpoint. Rejected: the spec's SSRF entry decides the guards (reject set, covers, probe timeout, audit), and platform rate limiting owns volume.
Non-goals
- A member-level test with per-member overrides. Out of scope by the issue.
- The monolith client, mutation, and form. That is the S07 workstream.
- Redirect-hop
Closepropagation. A named accepted residue, bounded per probe bymax_redirects, detected by the per-request audit event. If the acceptance flips, the follow-up is one line: mark each hop's request in the probe client's redirect hook. - ADR-009 and ADR-021 amendments. They ride the handbook batch per the spec's Follow-ups.
- The stale line in the sibling test-upstream-connection.bru. It predates this branch.