package adminapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) func TestTheTenancyTreeKeysAndMembershipsAreAdministered(t *testing.T) { ctx := context.Background() a, _ := setup(t, testModels()) s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "s.db")) if err != nil { t.Fatal(err) } defer s.Close() a.SetQuotas(s) a.SetTenancy(s) mux := http.NewServeMux() a.Register(mux) do := func(method, path, body string, out any) int { t.Helper() rec := httptest.NewRecorder() mux.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body))) if out != nil && rec.Code < 300 { if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil { t.Fatalf("%s %s: %v (%s)", method, path, err, rec.Body.String()) } } if rec.Code >= 300 { t.Logf("%s %s: %d %s", method, path, rec.Code, strings.TrimSpace(rec.Body.String())) } return rec.Code } var made TenancyItem if c := do("POST", "/api/v1/orgs", `{"name":"Acme"}`, &made); c != http.StatusCreated { t.Fatalf("adding an organization: %d", c) } acme := made.ID if c := do("POST", "/api/v1/orgs", `{"name":"Acme"}`, nil); c != http.StatusConflict { t.Errorf("a second Acme: %d", c) } if c := do("POST", "/api/v1/teams", `{"name":"hr"}`, nil); c != http.StatusBadRequest { t.Errorf("a team in nothing: %d", c) } do("POST", "/api/v1/teams", `{"name":"hr","in":"Acme"}`, &made) hr := made.ID if c := do("POST", "/api/v1/projects", `{"name":"crm","in":"Acme/hr"}`, &made); c != http.StatusCreated || made.In != hr { t.Fatalf("adding a project: %d %v", c, made) } crm := made.ID do("POST", "/api/v1/orgs", `{"name":"Other"}`, &made) other := made.ID // Renamed, and moved with what is in it. if c := do("PATCH", "/api/v1/projects/"+crm, `{"name":"crm2"}`, nil); c != http.StatusOK { t.Errorf("renaming: %d", c) } if c := do("PATCH", "/api/v1/teams/"+hr, `{"in":"Other"}`, nil); c != http.StatusOK { t.Errorf("moving: %d", c) } if c := do("PATCH", "/api/v1/orgs/"+acme, `{"in":"Other"}`, nil); c != http.StatusBadRequest { t.Errorf("moving an organization: %d", c) } var tree []OrgView do("GET", "/api/v1/orgs", "", &tree) if len(tree) != 2 || tree[1].ID != other || len(tree[1].Teams) != 1 || tree[1].Teams[0].Projects[0].Path != "Other/hr/crm2" { t.Fatalf("the tree is %+v", tree) } // A project's key: given once, listed without it. var key KeyCreated rec := httptest.NewRecorder() mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/keys", strings.NewReader(`{"name":"svc","project":"Other/hr/crm2","expires_in":"24h"}`))) if rec.Code != http.StatusCreated { t.Fatalf("issuing a key: %d %s", rec.Code, rec.Body.String()) } _ = json.Unmarshal(rec.Body.Bytes(), &key) if !strings.HasPrefix(key.Secret, "sk-llmgw-") || key.Project != crm || key.ExpiresAt.IsZero() { t.Errorf("the key issued: %+v", key) } if k, err := s.KeyByHash(ctx, store.HashKey(key.Secret)); err != nil || k.ID != key.ID { t.Errorf("the key does not authenticate: %v", err) } var keys []KeyView do("GET", "/api/v1/keys?project="+crm, "", &keys) if len(keys) != 1 || keys[0].ID != key.ID || keys[0].State != "active" || keys[0].ProjectPath != "Other/hr/crm2" { t.Errorf("the project's keys: %+v", keys) } raw := httptest.NewRecorder() mux.ServeHTTP(raw, httptest.NewRequest("GET", "/api/v1/keys", nil)) if strings.Contains(raw.Body.String(), key.Secret) || strings.Contains(raw.Body.String(), "secret") { t.Error("a listing holds the key") } if c := do("POST", "/api/v1/keys", `{"name":"x","project":"Other/hr/crm2","expires_in":"soon"}`, nil); c != http.StatusBadRequest { t.Errorf("a lifetime that is not one: %d", c) } if c := do("DELETE", "/api/v1/projects/"+crm, "", nil); c != http.StatusConflict { t.Errorf("removing a project with a key: %d", c) } if c := do("DELETE", "/api/v1/keys/"+key.ID, "", nil); c != http.StatusOK { t.Errorf("revoking: %d", c) } var one KeyView do("GET", "/api/v1/keys/"+key.ID, "", &one) if one.State != "revoked" { t.Errorf("after revoking, %+v", one) } // A person: a key in a project only as its member, revoked when they // leave it. if err := s.CreateUser(ctx, store.User{ID: "u1", Email: "ayse@example.com", PasswordHash: "x", State: store.UserActive, CreatedAt: time.Now()}); err != nil { t.Fatal(err) } if c := do("POST", "/api/v1/keys", `{"name":"mine","project":"`+crm+`","user":"ayse@example.com"}`, nil); c != http.StatusConflict { t.Errorf("a key where its person is no member: %d", c) } var u UserView if c := do("POST", "/api/v1/users/ayse@example.com/memberships", `{"target":"project:Other/hr/crm2"}`, &u); c != http.StatusOK || len(u.Memberships) != 1 || u.Memberships[0].Target != "project:"+crm || u.Memberships[0].Role != "member" { t.Fatalf("joining: %d %+v", c, u) } var mine KeyCreated rec = httptest.NewRecorder() mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/keys", strings.NewReader(`{"name":"mine","project":"`+crm+`","user":"u1"}`))) _ = json.Unmarshal(rec.Body.Bytes(), &mine) if rec.Code != http.StatusCreated || mine.User != "u1" { t.Fatalf("a member's key: %d %s", rec.Code, rec.Body.String()) } if c := do("DELETE", "/api/v1/users/u1/memberships", `{"target":"project:`+crm+`"}`, &u); c != http.StatusOK || len(u.Memberships) != 0 { t.Errorf("leaving: %d %+v", c, u) } do("GET", "/api/v1/keys/"+mine.ID, "", &one) if one.State != "revoked" { t.Errorf("the key of a person who left: %+v", one) } if c := do("DELETE", "/api/v1/users/u1/memberships", `{"target":"project:`+crm+`"}`, nil); c != http.StatusNotFound { t.Errorf("leaving twice: %d", c) } var people []UserView do("GET", "/api/v1/users", "", &people) if len(people) != 1 || people[0].Email != "ayse@example.com" { t.Errorf("people: %+v", people) } // Only what is empty is removed. if c := do("DELETE", "/api/v1/orgs/Acme", "", nil); c != http.StatusOK { t.Errorf("removing the empty Acme by name: %d", c) } if c := do("DELETE", "/api/v1/orgs/"+other, "", nil); c != http.StatusConflict { t.Errorf("removing an organization with a team: %d", c) } } func TestTenancyNeedsTheStore(t *testing.T) { a, _ := setup(t, testModels()) if c := call(t, a, "GET", "/api/v1/orgs", "", nil); c != http.StatusNotImplemented { t.Errorf("without a store: %d", c) } }