Files
llmgw/internal/store/copy_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

295 lines
8.7 KiB
Go

package store
import (
"context"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"time"
)
func TestEveryTableIsInTheCopyOrder(t *testing.T) {
// A table added by a migration and not added here is copied by nothing,
// and the destination comes up quietly missing it — discovered when
// somebody looks for a setting that was there before the move. Read out of
// the migrations rather than listed twice, so the two cannot disagree.
var declared []string
entries, err := migrationFS.ReadDir("migrations/sqlite")
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
body, err := migrationFS.ReadFile("migrations/sqlite/" + e.Name())
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
rest, ok := strings.CutPrefix(line, "CREATE TABLE ")
if !ok {
continue
}
name, _, _ := strings.Cut(rest, " ")
name = strings.TrimSuffix(strings.TrimSpace(name), "(")
declared = append(declared, name)
}
}
if len(declared) == 0 {
t.Fatal("found no tables in the migrations, so this test proves nothing")
}
renamed := renamedTables(t)
for _, name := range declared {
if to, ok := renamed[name]; ok {
// A table rebuilt under a temporary name and renamed back is the
// table it became, not another one.
name = to
}
if slices.Contains(copyOrder, name) {
continue
}
// Left behind on purpose, with the reason written down beside it.
if why, ok := notCopied[name]; ok {
if why == "" {
t.Errorf("%q is excluded from the copy with no reason given", name)
}
continue
}
t.Errorf("%q is created by a migration but nothing copies it", name)
}
}
func TestCopyRefusesADestinationThatAlreadyHasRows(t *testing.T) {
// Merging two stores means deciding what happens when both hold a project
// with the same id and different contents, and every answer is a guess
// about which one somebody meant.
ctx := context.Background()
src, dst := openDB(t), openDB(t)
tree(t, src)
tree(t, dst)
_, err := src.CopyTo(ctx, dst)
if err == nil || !strings.Contains(err.Error(), "empty store") {
t.Errorf("copying into a populated store gave %v", err)
}
}
func TestACopiedStoreStillAuthenticatesTheSameKey(t *testing.T) {
// The reason this exists. Keys are stored hashed, so a migration that
// loses them cannot recreate them: every person and every script would
// need a new one issued.
ctx := context.Background()
src := openDB(t)
proj := tree(t, src)
secret, k := mustKey(t, src, "k1", proj)
if err := src.WriteUsage(ctx, []Usage{{
At: time.Now(), KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
PromptTokens: 3, CompletionTokens: 4, CostMicros: 99, Status: 200,
}}); err != nil {
t.Fatal(err)
}
dst, err := Open(ctx, filepath.Join(t.TempDir(), "copy.db"))
if err != nil {
t.Fatal(err)
}
defer dst.Close()
res, err := src.CopyTo(ctx, dst)
if err != nil {
t.Fatalf("copying: %v", err)
}
if len(res) != len(copyOrder) {
t.Errorf("copied %d tables of %d", len(res), len(copyOrder))
}
// The same secret the caller already holds still resolves, in the copy.
got, err := dst.KeyByHash(ctx, HashKey(secret))
if err != nil {
t.Fatalf("the copied store does not know the key: %v", err)
}
if got.ID != k.ID || got.ProjectID != proj {
t.Errorf("key came back as %+v", got)
}
// And the spend it had, so a budget is not quietly reset by moving.
spend, err := dst.SpendSince(ctx, time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if spend[k.ID] != 99 {
t.Errorf("spend after the copy = %d, want 99", spend[k.ID])
}
}
func TestCopyCarriesConversationsAndNotes(t *testing.T) {
ctx := context.Background()
src := openDB(t)
proj := tree(t, src)
if err := src.SetThreadRetention(ctx, proj, 24); err != nil {
t.Fatal(err)
}
if err := src.AppendThread(ctx, proj, "th1", []Message{
{Role: "user", Content: "remembered"},
}); err != nil {
t.Fatal(err)
}
dst, err := Open(ctx, filepath.Join(t.TempDir(), "copy.db"))
if err != nil {
t.Fatal(err)
}
defer dst.Close()
if _, err := src.CopyTo(ctx, dst); err != nil {
t.Fatal(err)
}
msgs, err := dst.Thread(ctx, proj, "th1")
if err != nil {
t.Fatalf("the conversation did not survive: %v", err)
}
if len(msgs) != 1 || msgs[0].Content != "remembered" {
t.Errorf("thread came back as %+v", msgs)
}
// The retention setting is a column on projects, so it rides along — a
// destination that kept the conversations and forgot the promise about
// how long would be worse than one that kept neither.
if d, err := dst.ThreadRetention(ctx, proj); err != nil || d == 0 {
t.Errorf("retention after the copy = %v (err %v)", d, err)
}
}
func TestCopyingAnEmptyStoreIsFine(t *testing.T) {
ctx := context.Background()
src := openDB(t)
dst, err := Open(ctx, filepath.Join(t.TempDir(), "copy.db"))
if err != nil {
t.Fatal(err)
}
defer dst.Close()
if _, err := src.CopyTo(ctx, dst); err != nil {
t.Errorf("copying an empty store failed: %v", err)
}
_ = os.Remove("")
}
func TestTheCopyOrderSatisfiesTheForeignKeys(t *testing.T) {
// Completeness is not enough: a table copied before the one it references
// fails on a constraint partway through, leaving some tables copied and
// some not. The first version of copyOrder followed the order the
// migrations create tables in, and users is created after api_keys even
// though api_keys points at it — so the copy died three tables in, on a
// real database, after the gateway had already been stopped.
//
// Derived from the migrations rather than restated, so a new reference
// fails here rather than during somebody's migration.
deps := map[string]map[string]bool{}
note := func(table, ref string) {
if table == ref {
return
}
if deps[table] == nil {
deps[table] = map[string]bool{}
}
deps[table][ref] = true
}
entries, err := migrationFS.ReadDir("migrations/sqlite")
if err != nil {
t.Fatal(err)
}
body := ""
for _, e := range entries {
b, err := migrationFS.ReadFile("migrations/sqlite/" + e.Name())
if err != nil {
t.Fatal(err)
}
body += string(b)
}
// Table bodies, so a REFERENCES is attributed to the table holding it,
// under the name it ends with for one rebuilt and renamed.
renamed := renamedTables(t)
for _, m := range regexpCreateTable.FindAllStringSubmatch(body, -1) {
table := m[1]
if to, ok := renamed[table]; ok {
table = to
}
for _, r := range regexpReferences.FindAllStringSubmatch(m[2], -1) {
note(table, r[1])
}
}
// And columns added later, which is exactly how api_keys got its user_id.
for _, m := range regexpAlterRef.FindAllStringSubmatch(body, -1) {
note(m[1], m[2])
}
if len(deps) == 0 {
t.Fatal("found no foreign keys, so this test proves nothing")
}
position := map[string]int{}
for i, t := range copyOrder {
position[t] = i
}
for table, refs := range deps {
for ref := range refs {
if position[ref] > position[table] {
t.Errorf("%s is copied before %s, which it references", table, ref)
}
}
}
}
var (
regexpCreateTable = regexp.MustCompile(`(?s)CREATE TABLE (\w+)\s*\((.*?)\n\);`)
regexpReferences = regexp.MustCompile(`REFERENCES\s+(\w+)`)
regexpAlterRef = regexp.MustCompile(`ALTER TABLE (\w+) ADD COLUMN[^;]*REFERENCES\s+(\w+)`)
regexpRename = regexp.MustCompile(`ALTER TABLE (\w+) RENAME TO (\w+)`)
)
// renamedTables maps a table a migration renames to the name it gets.
func renamedTables(t *testing.T) map[string]string {
t.Helper()
out := map[string]string{}
entries, err := migrationFS.ReadDir("migrations/sqlite")
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
b, err := migrationFS.ReadFile("migrations/sqlite/" + e.Name())
if err != nil {
t.Fatal(err)
}
for _, m := range regexpRename.FindAllStringSubmatch(string(b), -1) {
out[m[1]] = m[2]
}
}
return out
}
func TestAnEmptyBlobDoesNotBecomeNullInTheCopy(t *testing.T) {
// key_claims.sealed is emptied the moment a claim is used, so a claimed
// row holds an empty blob in a NOT NULL column. An empty blob scans as a
// typed nil slice and a real NULL as an untyped one; pgx cannot tell them
// apart, so the distinction is restored before the insert.
if got := keepEmptyBytes([]byte(nil)); got == nil {
t.Error("an empty byte column would be inserted as NULL")
} else if b, ok := got.([]byte); !ok || len(b) != 0 {
t.Errorf("empty bytes came out as %#v", got)
}
// A genuine NULL arrives as an untyped nil and must stay one.
if got := keepEmptyBytes(nil); got != nil {
t.Errorf("a NULL became %#v", got)
}
// Everything else passes through.
if got := keepEmptyBytes(int64(7)); got != int64(7) {
t.Errorf("a number was rewritten to %#v", got)
}
if got := keepEmptyBytes([]byte("x")); string(got.([]byte)) != "x" {
t.Errorf("a non-empty blob was rewritten to %#v", got)
}
}