feat(managementapi): repository update and delete handlers (S17 Phase 1 Step 10)

What this MR does

Implements S17 Phase 1 Step 10: the PATCH and DELETE single-resource handlers under /api/v1/{slug}/repositories/{repository_name}, replacing the last two 501 placeholders and completing the Phase 1 hosted-repository CRUD surface.

  • Update (PATCH → 200): the strict decode distinguishes omitted, null, and present keys. Carrying an immutable key (name, format, kind) is a 422 on presence alone — including at its current value and including {"name": null} — while a mutable field violating its schema (a null or unknown visibility, a non-string or over-long description) is a 400. Validation runs before any store access.
  • The store owns the PATCH merge: RepositoryStore.Update now takes a RepositoryPatch (optional visibility; tri-state description) and builds the SET list from the carried columns only. An omitted field never enters the statement, so two concurrent PATCHes touching different fields cannot clobber each other with a stale read. {} is a valid no-op answered from the current row without a write; a patch carrying no columns is a guarded store-contract violation.
  • Delete (DELETE → 204): resolves the row by name and maps the store's sentinels: ErrRepositoryNotEmpty to 409, the resolve-then-write race (ErrNotFound from Delete itself) to the existence-hiding 404, anything else to the logged 500.
  • Every handler response is validated against api/openapi/v1.yaml with kin-openapi, and DB-backed composition tests cover PATCH persistence (last_updated_at untouched, omitted columns surviving concurrent-style partial patches) and hard delete freeing the name for re-creation.

Behavior-preserving carry-ins riding with the step, from review of the sibling create MR: the repeated nil-result guard blocks fold into writeContractViolation, the by-name resolution the three single-resource handlers share moves into findRepositoryForRequest, parseListQuery's local 400 closure delegates to the shared badRequest factory, and production comments now cite only ADRs and the OpenAPI contract.

Size

2151 changed lines, ~73% of which are tests: the test-first authorship convention lands the full update/delete handler suites, the recording fakes, and the DB-backed composition tests in the first commit. The production change is ~580 lines across the two new handlers, the store's partial-SET Update, and the wiring. The commit split (test → feat → fix/refactor) mirrors the review path, so the production diff can be read first and the suites sampled.

Acceptance criteria

Spec: docs/specs/S17-rest-management-api.md

# Criterion Tests
AC-1 OpenAPI document defines endpoints and validates in CI Step 1 (merged); CI-owned. Not this MR.
AC-2 POST creates each format: 201, resource body, Location; three-row transaction with full rollback Step 9 MR (!801 (merged), merged). Not this MR.
AC-3 POST against an existing name returns 409 Step 9 MR (merged). Not this MR.
AC-4 Non-hosted kind 422; invalid fields or malformed body 400; visibility defaults to private Step 9 MR (merged). Not this MR.
AC-5 Per-format cap rejects with 422 Step 9 MR (merged). Not this MR.
AC-6 format=maven creates; container and npm likewise Step 9 MR (merged). Not this MR.
AC-7 GET detail 200/404, no settings key Step 8 MR (!788 (merged), merged). Not this MR.
AC-8 GET list filters, sorts, 400 on invalid params Step 8 MR (merged). Not this MR.
AC-9 GET list keyset pagination Step 8 MR (merged). Not this MR.
AC-10 PATCH updates description and visibility, returns the resource, leaves last_updated_at unchanged, 422 on immutable field TestUpdateHandler_UpdatesMutableFields, TestUpdateHandler_ImmutableField_Returns422, TestUpdateHandlerIntegration_PatchPersists; the column-level last_updated_at guarantee and the omitted-column regression pins are owned by TestRepositoryStore_Update (extended in this MR for the partial-SET contract)
AC-11 DELETE hard-deletes (204, name freed); artifacts 409 with the row left intact; miss 404 TestDeleteHandler_ExistingRepository_Returns204, TestDeleteHandler_RepositoryNotEmpty_Returns409, TestDeleteHandler_MissingRepository_Returns404, TestDeleteHandlerIntegration_DeleteFreesName; the atomic abort leaving the row intact is owned by TestRepositoryStore_Delete (Step 7, merged)
AC-12 Cross-slug isolation: absent from another slug's list, 404 on detail/update/delete Update/delete arms: TestUpdateHandler_CrossSlug_Returns404, TestDeleteHandler_CrossSlug_Returns404; list/detail arms owned by Step 8
AC-13 Every error path returns the S01 envelope with request_id assertErrorEnvelope asserted in every update/delete error test (400, 404, 409, 422, 500); schema conformance via assertMatchesContract

Error cases

# Condition Tests
E-1 Slug resolves to no namespace: 404 not_found TestUpdateHandler_UnknownSlug_404AndNoStoreCall, TestDeleteHandler_UnknownSlug_404AndNoStoreCall; TestHandler_UnknownSlug_Returns404WithEnvelope (PATCH/DELETE rows, pre-existing)
E-2 Malformed JSON body: 400 TestUpdateHandler_Validation_Returns400 (malformed, empty, non-object, trailing-data rows); create side owned by Step 9
E-3 Missing/invalid authentication: 401 (S08) Central auth middleware (S08 stub). Not tested in this MR.
E-4 Not permitted: 403 (S09) Central authz middleware (S09 stub). Not tested in this MR.
E-5 List invalid format/kind/sort/order/limit: 400 Step 8 MR (merged). Not this MR.
E-6 List cursor fails to decode or carries no boundary: 400 Step 8 MR (merged). Not this MR.
E-7 Create name conflict: 409 Step 9 MR (merged). Not this MR.
E-8 Create invalid name/format/visibility or over-long description: 400 Step 9 MR (merged). Not this MR.
E-9 Create non-hosted kind: 422 Step 9 MR (merged). Not this MR.
E-10 Create per-format cap reached: 422 Step 9 MR (merged). Not this MR.
E-11 Detail/Update/Delete repository missing: 404 TestUpdateHandler_MissingRepository_Returns404, TestUpdateHandler_RaceDeletedRow_Returns404, TestDeleteHandler_MissingRepository_Returns404, TestDeleteHandler_RaceDeletedRow_Returns404; detail arm owned by Step 8
E-12 Delete with artifacts: 409 conflict TestDeleteHandler_RepositoryNotEmpty_Returns409 (sentinel mapping); the FK abort itself is owned by TestRepositoryStore_Delete (Step 7, merged)
E-13 Update immutable field present: 422 TestUpdateHandler_ImmutableField_Returns422
E-14 Update invalid field value: 400 TestUpdateHandler_Validation_Returns400 (including the wrong-type visibility row); accepting edges in TestUpdateHandler_ValidationBoundaries_Accepted
E-15 Unexpected server failure: 500 TestUpdateHandler_ReaderFailure_Returns500, TestUpdateHandler_StoreFailure_Returns500, TestUpdateHandler_StoreFailure_LogsCause, TestUpdateHandler_StoreContractViolations_Return500, TestDeleteHandler_ReaderFailure_Returns500, TestDeleteHandler_StoreFailure_Returns500, TestDeleteHandler_StoreFailure_LogsCause; cancellation classification in TestUpdateHandler_CanceledUpdate_WritesNothing, TestDeleteHandler_CanceledDelete_WritesNothing

Security

# Concern Tests
S-1 Auth (S08) and authz (S09) on every endpoint Central middleware stubs, wired outside this handler. Not tested in this MR.
S-2 Existence hiding: missing or hidden is the same 404 TestUpdateHandler_MissingRepository_Returns404, TestDeleteHandler_MissingRepository_Returns404, the cross-slug and unknown-slug tests; TestUpdateHandler_ReaderFailure_Returns500 / TestDeleteHandler_ReaderFailure_Returns500 pin that an outage is never masked as the existence-hiding 404
S-3 Input validation before any database access TestUpdateHandler_Validation_Returns400 and TestUpdateHandler_ImmutableField_Returns422 (empty FindCalls()/updates() after rejection); the name pattern is create-side (Step 9)
S-4 Injection: Jet builder with bound parameters Datastore-owned (Step 7, merged). Not tested at this seam.
S-5 Name immutability TestUpdateHandler_ImmutableField_Returns422 (name row); closes rename-based authorization bypass (ADR-009)
S-6 Tenant isolation: queries scoped by resolved namespace_id TestUpdateHandler_UpdatesMutableFields and TestDeleteHandler_ExistingRepository_Returns204 (namespace-scoped store calls), TestUpdateHandler_CrossSlug_Returns404, TestDeleteHandler_CrossSlug_Returns404; query-side scoping owned by Step 7 integration tests

Related to #172 (closed)

Merge request reports

Loading
Loading