package jwtauth import ( "context" "net/http" "net/http/httptest" "testing" "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" ) type fakeDeviceStore struct { touched []db.TouchDeviceParams } func (f *fakeDeviceStore) TouchDevice(_ context.Context, arg db.TouchDeviceParams) error { f.touched = append(f.touched, arg) return nil } // runMiddleware drives a request through the auth middleware and captures the claims // the downstream handler sees (nil if the request was rejected before reaching it). func runMiddleware(a *Authenticator, r *http.Request) (*httptest.ResponseRecorder, *Claims) { var captured *Claims h := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, rr *http.Request) { if c, ok := ClaimsFromContext(rr.Context()); ok { captured = c } w.WriteHeader(http.StatusOK) })) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w, captured } func TestMiddleware_ProdReadsAuthHeaders(t *testing.T) { a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{}) r := httptest.NewRequest(http.MethodGet, "/api/me", nil) r.Header.Set("X-Auth-Subject", "user-1") r.Header.Set("X-Auth-Scope", "app:cdrop:guest") r.Header.Set("X-Auth-Meta", "dev_abc") r.Header.Set("X-Auth-Name", "Alice") r.Header.Set("X-Auth-Roles", "admin, user") w, c := runMiddleware(a, r) if w.Code != http.StatusOK { t.Fatalf("status: got %d, want 200", w.Code) } if c == nil { t.Fatal("claims missing from context") } if c.UserID != "user-1" || c.DeviceID != "dev_abc" || c.Name != "Alice" { t.Errorf("claims wrong: %+v", c) } if !c.Guest() { t.Error("app:cdrop:guest scope should mark Guest()") } if len(c.Groups) != 2 || c.Groups[0] != "admin" || c.Groups[1] != "user" { t.Errorf("groups: got %v", c.Groups) } } func TestMiddleware_ProdMissingSubjectRejected(t *testing.T) { a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{}) r := httptest.NewRequest(http.MethodGet, "/api/me", nil) w, c := runMiddleware(a, r) if w.Code != http.StatusUnauthorized { t.Fatalf("no X-Auth-Subject: got %d, want 401", w.Code) } if c != nil { t.Error("handler must not run on rejected request") } } func TestMiddleware_TouchesManagedDevice(t *testing.T) { fs := &fakeDeviceStore{} a := New(&config.Config{AuthMode: "prod"}, fs) r := httptest.NewRequest(http.MethodGet, "/api/me", nil) r.Header.Set("X-Auth-Subject", "user-1") r.Header.Set("X-Auth-Scope", "app:cdrop:full") r.Header.Set("X-Auth-Meta", "dev_x") runMiddleware(a, r) if len(fs.touched) != 1 { t.Fatalf("touch count: got %d, want 1", len(fs.touched)) } if fs.touched[0].DeviceID != "dev_x" || fs.touched[0].Tier != "full" || fs.touched[0].UserID != "user-1" { t.Errorf("touch params: %+v", fs.touched[0]) } } func TestMiddleware_SkipsTouchWhenUnmanaged(t *testing.T) { fs := &fakeDeviceStore{} a := New(&config.Config{AuthMode: "prod"}, fs) r := httptest.NewRequest(http.MethodGet, "/api/me", nil) r.Header.Set("X-Auth-Subject", "user-1") r.Header.Set("X-Auth-Scope", "full") // no X-Auth-Meta → unmanaged caller (e.g. a global SSO browser) runMiddleware(a, r) if len(fs.touched) != 0 { t.Errorf("unmanaged caller should not touch a device row; got %d", len(fs.touched)) } } func TestMiddleware_DevMode(t *testing.T) { a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{}) r := httptest.NewRequest(http.MethodGet, "/api/me", nil) r.Header.Set("Authorization", "Bearer devtok") r.Header.Set("X-Dev-User", "dev-alice") r.Header.Set("X-Dev-Scope", "guest") w, c := runMiddleware(a, r) if w.Code != http.StatusOK || c == nil { t.Fatalf("dev auth failed: %d", w.Code) } if c.UserID != "dev-alice" || !c.Guest() { t.Errorf("dev claims wrong: %+v", c) } } func TestMiddleware_DevModeBadToken(t *testing.T) { a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{}) r := httptest.NewRequest(http.MethodGet, "/api/me", nil) r.Header.Set("Authorization", "Bearer wrong") w, _ := runMiddleware(a, r) if w.Code != http.StatusUnauthorized { t.Fatalf("bad dev token: got %d, want 401", w.Code) } } func TestClaimsTier(t *testing.T) { cases := []struct { scope string tier string guest bool }{ {"app:cdrop:guest", "guest", true}, {"app:cdrop:full", "full", false}, {"full", "full", false}, {"app:cdrop", "cdrop", false}, {"", "", false}, } for _, tc := range cases { c := &Claims{Scope: tc.scope} if c.Tier() != tc.tier { t.Errorf("Tier(%q): got %q, want %q", tc.scope, c.Tier(), tc.tier) } if c.Guest() != tc.guest { t.Errorf("Guest(%q): got %v, want %v", tc.scope, c.Guest(), tc.guest) } } } func TestSanitizeDeviceName(t *testing.T) { if got := SanitizeDeviceName(" Alice's Mac "); got != "Alice's Mac" { t.Errorf("trim: got %q", got) } // Non-ASCII is stripped (the name rides an HTTP header). if got := SanitizeDeviceName("名字abc"); got != "abc" { t.Errorf("non-ascii strip: got %q", got) } } func TestNormalizeDeviceType(t *testing.T) { for _, in := range []string{"macos", "windows", "linux", "ios", "browser"} { if got := normalizeDeviceType(in); got != in { t.Errorf("normalizeDeviceType(%q): got %q", in, got) } } if got := normalizeDeviceType("rogue"); got != "browser" { t.Errorf("unknown type should fall back to browser; got %q", got) } }