Skip to content

Commit ddc3b9b

Browse files
committed
admin: address PR #669 review (route comment, ValidationError test, MaxBytes nil doc)
Three actionable findings from claude review on 91a48ef: 1) **Medium: stale route-table comment in `buildAPIMux`** `internal/admin/server.go`: the comment block listing the routes only mentioned the two GET S3 endpoints. Added the three new write endpoints (POST /buckets, DELETE /buckets/{name}, PUT /buckets/{name}/acl) so a future reader does not have to reverse-engineer the routing from `servePerBucket`. 2) **Medium: missing handler-level `*ValidationError` test** `s3_handler_test.go`: `writeBucketsError`'s `errors.As(err, &verr)` arm was exercised end-to-end via the bridge translation but had no direct handler-level coverage. Added `TestS3Handler_WriteEndpoints_ValidationErrorReturns400` — three sub-cases (create / put_acl / delete) injecting a `*ValidationError` directly into the stub source and asserting 400 + `invalid_request` + the message. Locks the arm down so a future refactor that drops the `errors.As` branch fails CI immediately. 3) **Minor: `MaxBytesReader(nil, ...)` undocumented** `s3_handler.go`: added an explanatory comment at the call site. Passing nil is safe because `MaxBytesReader`'s only writer use is the connection-close optimisation (a comma-ok type assertion that no-ops on nil); the cap itself is enforced regardless. We pass nil because the helper takes only `io.Reader` so it remains usable from non-HTTP entry points (the upcoming AdminForward decoder reuses the same shape). The 413 path still works via `errAdminS3BodyTooLarge` matching on `*http.MaxBytesError`. Findings #3 (failed-write audit log) and #5 (DisallowUnknownFields error message) were noted as pre-existing patterns shared with the Dynamo side; tracking them separately rather than diverging on this slice.
1 parent 91a48ef commit ddc3b9b

3 files changed

Lines changed: 66 additions & 0 deletions

File tree

internal/admin/s3_handler.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,17 @@ func decodeAdminS3JSONBody[T any](body io.Reader) (T, error) {
437437
if body == nil {
438438
return zero, errors.New("request body is empty")
439439
}
440+
// nil ResponseWriter is safe here: MaxBytesReader's only use of
441+
// the writer is the connection-close optimisation (sets the
442+
// requestTooLarger flag on the writer when the cap is hit); the
443+
// nil-check inside is a comma-ok type assertion, so passing nil
444+
// just skips the optimisation. The cap itself is still enforced
445+
// and surfaces as *http.MaxBytesError on the read, which the
446+
// errors.As below catches and translates to errAdminS3BodyTooLarge.
447+
// We do not have access to the *http.Request's writer at this
448+
// layer (the helper takes only an io.Reader so the same code is
449+
// reusable from non-HTTP entry points like AdminForward decode).
450+
// Claude review on PR #669 flagged the nil as undocumented.
440451
limited := http.MaxBytesReader(nil, io.NopCloser(body), adminS3CreateBodyLimit)
441452
raw, err := io.ReadAll(limited)
442453
if err != nil {

internal/admin/s3_handler_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,58 @@ func TestS3Handler_DeleteBucket_NotLeaderReturns503(t *testing.T) {
648648
require.Equal(t, "1", rec.Header().Get("Retry-After"))
649649
}
650650

651+
func TestS3Handler_WriteEndpoints_ValidationErrorReturns400(t *testing.T) {
652+
// Pin the writeBucketsError *ValidationError arm — exercised
653+
// end-to-end via the bridge (adapter ErrAdminInvalid* →
654+
// translateAdminBucketsError → &ValidationError{...}) but
655+
// previously had no handler-level coverage. Locking the arm
656+
// down protects against a future refactor that drops the
657+
// errors.As branch and silently downgrades typed validation
658+
// failures to 500 (Claude review on PR #669).
659+
const msg = "invalid bucket name: uppercase letters not allowed"
660+
t.Run("create", func(t *testing.T) {
661+
src := &stubBucketsSource{createErr: &ValidationError{Message: msg}}
662+
h := newS3HandlerForTest(src)
663+
req := httptest.NewRequest(http.MethodPost, pathS3Buckets,
664+
strings.NewReader(validCreateBucketBody()))
665+
req = withFullPrincipal(req)
666+
rec := httptest.NewRecorder()
667+
h.ServeHTTP(rec, req)
668+
require.Equal(t, http.StatusBadRequest, rec.Code)
669+
require.Contains(t, rec.Body.String(), "invalid_request")
670+
require.Contains(t, rec.Body.String(), msg)
671+
})
672+
t.Run("put_acl", func(t *testing.T) {
673+
src := &stubBucketsSource{
674+
buckets: map[string]BucketSummary{"orders": {Name: "orders"}},
675+
putACLErr: &ValidationError{Message: msg},
676+
}
677+
h := newS3HandlerForTest(src)
678+
req := httptest.NewRequest(http.MethodPut, pathS3Buckets+"/orders/acl",
679+
strings.NewReader(`{"acl":"public-read"}`))
680+
req = withFullPrincipal(req)
681+
rec := httptest.NewRecorder()
682+
h.ServeHTTP(rec, req)
683+
require.Equal(t, http.StatusBadRequest, rec.Code)
684+
require.Contains(t, rec.Body.String(), "invalid_request")
685+
require.Contains(t, rec.Body.String(), msg)
686+
})
687+
t.Run("delete", func(t *testing.T) {
688+
src := &stubBucketsSource{
689+
buckets: map[string]BucketSummary{"orders": {Name: "orders"}},
690+
deleteErr: &ValidationError{Message: msg},
691+
}
692+
h := newS3HandlerForTest(src)
693+
req := httptest.NewRequest(http.MethodDelete, pathS3Buckets+"/orders", nil)
694+
req = withFullPrincipal(req)
695+
rec := httptest.NewRecorder()
696+
h.ServeHTTP(rec, req)
697+
require.Equal(t, http.StatusBadRequest, rec.Code)
698+
require.Contains(t, rec.Body.String(), "invalid_request")
699+
require.Contains(t, rec.Body.String(), msg)
700+
})
701+
}
702+
651703
func TestS3Handler_WriteEndpoints_RejectMissingPrincipal(t *testing.T) {
652704
// Without a session principal in the context, writes must
653705
// 401 — SessionAuth normally enforces this; principalForWrite

internal/admin/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@ func (s *Server) APIHandler() http.Handler {
200200
// GET /admin/api/v1/dynamo/tables/{name} (auth required)
201201
// DELETE /admin/api/v1/dynamo/tables/{name} (auth required, full role)
202202
// GET /admin/api/v1/s3/buckets (auth required)
203+
// POST /admin/api/v1/s3/buckets (auth required, full role)
203204
// GET /admin/api/v1/s3/buckets/{name} (auth required)
205+
// DELETE /admin/api/v1/s3/buckets/{name} (auth required, full role)
206+
// PUT /admin/api/v1/s3/buckets/{name}/acl (auth required, full role)
204207
//
205208
// Body limit applies uniformly. CSRF and Audit middleware apply to
206209
// write-capable protected endpoints; login and logout carry their own

0 commit comments

Comments
 (0)