Files
llmgw/internal/scim/scim_test.go

158 lines
5.8 KiB
Go

package scim
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type mem struct {
users map[string]store.User
sessions map[string]bool
roles map[string]string
members map[string]bool
}
func newMem() *mem {
return &mem{users: map[string]store.User{}, sessions: map[string]bool{}, roles: map[string]string{}, members: map[string]bool{}}
}
func (m *mem) Users(context.Context, store.UserState) ([]store.User, error) {
var out []store.User
for _, u := range m.users {
out = append(out, u)
}
return out, nil
}
func (m *mem) UserByID(_ context.Context, id string) (store.User, error) {
if u, ok := m.users[id]; ok {
u.Role = m.roles[id]
return u, nil
}
return store.User{}, store.ErrNotFound
}
func (m *mem) UserByEmail(_ context.Context, e string) (store.User, error) {
for _, u := range m.users {
if u.Email == e {
return u, nil
}
}
return store.User{}, store.ErrNotFound
}
func (m *mem) CreateUser(_ context.Context, u store.User) error { m.users[u.ID] = u; return nil }
func (m *mem) SetUserState(_ context.Context, id string, s store.UserState) error {
u := m.users[id]
u.State = s
m.users[id] = u
return nil
}
func (m *mem) DeleteUserSessions(_ context.Context, id string) error {
delete(m.sessions, id)
return nil
}
func (m *mem) SetStaffRole(_ context.Context, id, role string) error { m.roles[id] = role; return nil }
func (m *mem) AddMembership(_ context.Context, u string, sc store.Scope, id, _, _ string, _ time.Time) error {
m.members[u+"@"+string(sc)+":"+id] = true
return nil
}
func (m *mem) RemoveMembership(_ context.Context, u string, sc store.Scope, id string, _ time.Time) (int, error) {
delete(m.members, u+"@"+string(sc)+":"+id)
return 1, nil
}
func (m *mem) AllOrgs(context.Context) ([]store.Org, error) { return nil, nil }
func (m *mem) AllTeams(context.Context) ([]store.TeamPath, error) {
return []store.TeamPath{{ID: "t1", Name: "ml", OrgName: "Acme"}}, nil
}
func (m *mem) AllProjects(context.Context) ([]store.ProjectPath, error) {
// As the store writes them.
return []store.ProjectPath{{ID: "p1", Path: "Acme / ml / train"}}, nil
}
func server(t *testing.T) (*mem, http.Handler) {
st := newMem()
mux := http.NewServeMux()
New(st, "s3cret", Mapping{Roles: map[string][]string{"operator": {"ops"}},
Memberships: map[string]string{"ml": "team:Acme/ml", "trainers": "project:Acme/ml/train"}}, slog.New(slog.DiscardHandler)).Register(mux)
return st, mux
}
func do(t *testing.T, h http.Handler, method, path, body string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Authorization", "Bearer s3cret")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec, out
}
func TestAProviderProvisionsAndSuspendsAccounts(t *testing.T) {
st, h := server(t)
r := httptest.NewRequest("GET", "/scim/v2/Users", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != 401 {
t.Errorf("no token: %d", rec.Code)
}
rec, u := do(t, h, "POST", "/scim/v2/Users", `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName":"Ann@Example.com","name":{"givenName":"Ann","familyName":"Lee"},"active":true}`)
if rec.Code != 201 || u["userName"] != "ann@example.com" || u["active"] != true {
t.Fatalf("create: %d %v", rec.Code, u)
}
id := u["id"].(string)
if st.users[id].Name != "Ann Lee" || st.users[id].PasswordHash != "!scim" {
t.Errorf("stored %+v", st.users[id])
}
if rec, _ := do(t, h, "POST", "/scim/v2/Users", `{"userName":"ann@example.com"}`); rec.Code != 409 {
t.Errorf("again: %d", rec.Code)
}
if _, l := do(t, h, "GET", `/scim/v2/Users?filter=userName+eq+%22ANN@example.com%22`, ""); l["totalResults"] != float64(1) {
t.Errorf("filter: %v", l)
}
// Entra writes {op: Replace, path: active, value: "False"}; Okta {op:
// replace, value: {active: false}}.
st.sessions[id] = true
do(t, h, "PATCH", "/scim/v2/Users/"+id, `{"Operations":[{"op":"Replace","path":"active","value":"False"}]}`)
if st.users[id].State != store.UserSuspended || st.sessions[id] {
t.Errorf("suspend: %+v sessions %v", st.users[id], st.sessions[id])
}
do(t, h, "PATCH", "/scim/v2/Users/"+id, `{"Operations":[{"op":"replace","value":{"active":true}}]}`)
if st.users[id].State != store.UserActive {
t.Errorf("reinstate: %+v", st.users[id])
}
if rec, _ := do(t, h, "DELETE", "/scim/v2/Users/"+id, ""); rec.Code != 204 || st.users[id].State != store.UserSuspended {
t.Errorf("delete is suspend: %d %+v", rec.Code, st.users[id])
}
// Groups the mapping knows give roles and places.
do(t, h, "PATCH", "/scim/v2/Users/"+id, `{"Operations":[{"op":"replace","value":{"active":true}}]}`)
do(t, h, "PATCH", "/scim/v2/Groups/ops", `{"Operations":[{"op":"add","path":"members","value":[{"value":"`+id+`"}]}]}`)
do(t, h, "PATCH", "/scim/v2/Groups/ml", `{"Operations":[{"op":"add","path":"members","value":[{"value":"`+id+`"}]}]}`)
if st.roles[id] != "operator" || !st.members[id+"@team:t1"] {
t.Errorf("roles %v members %v", st.roles, st.members)
}
do(t, h, "PATCH", "/scim/v2/Groups/ml", `{"Operations":[{"op":"remove","path":"members[value eq \"`+id+`\"]"}]}`)
do(t, h, "PATCH", "/scim/v2/Groups/ops", `{"Operations":[{"op":"remove","path":"members","value":[{"value":"`+id+`"}]}]}`)
if st.roles[id] != "" || st.members[id+"@team:t1"] {
t.Errorf("after leaving: roles %v members %v", st.roles, st.members)
}
do(t, h, "PATCH", "/scim/v2/Groups/trainers", `{"Operations":[{"op":"add","path":"members","value":[{"value":"`+id+`"}]}]}`)
if !st.members[id+"@project:p1"] {
t.Errorf("a project written without spaces: %v", st.members)
}
if _, l := do(t, h, "GET", "/scim/v2/Groups", ""); l["totalResults"] != float64(3) {
t.Errorf("groups: %v", l)
}
}