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_repository namespace-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 the slugMiddleware seam, this one included. A deployment without an iam: block still gets the allow-all evaluator until S09's fail-closed boot step retires it.
  • Audit gets a new remote.NamespaceConnectionTestEvent carrying format and credentials_supplied (submission, not attachment: the two together say whether the probe sent the credentials) rather than a widened row-keyed ConnectionTestEvent.
  • The settings.go validators gain a field-path parameter, so this endpoint's 400s name url and credentials.username while create keeps settings.url. The settings callers' messages stay byte-identical.
  • The handler marks every probe request Close before 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 by max_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/managementapi and cmd/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. NamespaceConnectionTestEvent carries format and credentials_supplied instead.
  • 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 Close propagation. A named accepted residue, bounded per probe by max_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.
Edited by Hayley Swimelar

Merge request reports

Loading
Loading