Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
158 lines
5.8 KiB
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)
|
|
}
|
|
}
|