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>
175 lines
5.1 KiB
Go
175 lines
5.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func newTestInit(t *testing.T) (*serverInit, *bytes.Buffer) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
var out bytes.Buffer
|
|
return &serverInit{
|
|
etcDir: filepath.Join(root, "etc"),
|
|
stateDir: filepath.Join(root, "state"),
|
|
pkiDir: filepath.Join(root, "etc", "pki"),
|
|
admin: "admin@example.com",
|
|
out: &out,
|
|
}, &out
|
|
}
|
|
|
|
func TestInitLeavesAControlPlaneThatStarts(t *testing.T) {
|
|
in, out := newTestInit(t)
|
|
if err := in.run(context.Background()); err != nil {
|
|
t.Fatalf("%v\n%s", err, out)
|
|
}
|
|
|
|
cfg, err := config.Load(in.configPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The configuration install.md used to show had an empty catalog and no
|
|
// control.enabled, which the server refuses as having nothing to serve.
|
|
if !cfg.ControlPlaneMode() {
|
|
t.Error("the written configuration does not run a control plane")
|
|
}
|
|
if !cfg.AgentTLS.Enabled() || !cfg.Auth.Enabled {
|
|
t.Error("mutual TLS and authentication should both be on")
|
|
}
|
|
for _, f := range []string{"ca.pem", "server.pem", "server-key.pem"} {
|
|
if _, err := os.Stat(filepath.Join(in.etcDir, f)); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
s, err := store.Open(context.Background(), cfg.Auth.Store)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
u, err := s.UserByEmail(context.Background(), "admin@example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inDefault, _ := s.CanUseProject(context.Background(), u.ID, "default")
|
|
if !u.IsAdmin || u.State != store.UserActive || !inDefault {
|
|
t.Errorf("administrator not ready to use: %+v", u)
|
|
}
|
|
if !strings.Contains(out.String(), "password: ") {
|
|
t.Error("the generated password was not shown")
|
|
}
|
|
}
|
|
|
|
func TestInitAgainChangesNothing(t *testing.T) {
|
|
in, out := newTestInit(t)
|
|
if err := in.run(context.Background()); err != nil {
|
|
t.Fatalf("%v\n%s", err, out)
|
|
}
|
|
cert, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem"))
|
|
|
|
again := *in
|
|
again.changed = false
|
|
out.Reset()
|
|
if err := again.run(context.Background()); err != nil {
|
|
t.Fatalf("%v\n%s", err, out)
|
|
}
|
|
if again.changed {
|
|
t.Errorf("a second run changed something:\n%s", out)
|
|
}
|
|
if strings.Contains(out.String(), "password: ") {
|
|
t.Error("a second run must not reset the administrator's password")
|
|
}
|
|
if now, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem")); !bytes.Equal(now, cert) {
|
|
t.Error("the certificate was reissued without reason")
|
|
}
|
|
}
|
|
|
|
func TestInitWithANewHostKeepsTheOldOnes(t *testing.T) {
|
|
in, out := newTestInit(t)
|
|
in.hosts = []string{"old.internal"}
|
|
if err := in.run(context.Background()); err != nil {
|
|
t.Fatalf("%v\n%s", err, out)
|
|
}
|
|
in.hosts = []string{"new.internal"}
|
|
if err := in.run(context.Background()); err != nil {
|
|
t.Fatalf("%v\n%s", err, out)
|
|
}
|
|
cert, err := readCert(filepath.Join(in.etcDir, "server.pem"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, h := range []string{"old.internal", "new.internal"} {
|
|
if !slices.Contains(cert.DNSNames, h) {
|
|
t.Errorf("certificate lost %s: %v", h, cert.DNSNames)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInitNeverOverwritesTheConfiguration(t *testing.T) {
|
|
in, out := newTestInit(t)
|
|
if err := os.MkdirAll(in.etcDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mine := "# mine\ncontrol:\n enabled: true\nauth:\n enabled: true\n store: " +
|
|
filepath.Join(in.stateDir, "mine.db") + "\n"
|
|
if err := os.WriteFile(in.configPath(), []byte(mine), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := in.run(context.Background()); err != nil {
|
|
t.Fatalf("%v\n%s", err, out)
|
|
}
|
|
if got, _ := os.ReadFile(in.configPath()); string(got) != mine {
|
|
t.Error("an existing server.yaml was rewritten")
|
|
}
|
|
// And the store it names is the one used.
|
|
if _, err := os.Stat(filepath.Join(in.stateDir, "mine.db")); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func TestTheInitUnitIsTheShippedUnit(t *testing.T) {
|
|
b, err := os.ReadFile("../../deploy/systemd/llmgw-server.service")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(b) != serverUnit {
|
|
t.Error("deploy/systemd/llmgw-server.service and the unit 'server init' writes have drifted")
|
|
}
|
|
}
|
|
|
|
func TestInitKeepsCertificatesFromAnAuthorityElsewhere(t *testing.T) {
|
|
in, out := newTestInit(t)
|
|
// Issued on another machine by an authority that never comes here.
|
|
elsewhere := &serverInit{etcDir: in.etcDir, pkiDir: filepath.Join(t.TempDir(), "pki"), out: &bytes.Buffer{}}
|
|
if err := os.MkdirAll(in.etcDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := elsewhere.ensureCerts(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem"))
|
|
|
|
if err := in.run(context.Background()); err != nil {
|
|
t.Fatalf("%v\n%s", err, out)
|
|
}
|
|
if after, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem")); !bytes.Equal(before, after) {
|
|
t.Error("a certificate from another authority was replaced")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(in.pkiDir, "ca-key.pem")); err == nil {
|
|
t.Error("an authority was created beside one that already exists elsewhere")
|
|
}
|
|
|
|
in.hosts = []string{"new.internal"}
|
|
if err := in.run(context.Background()); err == nil {
|
|
t.Error("--host cannot be honoured without the authority, and should say so")
|
|
}
|
|
}
|