mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
internal/server/middleware/identity.go + pkg/auth/middleware.go: RequireIdentity(true) now rejects empty X-Org-Id regardless of X-User-Id presence. The previous shape only 401'd when BOTH headers were empty, letting "X-User-Id only" requests fall through with empty org context. Empty org collapses to the solo bucket and reaches handlers with unscoped queries until 021_org_id_not_null lands. Org is the trust pivot for SQL scoping; user is informational. The gin and stdlib paths now reject identically. Tests: - pkg/auth: TestRequireIdentity_RequireTrueRejectsUserOnly (renamed from RequireTrueAcceptsUserOnly), TestRequireIdentity_RequireTrueAcceptsOrgOnly. - middleware: TestRequireIdentity_RejectsEmptyOrgWithUser, TestRequireIdentity_AcceptsOrgWithoutUser. go test ./pkg/auth/... ./internal/server/middleware/... PASS. Closes Red 2026-04-27 P1.
139 lines
3.7 KiB
Go
139 lines
3.7 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestRequireIdentity_RequireFalseAllowsMissing(t *testing.T) {
|
|
mw := RequireIdentity(false)
|
|
called := false
|
|
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
if got := OrgID(r.Context()); got != "" {
|
|
t.Errorf("OrgID: want empty, got %q", got)
|
|
}
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
|
|
if !called {
|
|
t.Fatal("downstream not called")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status: want 200, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireIdentity_RequireTrueRejectsMissing(t *testing.T) {
|
|
mw := RequireIdentity(true)
|
|
called := false
|
|
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
|
|
if called {
|
|
t.Fatal("downstream called despite missing identity")
|
|
}
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status: want 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireIdentity_PropagatesHeaders(t *testing.T) {
|
|
mw := RequireIdentity(true)
|
|
var (
|
|
gotOrg, gotUser, gotEmail string
|
|
)
|
|
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotOrg = OrgID(r.Context())
|
|
gotUser = UserID(r.Context())
|
|
gotEmail = UserEmail(r.Context())
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set(HeaderOrgID, "hanzo")
|
|
req.Header.Set(HeaderUserID, "user-123")
|
|
req.Header.Set(HeaderUserEmail, "[email protected]")
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status: want 200, got %d", rr.Code)
|
|
}
|
|
if gotOrg != "hanzo" {
|
|
t.Errorf("OrgID: want hanzo, got %q", gotOrg)
|
|
}
|
|
if gotUser != "user-123" {
|
|
t.Errorf("UserID: want user-123, got %q", gotUser)
|
|
}
|
|
if gotEmail != "[email protected]" {
|
|
t.Errorf("UserEmail: want [email protected], got %q", gotEmail)
|
|
}
|
|
}
|
|
|
|
func TestRequireIdentity_RequireTrueRejectsUserOnly(t *testing.T) {
|
|
// Empty X-Org-Id collapses to the solo bucket and reaches
|
|
// handlers with unscoped queries until 021_org_id_not_null
|
|
// lands. Cloud mode rejects regardless of X-User-Id presence:
|
|
// org is the trust pivot, user is informational.
|
|
mw := RequireIdentity(true)
|
|
called := false
|
|
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set(HeaderUserID, "user-anon")
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
|
|
if called {
|
|
t.Fatal("downstream called despite empty X-Org-Id")
|
|
}
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status: want 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireIdentity_RequireTrueAcceptsOrgOnly(t *testing.T) {
|
|
// Org-only is the canonical M2M shape: gateway has authenticated
|
|
// the caller against its API key (or another path) and emits
|
|
// X-Org-Id without a user. Allowed because org pins SQL scoping.
|
|
mw := RequireIdentity(true)
|
|
called := false
|
|
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
if got := OrgID(r.Context()); got != "hanzo" {
|
|
t.Errorf("OrgID: want hanzo, got %q", got)
|
|
}
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set(HeaderOrgID, "hanzo")
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
|
|
if !called {
|
|
t.Fatal("downstream not called despite org header")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status: want 200, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestOrgID_EmptyContext(t *testing.T) {
|
|
if got := OrgID(httptest.NewRequest(http.MethodGet, "/", nil).Context()); got != "" {
|
|
t.Errorf("OrgID(empty ctx): want empty, got %q", got)
|
|
}
|
|
}
|