Nothing in the tree could be renamed, moved or removed, and an approved person stayed in the project they were first put in — from the web or the CLI. Now: - /admin/users is the tree, each project opening onto its people and keys, with approve, move, suspend, reinstate and remove for people, revoke for keys, and add, rename, move and remove at every level, plus a search over every account. Forms post and get the page back, so it works with no script; htmx 2.0.11 (0BSD, embedded, checked against npm's integrity hash) swaps only the changed part, keeping open projects open. - llmgw org tree/add/rename/move/remove, users move/delete, and keys list --all. - Projects, teams and organizations are named by id, path (Acme/platform/gateway) or unique name; users by id or email. With a single project --project can be left out, instead of defaulting to "default", which a tree built from the web does not have. Moving a person moves the keys they made; operator keys stay with the project. Only empty things are removed, and never a project that had a key, since usage history refers to it. Also: keys init no longer reparents an existing team or project; reinstating an account with no project is refused (a rejected signup used to come back active and fail at its first key); rejecting a signup removes it; keys limits --budget takes whole units like the quotas page. Checked in headless Chrome: a move from an open project updates the page in place, keeps the project open, and the search still filters. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
125 lines
4.1 KiB
Go
125 lines
4.1 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 account, 1 key", "ayse@example.com"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("tree lacks %q:\n%s", want, out)
|
|
}
|
|
}
|
|
|
|
out = mustRun(t, db, "users", "move", "ayse@example.com", "--project", "research")
|
|
if !strings.Contains(out, "Scania / platform / research") {
|
|
t.Errorf("move said: %s", out)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|