- Add sso_clients table (migration 000010) with client_id, redirect_uri, tags (JSON), enabled flag, and audit timestamps - Add SSOClient model struct and audit events - Implement DB CRUD with 10 unit tests - Add REST API: GET/POST/PATCH/DELETE /v1/sso/clients (policy-gated) - Add gRPC SSOClientService with 5 RPCs (admin-only) - Add mciasctl sso list/create/get/update/delete commands - Add web UI admin page at /sso-clients with HTMX create/toggle/delete - Migrate handleSSOAuthorize and handleSSOTokenExchange to use DB - Remove SSOConfig, SSOClient struct, lookup methods from config - Simplify: client_id = service_name for policy evaluation Security: - SSO client CRUD is admin-only (policy-gated REST, requireAdmin gRPC) - redirect_uri must use https:// (validated at DB layer) - Disabled clients are rejected at both authorize and token exchange - All mutations write audit events Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
193 lines
4.6 KiB
Go
193 lines
4.6 KiB
Go
package db
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestCreateAndGetSSOClient(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/sso/callback", []string{"env:prod"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateSSOClient: %v", err)
|
|
}
|
|
if c.ID == 0 {
|
|
t.Error("expected non-zero ID")
|
|
}
|
|
if c.ClientID != "mcr" {
|
|
t.Errorf("client_id = %q, want %q", c.ClientID, "mcr")
|
|
}
|
|
if !c.Enabled {
|
|
t.Error("new client should be enabled by default")
|
|
}
|
|
if len(c.Tags) != 1 || c.Tags[0] != "env:prod" {
|
|
t.Errorf("tags = %v, want [env:prod]", c.Tags)
|
|
}
|
|
|
|
got, err := db.GetSSOClient("mcr")
|
|
if err != nil {
|
|
t.Fatalf("GetSSOClient: %v", err)
|
|
}
|
|
if got.RedirectURI != "https://mcr.example.com/sso/callback" {
|
|
t.Errorf("redirect_uri = %q", got.RedirectURI)
|
|
}
|
|
if len(got.Tags) != 1 || got.Tags[0] != "env:prod" {
|
|
t.Errorf("tags = %v after round-trip", got.Tags)
|
|
}
|
|
}
|
|
|
|
func TestCreateSSOClient_DuplicateClientID(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("first create: %v", err)
|
|
}
|
|
|
|
_, err = db.CreateSSOClient("mcr", "https://other.example.com/cb", nil, nil)
|
|
if err == nil {
|
|
t.Error("expected error for duplicate client_id")
|
|
}
|
|
}
|
|
|
|
func TestCreateSSOClient_Validation(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
_, err := db.CreateSSOClient("", "https://example.com/cb", nil, nil)
|
|
if err == nil {
|
|
t.Error("expected error for empty client_id")
|
|
}
|
|
|
|
_, err = db.CreateSSOClient("mcr", "http://example.com/cb", nil, nil)
|
|
if err == nil {
|
|
t.Error("expected error for non-https redirect_uri")
|
|
}
|
|
}
|
|
|
|
func TestGetSSOClient_NotFound(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
_, err := db.GetSSOClient("nonexistent")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListSSOClients(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
clients, err := db.ListSSOClients()
|
|
if err != nil {
|
|
t.Fatalf("ListSSOClients (empty): %v", err)
|
|
}
|
|
if len(clients) != 0 {
|
|
t.Errorf("expected 0 clients, got %d", len(clients))
|
|
}
|
|
|
|
_, _ = db.CreateSSOClient("mcat", "https://mcat.example.com/cb", nil, nil)
|
|
_, _ = db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
|
|
|
|
clients, err = db.ListSSOClients()
|
|
if err != nil {
|
|
t.Fatalf("ListSSOClients: %v", err)
|
|
}
|
|
if len(clients) != 2 {
|
|
t.Fatalf("expected 2 clients, got %d", len(clients))
|
|
}
|
|
// Ordered by client_id ASC.
|
|
if clients[0].ClientID != "mcat" {
|
|
t.Errorf("first client = %q, want %q", clients[0].ClientID, "mcat")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSSOClient(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", []string{"a"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
newURI := "https://mcr.example.com/sso/callback"
|
|
newTags := []string{"b", "c"}
|
|
disabled := false
|
|
if err := db.UpdateSSOClient("mcr", &newURI, &newTags, &disabled); err != nil {
|
|
t.Fatalf("UpdateSSOClient: %v", err)
|
|
}
|
|
|
|
got, err := db.GetSSOClient("mcr")
|
|
if err != nil {
|
|
t.Fatalf("get after update: %v", err)
|
|
}
|
|
if got.RedirectURI != newURI {
|
|
t.Errorf("redirect_uri = %q, want %q", got.RedirectURI, newURI)
|
|
}
|
|
if len(got.Tags) != 2 || got.Tags[0] != "b" {
|
|
t.Errorf("tags = %v, want [b c]", got.Tags)
|
|
}
|
|
if got.Enabled {
|
|
t.Error("expected enabled=false after update")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSSOClient_NotFound(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
uri := "https://x.example.com/cb"
|
|
err := db.UpdateSSOClient("nonexistent", &uri, nil, nil)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSSOClient(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
if err := db.DeleteSSOClient("mcr"); err != nil {
|
|
t.Fatalf("DeleteSSOClient: %v", err)
|
|
}
|
|
|
|
_, err = db.GetSSOClient("mcr")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound after delete, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSSOClient_NotFound(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
err := db.DeleteSSOClient("nonexistent")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateSSOClient_NilTags(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if c.Tags == nil {
|
|
t.Error("Tags should be empty slice, not nil")
|
|
}
|
|
if len(c.Tags) != 0 {
|
|
t.Errorf("expected 0 tags, got %d", len(c.Tags))
|
|
}
|
|
|
|
got, err := db.GetSSOClient("mcr")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.Tags == nil || len(got.Tags) != 0 {
|
|
t.Errorf("Tags round-trip: got %v", got.Tags)
|
|
}
|
|
}
|