A person was in exactly one project, chosen at approval, and could not use the gateway before that. People belong to several teams and to none, so: - memberships: a person is a member of any number of organizations, teams and projects, or of none; a member of a team or organization may use every project in it. users join/leave, and the people page adds and takes out from either side. Approval no longer needs a project. - personal keys: a key may have an owner and no project. /profile asks what a new key spends against; keys create --for does the same. auth.personal sets default limits for personal use or turns it off. - limits for people: --scope user holds a person across every key, project and the chat page; --per-member on an org, team or project gives each member their own share there. Resolution folds the tree, each per-member limit for the key's owner, and the owner's own, each in its own pool; the quotas page shows and sets both. - the chat page asks what to spend against and checks it per message. - usage records who spent it, so a person's budget survives a restart, personal use and chat included; restore no longer drops rows with no project. Leaving a project revokes the keys made there unless another membership still reaches it. A personal key keeps no threads or notes, which are per project, and is refused rather than dropped into the anonymous pool. Migration 0010 turns each person's project into a membership, adds usage.user_id and backfills it, and makes api_keys.project_id nullable. SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign keys off, which only works outside a transaction, so migrations marked for it run on one connection with foreign_key_check before commit. Tested on a populated SQLite store with claims, and on a copy of the cluster's PostgreSQL store: 5 people became members, 17 keys and 3 claims intact, 145 of 577 usage rows and all 69 chat rows attributed. The PostgreSQL store tests, including new membership cases, pass on 16. users move is gone; join and leave replace it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
238 lines
6.8 KiB
Go
238 lines
6.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// tree builds Acme/platform/{gateway,search} and Scania/ml, and an active
|
|
// user in gateway with one key of their own and one operator key there.
|
|
type fixture struct {
|
|
s *DB
|
|
acme, scania string
|
|
platform, ml string
|
|
gateway, search, research string
|
|
user, userKey, operatorKey string
|
|
}
|
|
|
|
func newFixture(t *testing.T) fixture {
|
|
t.Helper()
|
|
return newFixtureOn(t, tenancyStore(t))
|
|
}
|
|
|
|
// newFixtureOn builds the fixture in a given store, so the same cases run on
|
|
// PostgreSQL as on SQLite.
|
|
func newFixtureOn(t *testing.T, db *DB) fixture {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
f := fixture{s: db}
|
|
must := func(id string, err error) string {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
f.acme = must(f.s.CreateOrg(ctx, "Acme"))
|
|
f.scania = must(f.s.CreateOrg(ctx, "Scania"))
|
|
f.platform = must(f.s.CreateTeam(ctx, f.acme, "platform"))
|
|
f.ml = must(f.s.CreateTeam(ctx, f.scania, "ml"))
|
|
f.gateway = must(f.s.CreateProject(ctx, f.platform, "gateway"))
|
|
f.search = must(f.s.CreateProject(ctx, f.platform, "search"))
|
|
f.research = must(f.s.CreateProject(ctx, f.ml, "research"))
|
|
|
|
f.user = "usr-1"
|
|
if err := f.s.CreateUser(ctx, User{ID: f.user, Email: "a@x", Name: "a", PasswordHash: "h",
|
|
State: UserPending, CreatedAt: time.Now()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.s.ApproveUser(ctx, f.user, f.gateway, "test", time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, k, _ := NewKey("key-u", "mine", f.gateway)
|
|
if err := f.s.CreateUserKey(ctx, f.user, k); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.userKey = k.ID
|
|
_, k, _ = NewKey("key-o", "ci", f.gateway)
|
|
if err := f.s.CreateKey(ctx, k); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.operatorKey = k.ID
|
|
return f
|
|
}
|
|
|
|
func TestTreeCountsWhatUsesEachProject(t *testing.T) {
|
|
f := newFixture(t)
|
|
tree, err := f.s.Tree(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tree) != 2 || tree[0].Name != "Acme" || len(tree[0].Teams) != 1 {
|
|
t.Fatalf("tree = %+v", tree)
|
|
}
|
|
gw := tree[0].Teams[0].Projects[0]
|
|
if gw.Name != "gateway" || gw.Users != 1 || gw.Keys != 2 || gw.ActiveKeys != 2 {
|
|
t.Errorf("gateway = %+v", gw)
|
|
}
|
|
}
|
|
|
|
func TestASignupNeverApprovedCannotBeReinstated(t *testing.T) {
|
|
f := newFixture(t)
|
|
ctx := t.Context()
|
|
_ = f.s.CreateUser(ctx, User{ID: "usr-2", Email: "b@x", Name: "b", PasswordHash: "h",
|
|
State: UserPending, CreatedAt: time.Now()})
|
|
// Suspended while pending, then reinstated: that would be an approval
|
|
// nobody made.
|
|
if err := f.s.SetUserState(ctx, "usr-2", UserSuspended); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.s.SetUserState(ctx, "usr-2", UserActive); err == nil {
|
|
t.Error("an account nobody approved was made active")
|
|
}
|
|
// One that was approved can be.
|
|
if err := f.s.SetUserState(ctx, f.user, UserSuspended); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.s.SetUserState(ctx, f.user, UserActive); err != nil {
|
|
t.Errorf("an approved account could not be reinstated: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenamingKeepsNamesUniqueAmongSiblingsOnly(t *testing.T) {
|
|
f := newFixture(t)
|
|
ctx := t.Context()
|
|
if err := f.s.RenameProject(ctx, f.search, "gateway"); !errors.Is(err, ErrDuplicateName) {
|
|
t.Errorf("a sibling's name was accepted: %v", err)
|
|
}
|
|
// research is in another team, so "gateway" is free there.
|
|
if err := f.s.RenameProject(ctx, f.research, "gateway"); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if err := f.s.RenameOrg(ctx, f.acme, "Acme AB"); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if err := f.s.RenameTeam(ctx, f.platform, " "); err == nil {
|
|
t.Error("an empty name was accepted")
|
|
}
|
|
}
|
|
|
|
func TestMovingATeamOrProjectChecksTheNewParent(t *testing.T) {
|
|
f := newFixture(t)
|
|
ctx := t.Context()
|
|
if err := f.s.MoveProject(ctx, f.search, f.ml); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projects, _ := f.s.AllProjects(ctx)
|
|
var found bool
|
|
for _, p := range projects {
|
|
if p.ID == f.search && p.Path == "Scania / ml / search" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("search did not move: %+v", projects)
|
|
}
|
|
if err := f.s.MoveTeam(ctx, f.platform, "org-nope"); err == nil {
|
|
t.Error("moved into an organization that does not exist")
|
|
}
|
|
// A team named ml already exists in Scania.
|
|
if _, err := f.s.CreateTeam(ctx, f.acme, "ml"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
teams, _ := f.s.AllTeams(ctx)
|
|
for _, tm := range teams {
|
|
if tm.OrgID == f.acme && tm.Name == "ml" {
|
|
if err := f.s.MoveTeam(ctx, tm.ID, f.scania); !errors.Is(err, ErrDuplicateName) {
|
|
t.Errorf("two teams named ml in one organization: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOnlyEmptyThingsAreDeleted(t *testing.T) {
|
|
f := newFixture(t)
|
|
ctx := t.Context()
|
|
err := f.s.DeleteProject(ctx, f.gateway)
|
|
if !errors.Is(err, ErrNotEmpty) || !strings.Contains(err.Error(), "1 member and 2 keys") {
|
|
t.Errorf("deleting a used project: %v", err)
|
|
}
|
|
if err := f.s.DeleteTeam(ctx, f.platform); !errors.Is(err, ErrNotEmpty) {
|
|
t.Errorf("deleting a team with projects: %v", err)
|
|
}
|
|
if err := f.s.DeleteProject(ctx, f.search); err != nil {
|
|
t.Errorf("deleting an unused project: %v", err)
|
|
}
|
|
if err := f.s.DeleteProject(ctx, f.research); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.s.DeleteTeam(ctx, f.ml); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.s.DeleteOrg(ctx, f.scania); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.s.DeleteOrg(ctx, f.scania); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("deleting twice: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeletingAUserRevokesTheirKeysAndKeepsThem(t *testing.T) {
|
|
f := newFixture(t)
|
|
ctx := t.Context()
|
|
if err := f.s.DeleteUser(ctx, f.user, time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.s.UserByID(ctx, f.user); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("user still there: %v", err)
|
|
}
|
|
keys, _ := f.s.Keys(ctx, f.gateway)
|
|
for _, k := range keys {
|
|
if k.ID == f.userKey && k.RevokedAt.IsZero() {
|
|
t.Error("the deleted person's key still works")
|
|
}
|
|
if k.ID == f.operatorKey && !k.RevokedAt.IsZero() {
|
|
t.Error("an operator key was revoked with the person")
|
|
}
|
|
}
|
|
if len(keys) != 2 {
|
|
t.Errorf("keys are kept for usage history; have %d", len(keys))
|
|
}
|
|
}
|
|
|
|
func TestAllKeysNamesProjectAndOwner(t *testing.T) {
|
|
f := newFixture(t)
|
|
rows, err := f.s.AllKeys(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 2 {
|
|
t.Fatalf("rows = %+v", rows)
|
|
}
|
|
for _, r := range rows {
|
|
if r.ProjectPath != "Acme / platform / gateway" {
|
|
t.Errorf("path %q", r.ProjectPath)
|
|
}
|
|
if (r.ID == f.userKey) != (r.OwnerEmail == "a@x") {
|
|
t.Errorf("owner of %s is %q", r.ID, r.OwnerEmail)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRedeclaringATeamDoesNotMoveIt(t *testing.T) {
|
|
f := newFixture(t)
|
|
err := f.s.PutTeam(context.Background(), Team{ID: f.platform, OrgID: f.scania, Name: "platform"})
|
|
if err == nil {
|
|
t.Fatal("PutTeam moved a team to another organization")
|
|
}
|
|
teams, _ := f.s.AllTeams(context.Background())
|
|
for _, tm := range teams {
|
|
if tm.ID == f.platform && tm.OrgID != f.acme {
|
|
t.Error("the team changed owner")
|
|
}
|
|
}
|
|
}
|