Files
llmgw/internal/accounting/people_test.go
T
Ilker ManapandClaude Opus 5.5 098886e0e4 Let a person belong to many places, or to none
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>
2026-09-28 19:03:46 +02:00

127 lines
4.3 KiB
Go

package accounting
import (
"context"
"errors"
"log/slog"
"path/filepath"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// peopleStore has Acme/ml/training with two members, ayse and deniz.
func peopleStore(t *testing.T) (*store.DB, string) {
t.Helper()
ctx := context.Background()
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "p.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
org, _ := s.CreateOrg(ctx, "Acme")
team, _ := s.CreateTeam(ctx, org, "ml")
proj, _ := s.CreateProject(ctx, team, "training")
for _, id := range []string{"ayse", "deniz"} {
if err := s.CreateUser(ctx, store.User{ID: id, Email: id + "@x", Name: id,
PasswordHash: "h", State: store.UserPending, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := s.ApproveUser(ctx, id, proj, "test", time.Now()); err != nil {
t.Fatal(err)
}
}
return s, proj
}
// keyFor makes a key for a person and resolves it the way authentication does.
func keyFor(t *testing.T, s *store.DB, id, user, project string) auth.Principal {
t.Helper()
ctx := context.Background()
_, k, _ := store.NewKey(id, id, project)
if err := s.CreateUserKey(ctx, user, k); err != nil {
t.Fatal(err)
}
e, err := s.Resolve(ctx, id)
if err != nil {
t.Fatalf("resolving %s: %v", id, err)
}
return auth.Principal{KeyID: id, UserID: e.UserID, ProjectID: e.ProjectID, Limits: e}
}
func TestAPersonsLimitIsOnePoolAcrossTheirKeys(t *testing.T) {
s, proj := peopleStore(t)
if err := s.PutLimits(context.Background(),
store.Limits{Scope: store.ScopeUser, ScopeID: "ayse", RPM: intp(2)}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
personal := keyFor(t, s, "k-personal", "ayse", "")
inProject := keyFor(t, s, "k-project", "ayse", proj)
// Two requests, one on each key, use up the person's two a minute.
for _, p := range []auth.Principal{personal, inProject} {
if _, err := a.Check(p, "free", 1); err != nil {
t.Fatalf("%s: %v", p.KeyID, err)
}
}
if _, err := a.Check(personal, "free", 1); !errors.Is(err, ErrRateLimited) {
t.Errorf("a third request across her keys was allowed: %v", err)
}
// Deniz is somebody else.
if _, err := a.Check(keyFor(t, s, "k-deniz", "deniz", proj), "free", 1); err != nil {
t.Errorf("another person was limited by hers: %v", err)
}
}
func TestAPerMemberLimitCountsEachMemberApart(t *testing.T) {
s, proj := peopleStore(t)
if err := s.PutLimits(context.Background(),
store.Limits{Scope: store.ScopeProjectMember, ScopeID: proj, RPM: intp(1)}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
ayse := keyFor(t, s, "k-a", "ayse", proj)
deniz := keyFor(t, s, "k-d", "deniz", proj)
if _, err := a.Check(ayse, "free", 1); err != nil {
t.Fatal(err)
}
if _, err := a.Check(ayse, "free", 1); !errors.Is(err, ErrRateLimited) {
t.Errorf("her second request passed a one-a-minute per-member limit: %v", err)
}
// Her share is hers: Deniz still has his.
if _, err := a.Check(deniz, "free", 1); err != nil {
t.Errorf("one member's use counted against another: %v", err)
}
// And outside the project it does not apply at all.
if _, err := a.Check(keyFor(t, s, "k-a-own", "ayse", ""), "free", 1); err != nil {
t.Errorf("a project's per-member limit held her personal key: %v", err)
}
}
func TestAPersonsBudgetSurvivesARestart(t *testing.T) {
s, _ := peopleStore(t)
ctx := context.Background()
if err := s.PutLimits(ctx, store.Limits{Scope: store.ScopeUser, ScopeID: "ayse",
Budget: i64p(1000), BudgetPeriod: store.PeriodMonth}); err != nil {
t.Fatal(err)
}
// Spent before the restart, personally and on the chat page.
if err := s.WriteUsage(ctx, []store.Usage{
{At: time.Now(), KeyID: "k-old", UserID: "ayse", CostMicros: 600, Model: "paid", ServedModel: "paid"},
{At: time.Now(), KeyID: "session:ayse", UserID: "ayse", CostMicros: 500, Model: "paid", ServedModel: "paid"},
}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
if err := a.RestoreBudgets(ctx, s, store.PeriodMonth); err != nil {
t.Fatal(err)
}
if _, err := a.Check(keyFor(t, s, "k-new", "ayse", ""), "paid", 1); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("a restart forgot what she had spent: %v", err)
}
}