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