Files
Commilitia-Drop/internal/jwtauth/middleware_test.go
T

173 lines
5.3 KiB
Go

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:commilitia-drop: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:commilitia-drop: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:commilitia-drop: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:commilitia-drop:guest", "guest", true},
{"app:commilitia-drop:full", "full", false},
{"full", "full", false},
{"app:commilitia-drop", "commilitia-drop", 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)
}
}