mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:41:29 +00:00
99a012e3be
Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home. Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually. Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser). Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames. Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested. Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml.
245 lines
7.4 KiB
Go
245 lines
7.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/certctl-io/certctl/internal/auth"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
"github.com/certctl-io/certctl/internal/service"
|
|
)
|
|
|
|
// fakeApprovalSvc satisfies handler.ApprovalServicer for tests. The
|
|
// service-layer's same-actor RBAC + already-decided checks are
|
|
// re-implemented here so the handler-level tests can exercise the
|
|
// HTTP error-mapping without standing up the full ApprovalService.
|
|
type fakeApprovalSvc struct {
|
|
mu sync.Mutex
|
|
requests map[string]*domain.ApprovalRequest // keyed by ID
|
|
approveBy map[string]string // ID → decidedBy (for assertions)
|
|
}
|
|
|
|
func newFakeApprovalSvc() *fakeApprovalSvc {
|
|
return &fakeApprovalSvc{
|
|
requests: map[string]*domain.ApprovalRequest{},
|
|
approveBy: map[string]string{},
|
|
}
|
|
}
|
|
|
|
func (s *fakeApprovalSvc) seed(req *domain.ApprovalRequest) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cp := *req
|
|
s.requests[req.ID] = &cp
|
|
}
|
|
|
|
func (s *fakeApprovalSvc) Approve(ctx context.Context, requestID, decidedBy, note string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r, ok := s.requests[requestID]
|
|
if !ok {
|
|
return service.ErrApprovalNotFound
|
|
}
|
|
if r.State.IsTerminal() {
|
|
return service.ErrApprovalAlreadyDecided
|
|
}
|
|
if decidedBy == r.RequestedBy {
|
|
return service.ErrApproveBySameActor
|
|
}
|
|
r.State = domain.ApprovalStateApproved
|
|
s.approveBy[requestID] = decidedBy
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeApprovalSvc) Reject(ctx context.Context, requestID, decidedBy, note string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r, ok := s.requests[requestID]
|
|
if !ok {
|
|
return service.ErrApprovalNotFound
|
|
}
|
|
if r.State.IsTerminal() {
|
|
return service.ErrApprovalAlreadyDecided
|
|
}
|
|
if decidedBy == r.RequestedBy {
|
|
return service.ErrApproveBySameActor
|
|
}
|
|
r.State = domain.ApprovalStateRejected
|
|
s.approveBy[requestID] = decidedBy
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeApprovalSvc) Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r, ok := s.requests[id]
|
|
if !ok {
|
|
return nil, service.ErrApprovalNotFound
|
|
}
|
|
cp := *r
|
|
return &cp, nil
|
|
}
|
|
|
|
func (s *fakeApprovalSvc) List(ctx context.Context, filter *repository.ApprovalFilter) ([]*domain.ApprovalRequest, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []*domain.ApprovalRequest
|
|
for _, r := range s.requests {
|
|
if filter != nil && filter.State != "" && string(r.State) != filter.State {
|
|
continue
|
|
}
|
|
cp := *r
|
|
out = append(out, &cp)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// reqWithActor builds an httptest request with the auth-middleware UserKey
|
|
// pre-populated. Mimics what the auth middleware does in production.
|
|
func reqWithActor(t *testing.T, method, target string, body string, actor string, pathID string) (*http.Request, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
var br *strings.Reader
|
|
if body != "" {
|
|
br = strings.NewReader(body)
|
|
}
|
|
var req *http.Request
|
|
if br != nil {
|
|
req = httptest.NewRequest(method, target, br)
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if actor != "" {
|
|
req = req.WithContext(context.WithValue(req.Context(), auth.UserKey{}, actor))
|
|
}
|
|
if pathID != "" {
|
|
req.SetPathValue("id", pathID)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
return req, rr
|
|
}
|
|
|
|
// TestApproval_HandlerApproveAsSameActor_Returns403 — handler-level pin
|
|
// of the load-bearing RBAC contract. Compliance auditors expect HTTP 403
|
|
// (not 401, not 500) when the requester tries to approve their own
|
|
// request.
|
|
func TestApproval_HandlerApproveAsSameActor_Returns403(t *testing.T) {
|
|
svc := newFakeApprovalSvc()
|
|
svc.seed(&domain.ApprovalRequest{
|
|
ID: "ar-1",
|
|
JobID: "job-1",
|
|
ProfileID: "p-cdn",
|
|
RequestedBy: "user-alice",
|
|
State: domain.ApprovalStatePending,
|
|
})
|
|
h := NewApprovalHandler(svc)
|
|
|
|
req, rr := reqWithActor(t, http.MethodPost,
|
|
"/api/v1/approvals/ar-1/approve", `{"note":"self-approve"}`, "user-alice", "ar-1")
|
|
h.Approve(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403; got %d (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "two-person integrity") {
|
|
t.Fatalf("expected two-person-integrity message in body; got %s", rr.Body.String())
|
|
}
|
|
|
|
// Different actor approves successfully — pins the success path too.
|
|
req2, rr2 := reqWithActor(t, http.MethodPost,
|
|
"/api/v1/approvals/ar-1/approve", `{"note":"approved by different actor"}`, "user-bob", "ar-1")
|
|
h.Approve(rr2, req2)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for different-actor approve; got %d (body=%s)", rr2.Code, rr2.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestApproval_HandlerEmptyNote_Allowed_DecidedByExtractedFromAuth — handler
|
|
// accepts an empty body / empty note (no compliance-blocking format
|
|
// requirement) and the audit row records the absence. Pins that the
|
|
// handler extracts decided_by from the auth-middleware UserKey, NOT from
|
|
// the request body.
|
|
func TestApproval_HandlerEmptyNote_Allowed_DecidedByExtractedFromAuth(t *testing.T) {
|
|
svc := newFakeApprovalSvc()
|
|
svc.seed(&domain.ApprovalRequest{
|
|
ID: "ar-2",
|
|
JobID: "job-2",
|
|
ProfileID: "p-cdn",
|
|
RequestedBy: "user-charlie",
|
|
State: domain.ApprovalStatePending,
|
|
})
|
|
h := NewApprovalHandler(svc)
|
|
|
|
// Empty body + empty note both accepted.
|
|
req, rr := reqWithActor(t, http.MethodPost,
|
|
"/api/v1/approvals/ar-2/approve", "", "user-bob", "ar-2")
|
|
h.Approve(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for empty body; got %d (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Verify the response carries the auth-middleware-derived actor.
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode resp: %v", err)
|
|
}
|
|
if resp["decided_by"] != "user-bob" {
|
|
t.Fatalf("decided_by should come from auth middleware; got %v", resp["decided_by"])
|
|
}
|
|
|
|
// Confirm the service-layer recorded user-bob as the decider.
|
|
if got := svc.approveBy["ar-2"]; got != "user-bob" {
|
|
t.Fatalf("svc should have recorded decidedBy=user-bob; got %s", got)
|
|
}
|
|
|
|
// Unauthenticated request returns 401, not 500.
|
|
req2, rr2 := reqWithActor(t, http.MethodPost,
|
|
"/api/v1/approvals/ar-2/approve", "", "", "ar-2")
|
|
h.Approve(rr2, req2)
|
|
if rr2.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated; got %d", rr2.Code)
|
|
}
|
|
}
|
|
|
|
// TestApproval_HandlerNotFound_Returns404 + AlreadyDecided returns 409 —
|
|
// pin the error-status mapping for the remaining service sentinels.
|
|
func TestApproval_HandlerErrorMapping(t *testing.T) {
|
|
svc := newFakeApprovalSvc()
|
|
svc.seed(&domain.ApprovalRequest{
|
|
ID: "ar-decided",
|
|
JobID: "job-3",
|
|
ProfileID: "p-cdn",
|
|
RequestedBy: "user-alice",
|
|
State: domain.ApprovalStateApproved,
|
|
})
|
|
h := NewApprovalHandler(svc)
|
|
|
|
t.Run("NotFound_Returns_404", func(t *testing.T) {
|
|
req, rr := reqWithActor(t, http.MethodPost,
|
|
"/api/v1/approvals/missing/approve", "", "user-bob", "missing")
|
|
h.Approve(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404; got %d", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("AlreadyDecided_Returns_409", func(t *testing.T) {
|
|
req, rr := reqWithActor(t, http.MethodPost,
|
|
"/api/v1/approvals/ar-decided/approve", "", "user-bob", "ar-decided")
|
|
h.Approve(rr, req)
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409; got %d", rr.Code)
|
|
}
|
|
if !errors.Is(service.ErrApprovalAlreadyDecided, service.ErrApprovalAlreadyDecided) {
|
|
t.Fatal("sentinel sanity")
|
|
}
|
|
})
|
|
}
|