Files
llmgw/internal/cli/org_test.go
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

134 lines
4.6 KiB
Go

package cli
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// run executes the CLI against one store file and returns stdout.
func run(t *testing.T, db string, args ...string) (string, error) {
t.Helper()
cmd := Root()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
// --config pointed nowhere, so a server.yaml on the test machine is never read.
cmd.SetArgs(append(args, "--store", db, "--config", ""))
err := cmd.ExecuteContext(context.Background())
return out.String(), err
}
func mustRun(t *testing.T, db string, args ...string) string {
t.Helper()
out, err := run(t, db, args...)
if err != nil {
t.Fatalf("%v: %v", args, err)
}
return out
}
func TestTheTreeIsManagedByNameNotId(t *testing.T) {
db := filepath.Join(t.TempDir(), "s.db")
mustRun(t, db, "org", "add", "org", "Acme")
mustRun(t, db, "org", "add", "org", "Scania")
mustRun(t, db, "org", "add", "team", "platform", "--in", "Acme")
mustRun(t, db, "org", "add", "team", "platform", "--in", "Scania")
// "platform" is in both, so a project needs the path.
_, err := run(t, db, "org", "add", "project", "gateway", "--in", "platform")
if err == nil || !strings.Contains(err.Error(), "more than one team") {
t.Fatalf("an ambiguous name was accepted: %v", err)
}
mustRun(t, db, "org", "add", "project", "gateway", "--in", "acme/Platform")
mustRun(t, db, "org", "add", "project", "research", "--in", "Scania / platform")
mustRun(t, db, "users", "create", "ayse@example.com", "--password", "correct horse battery", "--project", "Acme/platform/gateway")
mustRun(t, db, "keys", "create", "laptop", "--project", "gateway")
out := mustRun(t, db, "org", "tree", "--people")
for _, want := range []string{"Acme", "gateway", "1 member, 1 key", "ayse@example.com"} {
if !strings.Contains(out, want) {
t.Errorf("tree lacks %q:\n%s", want, out)
}
}
out = mustRun(t, db, "users", "join", "ayse@example.com", "--project", "research")
if !strings.Contains(out, "Scania / platform / research") {
t.Errorf("join said: %s", out)
}
mustRun(t, db, "users", "leave", "ayse@example.com", "--project", "Acme/platform/gateway")
// She now uses the gateway through research alone; a personal key is hers.
mustRun(t, db, "keys", "create", "mine", "--for", "ayse@example.com")
if out := mustRun(t, db, "keys", "list", "--all"); !strings.Contains(out, "personal") {
t.Errorf("a personal key is not listed as one:\n%s", out)
}
if _, err := run(t, db, "keys", "create", "x", "--for", "ayse@example.com", "--project", "gateway"); err == nil {
t.Error("a key was made for her in a project she left")
}
mustRun(t, db, "org", "rename", "project", "research", "lab")
mustRun(t, db, "org", "move", "project", "Acme/platform/gateway", "--to", "Scania/platform")
// gateway still has the operator's key, so it stays.
if _, err := run(t, db, "org", "remove", "project", "gateway"); err == nil {
t.Error("a project with a key was removed")
}
mustRun(t, db, "org", "remove", "team", "Acme/platform")
mustRun(t, db, "org", "remove", "org", "Acme")
s, err := store.Open(context.Background(), db)
if err != nil {
t.Fatal(err)
}
defer s.Close()
paths, _ := s.AllProjects(context.Background())
var got []string
for _, p := range paths {
got = append(got, p.Path)
}
if strings.Join(got, "|") != "Scania / platform / gateway|Scania / platform / lab" {
t.Errorf("projects = %v", got)
}
}
func TestOneProjectNeedsNoFlag(t *testing.T) {
db := filepath.Join(t.TempDir(), "s.db")
mustRun(t, db, "org", "add", "org", "Acme")
mustRun(t, db, "org", "add", "team", "platform")
mustRun(t, db, "org", "add", "project", "gateway")
mustRun(t, db, "keys", "create", "laptop")
if out := mustRun(t, db, "keys", "list"); !strings.Contains(out, "laptop") {
t.Errorf("list: %s", out)
}
mustRun(t, db, "org", "add", "project", "search", "--in", "platform")
if _, err := run(t, db, "keys", "create", "other"); err == nil ||
!strings.Contains(err.Error(), "which project?") {
t.Errorf("two projects and no flag: %v", err)
}
}
func TestKeyBudgetsAreWholeUnits(t *testing.T) {
db := filepath.Join(t.TempDir(), "s.db")
mustRun(t, db, "org", "add", "org", "Acme")
mustRun(t, db, "org", "add", "team", "platform")
mustRun(t, db, "org", "add", "project", "gateway")
mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--budget", "12.5")
s, err := store.Open(context.Background(), db)
if err != nil {
t.Fatal(err)
}
defer s.Close()
all, _ := s.AllLimits(context.Background())
for _, l := range all {
if l.Budget == nil || *l.Budget != 12_500_000 {
t.Errorf("budget stored as %v micro-units", l.Budget)
}
}
}