Files
llmgw/internal/store/postgres_test.go
T
Ilker ManapandClaude Opus 5 ade689fc8e Refuse to run the destructive store tests against a real database
These TRUNCATE every table before each test. Pointed at a deployment that is a
silent and complete data loss — every key, every user, every conversation.

Not hypothetical. I did it: an SSH tunnel to the production database had been
opened minutes earlier for a perfectly good reason, and the same DSN was then
handed to the test suite. Twenty-one users, nine API keys, eighteen sessions,
four hundred and sixty-one usage records and three runtime-added models, gone
between one command and the next. Recovered only because the pre-migration
SQLite file was still there.

The guard is the database name. A test database has to say so, because a check
that could be satisfied by accident would not have stopped this one — the DSN
was entirely correct for what it had been opened for, and catastrophic for
what it was then used for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 18:40:12 +02:00

208 lines
6.3 KiB
Go

package store
import (
"context"
"errors"
"net/url"
"os"
"strings"
"testing"
"time"
)
// openPG opens the test database named by LLMGW_TEST_POSTGRES, or skips.
//
// Env-gated rather than spun up here, because a store test that needs a
// container is a test nobody runs. Pointed at a database, it runs the same
// assertions the SQLite tests make — which is the only thing that can catch
// the two backends drifting apart.
//
// Every table is emptied first. The alternative is a test database that
// accumulates rows until an assertion about counts starts failing for reasons
// that have nothing to do with the change being tested.
func openPG(t *testing.T) *SQLite {
t.Helper()
dsn := os.Getenv("LLMGW_TEST_POSTGRES")
if dsn == "" {
t.Skip("set LLMGW_TEST_POSTGRES to a database DSN to run these")
}
requireTestDatabase(t, dsn)
s, err := OpenPostgres(context.Background(), dsn)
if err != nil {
t.Fatalf("opening the test database: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
// CASCADE because the tables reference each other, and TRUNCATE rather
// than DELETE so identity columns restart too.
_, err = s.db.ExecContext(context.Background(),
// leadership included, and it is not in copyOrder: a subtest that
// inherits the previous one's lease is refused for reasons that have
// nothing to do with what it is testing. It passed on SQLite, where
// every subtest gets a fresh file, and failed only here.
`TRUNCATE thread_messages, threads, memory_notes, usage, api_keys,
key_claims, limits, models, sessions, users, projects, teams,
organizations, leadership RESTART IDENTITY CASCADE`)
if err != nil {
t.Fatalf("clearing the test database: %v", err)
}
return s
}
// requireTestDatabase refuses to run against a database that is not obviously
// a test one.
//
// These tests truncate every table before each one. Pointed at a real
// deployment that is a silent, complete data loss — every key, every user,
// every conversation — and it is not a hypothetical: it happened here, through
// an SSH tunnel to the production database that had been opened minutes
// earlier for a perfectly good reason. The DSN was correct for what it was
// opened for and catastrophic for what it was then used for.
//
// The name has to say so. A guard that could be satisfied by accident would
// not have stopped it.
func requireTestDatabase(t *testing.T, dsn string) {
t.Helper()
u, err := url.Parse(dsn)
if err != nil {
t.Fatalf("LLMGW_TEST_POSTGRES is not a URL: %v", err)
}
name := strings.TrimPrefix(u.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run destructive tests against database %q:\n"+
" these TRUNCATE every table before each test.\n"+
" Point LLMGW_TEST_POSTGRES at a database whose name contains \"test\".", name)
}
}
func TestPostgresCarriesTheWholeTenancyTree(t *testing.T) {
ctx := context.Background()
s := openPG(t)
proj := tree(t, s)
secret, k := mustKey(t, s, "k1", proj)
_ = secret
got, err := s.KeyByHash(ctx, k.Hash)
if err != nil {
t.Fatalf("reading the key back: %v", err)
}
if got.ID != k.ID || got.ProjectID != proj {
t.Errorf("key came back as %+v", got)
}
}
func TestPostgresKeepsUsageAndSpend(t *testing.T) {
// The accounting path, which is the one that decides whether a budget
// means anything after a restart.
ctx := context.Background()
s := openPG(t)
proj := tree(t, s)
_, k := mustKey(t, s, "k1", proj)
now := time.Now()
err := s.WriteUsage(ctx, []Usage{
{At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
PromptTokens: 10, CompletionTokens: 20, CostMicros: 500, Status: 200, LatencyMs: 42},
{At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
PromptTokens: 1, CompletionTokens: 2, CostMicros: 250, Status: 200, LatencyMs: 7},
})
if err != nil {
t.Fatalf("writing usage: %v", err)
}
spend, err := s.SpendSince(ctx, now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if spend[k.ID] != 750 {
t.Errorf("spend = %d, want 750", spend[k.ID])
}
}
func TestPostgresKeepsThreadsInOrderAndScoped(t *testing.T) {
// The newest tables, and the ones with the most SQL per operation —
// a transaction, a MAX(seq), an insert per turn.
ctx := context.Background()
s := openPG(t)
proj := tree(t, s)
if err := s.SetThreadRetention(ctx, proj, 24); err != nil {
t.Fatal(err)
}
for _, m := range []Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "answer", Model: "small"},
{Role: "user", Content: "second"},
} {
if err := s.AppendThread(ctx, proj, "th1", []Message{m}); err != nil {
t.Fatal(err)
}
}
got, err := s.Thread(ctx, proj, "th1")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || got[0].Content != "first" || got[2].Content != "second" {
t.Fatalf("thread came back as %+v", got)
}
if got[1].Model != "small" {
t.Errorf("the model that answered was lost: %+v", got[1])
}
// And the scope holds here exactly as it does on SQLite.
if err := s.PutProject(ctx, Project{ID: "proj2", TeamID: "team1", Name: "Other"}); err != nil {
t.Fatal(err)
}
if _, err := s.Thread(ctx, "proj2", "th1"); !errors.Is(err, ErrNoThread) {
t.Errorf("reading across projects returned %v", err)
}
}
func TestPostgresEnforcesLimitsTheSameWay(t *testing.T) {
ctx := context.Background()
s := openPG(t)
proj := tree(t, s)
_, k := mustKey(t, s, "k1", proj)
rpm := 7
budget := int64(9000)
if err := s.PutLimits(ctx, Limits{
Scope: ScopeProject, ScopeID: proj, RPM: &rpm,
Budget: &budget, BudgetPeriod: PeriodMonth,
}); err != nil {
t.Fatal(err)
}
eff, err := s.Resolve(ctx, k.ID)
if err != nil {
t.Fatal(err)
}
if eff.RPM == nil || *eff.RPM != 7 {
t.Errorf("rpm did not resolve down the tree: %+v", eff.RPM)
}
if eff.Budget == nil || *eff.Budget != 9000 {
t.Errorf("budget did not resolve down the tree: %+v", eff.Budget)
}
}
func TestPostgresUpsertsRatherThanDuplicating(t *testing.T) {
// ON CONFLICT is the one non-trivial construct shared by both dialects.
// If it behaved differently here, applying a configuration twice would
// start failing instead of being a no-op.
ctx := context.Background()
s := openPG(t)
tree(t, s)
tree(t, s)
orgs, err := s.Orgs(ctx)
if err != nil {
t.Fatal(err)
}
if len(orgs) != 1 {
t.Errorf("applying the tree twice produced %d orgs", len(orgs))
}
}