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) } }