Skip to content

Commit d650450

Browse files
committed
admin: address Claude review on PR #644
Four findings on the follower-side forward client + handler integration: - High: PBAdminForwardClient declared opts as `...interface{}` via `type grpcCallOption = interface{}`, which is incompatible with the proto-generated *adminForwardClient's `...grpc.CallOption`. Go interface satisfaction requires exact variadic element match, so the production bridge would have failed to compile when assigning pb.NewAdminForwardClient(conn) to PBAdminForwardClient. Switch to grpc.CallOption (already a transitive dependency, no new import surface). - Medium: writeForwardResult re-emitted Content-Type and Cache-Control but dropped X-Content-Type-Options: nosniff that writeAdminJSONStatus sets on the leader-direct path. Added parity. New test TestDynamoHandler_ForwarderForwardsLeaderResponseSetsNosniff pins the header. - Low: missing test for "forwarder configured AND source returns non-ErrTablesNotLeader". Added TestDynamoHandler_ForwarderNotInvokedForNonNotLeaderError — table-driven sweep over already-exists, validation, and generic error, each confirming the forwarder is never invoked and the appropriate non-503 status is returned. - Nit: WithLeaderForwarder's nil semantics differ from WithLogger's. Added a doc comment explaining the intentional asymmetry — nil forwarder is a meaningful "disable" state, nil logger preserves the slog.Default() seed.
1 parent a4982a8 commit d650450

4 files changed

Lines changed: 81 additions & 6 deletions

File tree

internal/admin/dynamo_handler.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@ func (h *DynamoHandler) WithRoleStore(r RoleStore) *DynamoHandler {
213213
// to the standard 503 leader_unavailable response. Production
214214
// wires this to the gRPCForwardClient in main_admin.go; tests
215215
// inject a stub.
216+
//
217+
// Asymmetric vs WithLogger by design: WithLogger no-ops on nil to
218+
// preserve the slog.Default() seeded by NewDynamoHandler, but a
219+
// nil forwarder here is a meaningful "disable forwarding" state
220+
// (the gate in tryForwardCreate / tryForwardDelete checks for
221+
// nil and falls back to the leader-only 503 path).
216222
func (h *DynamoHandler) WithLeaderForwarder(f LeaderForwarder) *DynamoHandler {
217223
h.forwarder = f
218224
return h
@@ -468,6 +474,12 @@ func (h *DynamoHandler) tryForwardDelete(w http.ResponseWriter, r *http.Request,
468474
// leader-direct call from the SPA's point of view.
469475
func (h *DynamoHandler) writeForwardResult(w http.ResponseWriter, r *http.Request, res *ForwardResult) {
470476
w.Header().Set("Content-Type", res.ContentType)
477+
// Match writeAdminJSONStatus's hardening on the leader-direct
478+
// path: forwarded responses must also carry nosniff so a SPA
479+
// request that happened to traverse a follower does not
480+
// silently lose the MIME-sniff protection. Claude review on
481+
// PR #644 caught the parity gap.
482+
w.Header().Set("X-Content-Type-Options", "nosniff")
471483
w.Header().Set("Cache-Control", "no-store")
472484
// 503 from the leader (e.g. it stepped down mid-request) must
473485
// carry Retry-After so the client retries; preserve the

internal/admin/forward_client.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
pb "github.com/bootjp/elastickv/proto"
99
pkgerrors "github.com/cockroachdb/errors"
1010
"github.com/goccy/go-json"
11+
"google.golang.org/grpc"
1112
)
1213

1314
// LeaderForwarder is the contract the admin HTTP handler invokes
@@ -71,14 +72,17 @@ type GRPCConnFactory interface {
7172
// PBAdminForwardClient narrows pb.AdminForwardClient to just the
7273
// methods this package uses. The narrower interface keeps the test
7374
// stub implementation small.
75+
//
76+
// The opts parameter must use grpc.CallOption (not interface{}) so
77+
// the proto-generated *adminForwardClient satisfies this interface
78+
// directly — Go interface satisfaction requires exact method-
79+
// signature match, including variadic element types. Claude review
80+
// on PR #644 caught the mismatch before the bridge tried to assign
81+
// pb.NewAdminForwardClient(conn) and the build broke.
7482
type PBAdminForwardClient interface {
75-
Forward(ctx context.Context, in *pb.AdminForwardRequest, opts ...grpcCallOption) (*pb.AdminForwardResponse, error)
83+
Forward(ctx context.Context, in *pb.AdminForwardRequest, opts ...grpc.CallOption) (*pb.AdminForwardResponse, error)
7684
}
7785

78-
// grpcCallOption is a re-export of google.golang.org/grpc.CallOption
79-
// kept private so callers do not import proto's grpc dep directly.
80-
type grpcCallOption = interface{}
81-
8286
// gRPCForwardClient is the production LeaderForwarder. Construct
8387
// one with NewGRPCForwardClient. Two collaborators are required:
8488
// - resolver: returns the current leader address, or "" if absent

internal/admin/forward_client_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
pb "github.com/bootjp/elastickv/proto"
1010
"github.com/goccy/go-json"
1111
"github.com/stretchr/testify/require"
12+
"google.golang.org/grpc"
1213
)
1314

1415
// stubForwardConn is the in-memory PBAdminForwardClient the
@@ -20,7 +21,7 @@ type stubForwardConn struct {
2021
lastReq *pb.AdminForwardRequest
2122
}
2223

23-
func (s *stubForwardConn) Forward(_ context.Context, in *pb.AdminForwardRequest, _ ...grpcCallOption) (*pb.AdminForwardResponse, error) {
24+
func (s *stubForwardConn) Forward(_ context.Context, in *pb.AdminForwardRequest, _ ...grpc.CallOption) (*pb.AdminForwardResponse, error) {
2425
s.lastReq = in
2526
if s.err != nil {
2627
return nil, s.err

internal/admin/forward_integration_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,64 @@ func TestDynamoHandler_ForwarderTransportErrorReturns503(t *testing.T) {
160160
require.NotContains(t, rec.Body.String(), "transport sentinel")
161161
}
162162

163+
// TestDynamoHandler_ForwarderNotInvokedForNonNotLeaderError pins
164+
// the gate in tryForwardCreate / tryForwardDelete: the forward
165+
// path must run ONLY when the source returned ErrTablesNotLeader.
166+
// Other source errors (already-exists, validation, generic) must
167+
// fall through to writeTablesError and never reach the forwarder
168+
// — otherwise a leader-direct rejection like 409 Conflict would
169+
// be silently re-applied at the leader. Claude review on PR #644
170+
// noted the test gap; locking it down protects against a future
171+
// change accidentally removing the !errors.Is guard.
172+
func TestDynamoHandler_ForwarderNotInvokedForNonNotLeaderError(t *testing.T) {
173+
cases := []struct {
174+
name string
175+
err error
176+
wantCode int
177+
}{
178+
{"already_exists", ErrTablesAlreadyExists, http.StatusConflict},
179+
{"validation", &ValidationError{Message: "bad input"}, http.StatusBadRequest},
180+
{"generic", errors.New("opaque storage failure"), http.StatusInternalServerError},
181+
}
182+
for _, tc := range cases {
183+
t.Run(tc.name, func(t *testing.T) {
184+
fwd := &stubLeaderForwarder{}
185+
src := &stubTablesSource{createErr: tc.err}
186+
h := NewDynamoHandler(src).WithLeaderForwarder(fwd)
187+
req := httptest.NewRequest(http.MethodPost, pathDynamoTables, strings.NewReader(validCreateBody()))
188+
req = withWritePrincipal(req)
189+
rec := httptest.NewRecorder()
190+
h.ServeHTTP(rec, req)
191+
192+
require.Equal(t, tc.wantCode, rec.Code)
193+
require.Empty(t, fwd.lastCreateInput.TableName,
194+
"forwarder must not be invoked for source error: %s", tc.name)
195+
})
196+
}
197+
}
198+
199+
// TestDynamoHandler_ForwarderForwardsLeaderResponseSetsNosniff
200+
// confirms the security parity Claude flagged: writeForwardResult
201+
// must emit X-Content-Type-Options: nosniff just like the leader-
202+
// direct path's writeAdminJSONStatus, otherwise a SPA hitting a
203+
// follower would silently lose MIME-sniff protection on
204+
// forwarded responses.
205+
func TestDynamoHandler_ForwarderForwardsLeaderResponseSetsNosniff(t *testing.T) {
206+
fwd := &stubLeaderForwarder{createRes: &ForwardResult{
207+
StatusCode: http.StatusCreated,
208+
Payload: []byte(`{"name":"users"}`),
209+
ContentType: "application/json; charset=utf-8",
210+
}}
211+
h := newFollowerHandler(t, fwd)
212+
req := httptest.NewRequest(http.MethodPost, pathDynamoTables, strings.NewReader(validCreateBody()))
213+
req = withWritePrincipal(req)
214+
rec := httptest.NewRecorder()
215+
h.ServeHTTP(rec, req)
216+
217+
require.Equal(t, http.StatusCreated, rec.Code)
218+
require.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
219+
}
220+
163221
func TestDynamoHandler_ForwarderForwardsLeaderConflictResponse(t *testing.T) {
164222
// 409 Conflict from the leader (table already exists) must
165223
// be relayed verbatim to the SPA, not re-classified as 503.

0 commit comments

Comments
 (0)