Files
nadir-agent/internal/rbac/middleware_test.go
T

157 lines
5.0 KiB
Go
Raw Normal View History

2026-06-22 16:06:57 +02:00
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)
}
}