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>
295 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|