package rbac import ( "context" "net/http" "path/filepath" "testing" "nadir/internal/auditlog" "nadir/internal/auth" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" "github.com/danielgtaylor/huma/v2/humatest" ) func TestRbacMiddleware(t *testing.T) { tempDir := t.TempDir() auditStore, err := auditlog.New(filepath.Join(tempDir, "audit.db")) if err != nil { t.Fatal(err) } defer auditStore.Close() sessions, err := auth.NewSessionStore(filepath.Join(tempDir, "sessions.db")) if err != nil { t.Fatal(err) } tokenStore, err := auth.NewTokenStore(filepath.Join(tempDir, "tokens.db")) if err != nil { t.Fatal(err) } tokenAuth := auth.NewTokenAuth(tokenStore) r := New() r.DefineRole(Role{ Name: "test-role", ModuleGrants: map[string][]Permission{ "test-mod": {Read, Write}, }, }) r.AssignRole("alice", "test-role") // A machine credential is just another RBAC subject: the token name is // assigned a role exactly like a username. r.AssignRole("dash", "test-role") mux := http.NewServeMux() api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0"))) api.UseMiddleware(RbacMiddleware(api, sessions, tokenAuth, r, auditStore)) huma.Register(api, huma.Operation{ OperationID: "public-get", Method: "GET", Path: "/public", }, func(ctx context.Context, _ *struct{}) (*struct{ Body string }, error) { return &struct{ Body string }{Body: "public"}, nil }) huma.Register(api, huma.Operation{ OperationID: "gated-get", Method: "GET", Path: "/gated-read", Metadata: map[string]any{"module": "test-mod", "permission": "read"}, }, func(ctx context.Context, _ *struct{}) (*struct{ Body string }, error) { return &struct{ Body string }{Body: "gated-read"}, nil }) huma.Register(api, huma.Operation{ OperationID: "gated-post", Method: "POST", Path: "/gated-write", Metadata: map[string]any{"module": "test-mod", "permission": "write"}, }, func(ctx context.Context, _ *struct{}) (*struct{ Body string }, error) { return &struct{ Body string }{Body: "gated-write"}, nil }) // 1. Test public route resp := api.Get("/public") if resp.Code != http.StatusOK { t.Errorf("public GET: got status %d, want %d", resp.Code, http.StatusOK) } // 2. Test gated route without cookie -> 401 Unauthorized resp = api.Get("/gated-read") if resp.Code != http.StatusUnauthorized { t.Errorf("gated GET no cookie: got status %d, want %d", resp.Code, http.StatusUnauthorized) } // 3. Test gated route with invalid cookie -> 401 Unauthorized resp = api.Get("/gated-read", "Cookie: nadir_session_id=invalid") if resp.Code != http.StatusUnauthorized { t.Errorf("gated GET invalid cookie: got status %d, want %d", resp.Code, http.StatusUnauthorized) } // Create valid session token, err := sessions.Create("alice") if err != nil { t.Fatal(err) } // 4. Test gated route with valid cookie -> 200 OK resp = api.Get("/gated-read", "Cookie: nadir_session_id="+token) if resp.Code != http.StatusOK { t.Errorf("gated GET valid cookie: got status %d, want %d", resp.Code, http.StatusOK) } // 5. Test CSRF violation: POST with mismatched Origin header -> 403 Forbidden resp = api.Post("/gated-write", "Cookie: nadir_session_id="+token, "Origin: http://evil.com", "Host: example.com", struct{}{}) if resp.Code != http.StatusForbidden { t.Errorf("CSRF mismatched Origin: got status %d, want %d", resp.Code, http.StatusForbidden) } // 6. Test CSRF success: POST with matching Origin header -> 200 OK resp = api.Post("/gated-write", "Cookie: nadir_session_id="+token, "Origin: http://example.com", "Host: example.com", struct{}{}) if resp.Code != http.StatusOK { t.Errorf("CSRF matching Origin: got status %d, want %d", resp.Code, http.StatusOK) } // 7. Test gated route with unauthorized user -> 403 Forbidden tokenBob, err := sessions.Create("bob") if err != nil { t.Fatal(err) } resp = api.Get("/gated-read", "Cookie: nadir_session_id="+tokenBob) if resp.Code != http.StatusForbidden { t.Errorf("bob unauthorized GET: got status %d, want %d", resp.Code, http.StatusForbidden) } // 8. Bearer token for an assigned name -> 200 OK rawToken, err := tokenStore.Create("dash") if err != nil { t.Fatal(err) } resp = api.Get("/gated-read", "Authorization: Bearer "+rawToken) if resp.Code != http.StatusOK { t.Errorf("valid bearer GET: got status %d, want %d", resp.Code, http.StatusOK) } // 9. Bogus bearer token -> 401 Unauthorized resp = api.Get("/gated-read", "Authorization: Bearer nad_deadbeef") if resp.Code != http.StatusUnauthorized { t.Errorf("bogus bearer GET: got status %d, want %d", resp.Code, http.StatusUnauthorized) } // 10. Bearer token with no role assignment -> 403 Forbidden rawUnassigned, err := tokenStore.Create("orphan") if err != nil { t.Fatal(err) } resp = api.Get("/gated-read", "Authorization: Bearer "+rawUnassigned) if resp.Code != http.StatusForbidden { t.Errorf("unassigned bearer GET: got status %d, want %d", resp.Code, http.StatusForbidden) } }