173 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|