terminationFor sent every gateway error that was not Refused or Timeout to ToolFailure, because the enum had nowhere else to put it. On 2026-09-22 that was 131 of 318 production runs, and not one of them was a tool failing: 56 were retired model ids, 25 an exhausted Anthropic balance, 45 Groq's free-tier rate limit -- the only one still happening. An operator reading the termination column saw "a tool is broken" for two weeks while the actual answer was "we are not paying for capacity". GatewayFailure is the seventh termination. Rate limited, request rejected, credential refused and unreachable land there; Refused and Deadline keep their own reasons; a non-gateway error is still the tool layer's. A delegation whose subagent died at the gateway now carries that reason up to the parent instead of reading as a tool call that failed. Migration 000016 widens the CHECK that 000006 chose precisely so this would be a migration rather than an ALTER TYPE. Its down folds any GatewayFailure rows back to ToolFailure BEFORE narrowing the constraint, which is the order that works; verified up, down and up again on a scratch database. Existing rows are left as they are -- the trajectory entries still carry the gateway.* code for anyone reclassifying history. The surface wording is the one termination where "try again" is honest advice, since the dominant cause clears within a minute. Full suite run against a real database, including the tests that skip without one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
812 lines
28 KiB
Go
812 lines
28 KiB
Go
package domain_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/testutil"
|
|
)
|
|
|
|
// Phase 4C — the shape of the authored-definition tables.
|
|
//
|
|
// These test the MIGRATION, not any Go code: there is no agent or skill package
|
|
// yet, and there deliberately is not one until Phase 4D. What is under test is
|
|
// whether the database refuses the things it is supposed to refuse.
|
|
//
|
|
// An external test package (`domain_test`) rather than `package domain`,
|
|
// because testutil imports seeder which imports domain — reachable from an
|
|
// external test binary, an import cycle from an internal one.
|
|
|
|
const (
|
|
agents = "agent_definitions"
|
|
skills = "skill_definitions"
|
|
)
|
|
|
|
// fixture is a migrated sandbox with one organization and two users.
|
|
type fixture struct {
|
|
pool *pgxpool.Pool
|
|
ctx context.Context
|
|
orgID string
|
|
alice string
|
|
bob string
|
|
}
|
|
|
|
func newFixture(t *testing.T, label string) *fixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
pool := testutil.Sandbox(t, label)
|
|
testutil.ApplyAllMigrations(ctx, t, pool)
|
|
|
|
f := &fixture{pool: pool, ctx: ctx}
|
|
if err := pool.QueryRow(ctx,
|
|
`INSERT INTO organizations (name, slug) VALUES ('Defs Org','defs-org') RETURNING id::text`).
|
|
Scan(&f.orgID); err != nil {
|
|
t.Fatalf("create organization: %v", err)
|
|
}
|
|
f.alice = f.newUser(t, "alice@example.test")
|
|
f.bob = f.newUser(t, "bob@example.test")
|
|
return f
|
|
}
|
|
|
|
func (f *fixture) newUser(t *testing.T, email string) string {
|
|
t.Helper()
|
|
var id string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, $3) RETURNING id::text`,
|
|
f.orgID, email, email).Scan(&id); err != nil {
|
|
t.Fatalf("create user %s: %v", email, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// row is one candidate definition. Any field may be made deliberately wrong.
|
|
type row struct {
|
|
table string
|
|
defID string
|
|
orgID string
|
|
visibility string
|
|
owner *string
|
|
createdBy *string
|
|
markdown string
|
|
status string
|
|
version *int
|
|
}
|
|
|
|
func (f *fixture) insert(r row) (string, error) {
|
|
cols := []string{"definition_id", "org_id", "visibility", "owner_user_id", "created_by", "markdown"}
|
|
vals := []string{"$1::text", "$2::uuid", "$3::text", "$4::uuid", "$5::uuid", "$6::text"}
|
|
args := []any{r.defID, r.orgID, r.visibility, r.owner, r.createdBy, r.markdown}
|
|
|
|
if r.status != "" {
|
|
cols, vals = append(cols, "status"), append(vals, "$7::text")
|
|
args = append(args, r.status)
|
|
}
|
|
if r.version != nil {
|
|
cols = append(cols, "version")
|
|
vals = append(vals, "$"+itoa(len(args)+1)+"::integer")
|
|
args = append(args, *r.version)
|
|
}
|
|
|
|
var id string
|
|
err := f.pool.QueryRow(f.ctx,
|
|
"INSERT INTO "+r.table+" ("+strings.Join(cols, ", ")+") VALUES ("+
|
|
strings.Join(vals, ", ")+") RETURNING id::text", args...).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n < 10 {
|
|
return string(rune('0' + n))
|
|
}
|
|
return string(rune('0'+n/10)) + string(rune('0'+n%10))
|
|
}
|
|
|
|
// personal and organization build a valid row of each tier, so a test can
|
|
// change exactly one thing and see whether the database notices.
|
|
func (f *fixture) personal(table, defID, owner string) row {
|
|
return row{table: table, defID: defID, orgID: f.orgID, visibility: "personal",
|
|
owner: &owner, createdBy: &owner, markdown: "---\nid: " + defID + "\n---\n"}
|
|
}
|
|
|
|
func (f *fixture) organization(table, defID, author string) row {
|
|
return row{table: table, defID: defID, orgID: f.orgID, visibility: "organization",
|
|
owner: nil, createdBy: &author, markdown: "---\nid: " + defID + "\n---\n"}
|
|
}
|
|
|
|
func mustInsert(t *testing.T, f *fixture, r row) string {
|
|
t.Helper()
|
|
id, err := f.insert(r)
|
|
if err != nil {
|
|
t.Fatalf("a valid %s row was refused: %v", r.table, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func refused(t *testing.T, f *fixture, r row, wantConstraint, why string) {
|
|
t.Helper()
|
|
_, err := f.insert(r)
|
|
if err == nil {
|
|
t.Fatalf("%s: the row was ACCEPTED — %s", r.table, why)
|
|
}
|
|
if wantConstraint != "" && !strings.Contains(err.Error(), wantConstraint) {
|
|
t.Errorf("%s: refused by %v, want the %s constraint", r.table, err, wantConstraint)
|
|
}
|
|
}
|
|
|
|
/* ── 1, 2. The ownership invariant ──────────────────────────────────────── */
|
|
|
|
func TestVisibilityRequiresMatchingOwnership(t *testing.T) {
|
|
f := newFixture(t, "defs_ownership")
|
|
|
|
for _, table := range []string{agents, skills} {
|
|
t.Run(table, func(t *testing.T) {
|
|
// The two valid shapes.
|
|
mustInsert(t, f, f.personal(table, "valid-personal", f.alice))
|
|
mustInsert(t, f, f.organization(table, "valid-org", f.alice))
|
|
|
|
// 1. A personal definition with no owner belongs to nobody.
|
|
bad := f.personal(table, "no-owner", f.alice)
|
|
bad.owner = nil
|
|
refused(t, f, bad, "visibility_owner",
|
|
"a personal definition must have an owner")
|
|
|
|
// 2. An organization definition with an owner is two answers to
|
|
// "whose is this", which is one too many.
|
|
bad = f.organization(table, "with-owner", f.alice)
|
|
bad.owner = &f.alice
|
|
refused(t, f, bad, "visibility_owner",
|
|
"an organization definition must not have an owner")
|
|
|
|
// And an unrecognised tier is not a tier.
|
|
bad = f.personal(table, "bad-tier", f.alice)
|
|
bad.visibility = "public"
|
|
refused(t, f, bad, "visibility_check", "`public` is not a visibility")
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ── 3. definition_id format ────────────────────────────────────────────── */
|
|
|
|
// The same rule the frontend validator enforces, restated in the database so a
|
|
// caller that bypasses the application cannot store an id the registry could
|
|
// never address.
|
|
func TestDefinitionIDFormat(t *testing.T) {
|
|
f := newFixture(t, "defs_idformat")
|
|
|
|
valid := []string{"a", "board", "krow-workforce-agent", "x1", "a-1-b", "0abc"}
|
|
invalid := map[string]string{
|
|
"leading dash": "-board",
|
|
"upper case": "Board",
|
|
"underscore": "my_skill",
|
|
"space": "my skill",
|
|
"trailing dot": "board.",
|
|
"empty": "",
|
|
"slash": "custom/board",
|
|
"unicode": "bòard",
|
|
"sql-ish": "a'; DROP TABLE users; --",
|
|
"newline": "board\nx",
|
|
}
|
|
|
|
for _, table := range []string{agents, skills} {
|
|
t.Run(table, func(t *testing.T) {
|
|
for _, id := range valid {
|
|
if _, err := f.insert(f.personal(table, id, f.alice)); err != nil {
|
|
t.Errorf("valid id %q was refused: %v", id, err)
|
|
}
|
|
}
|
|
for name, id := range invalid {
|
|
bad := f.personal(table, id, f.bob)
|
|
refused(t, f, bad, "definition_id_format", "id "+name+" ("+id+") is not a valid id")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ── 4, 5, 6, 7. Status vocabularies and version ────────────────────────── */
|
|
|
|
func TestAgentStatusAndVersion(t *testing.T) {
|
|
f := newFixture(t, "defs_agentstatus")
|
|
|
|
// 4. The three agent statuses, and nothing else.
|
|
for _, status := range []string{"draft", "published", "archived"} {
|
|
r := f.personal(agents, "s-"+status, f.alice)
|
|
r.status = status
|
|
mustInsert(t, f, r)
|
|
}
|
|
for _, status := range []string{"active", "inactive", "live", "DRAFT", ""} {
|
|
r := f.personal(agents, "bad-status", f.bob)
|
|
r.status = status
|
|
if status == "" {
|
|
continue // an omitted status takes the default; tested below
|
|
}
|
|
refused(t, f, r, "status_check", "`"+status+"` is not an agent status")
|
|
}
|
|
|
|
// The default is draft: creating an agent must never publish it.
|
|
id := mustInsert(t, f, f.personal(agents, "defaulted", f.bob))
|
|
var status string
|
|
var version int
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT status, version FROM agent_definitions WHERE id = $1::uuid`, id).
|
|
Scan(&status, &version); err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if status != "draft" {
|
|
t.Errorf("default status = %q, want draft", status)
|
|
}
|
|
if version != 1 {
|
|
t.Errorf("default version = %d, want 1", version)
|
|
}
|
|
|
|
// 6. A version is a whole number of 1 or more.
|
|
for _, v := range []int{0, -1, -100} {
|
|
r := f.personal(agents, "bad-version", f.bob)
|
|
r.version = &v
|
|
refused(t, f, r, "version_check", "version must be at least 1")
|
|
}
|
|
for _, v := range []int{1, 2, 9999} {
|
|
r := f.personal(agents, "v-ok", f.alice)
|
|
r.version = &v
|
|
r.defID = "v-ok-" + itoa(v%100)
|
|
if _, err := f.insert(r); err != nil {
|
|
t.Errorf("version %d was refused: %v", v, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSkillStatusAndNoVersion(t *testing.T) {
|
|
f := newFixture(t, "defs_skillstatus")
|
|
|
|
// 5. The two skill statuses, and nothing else.
|
|
for _, status := range []string{"active", "inactive"} {
|
|
r := f.personal(skills, "s-"+status, f.alice)
|
|
r.status = status
|
|
mustInsert(t, f, r)
|
|
}
|
|
for _, status := range []string{"draft", "published", "archived", "ACTIVE"} {
|
|
r := f.personal(skills, "bad-status", f.bob)
|
|
r.status = status
|
|
refused(t, f, r, "status_check", "`"+status+"` is not a skill status")
|
|
}
|
|
|
|
// The default is active — a skill is on unless somebody turns it off.
|
|
id := mustInsert(t, f, f.personal(skills, "defaulted", f.bob))
|
|
var status string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT status FROM skill_definitions WHERE id = $1::uuid`, id).Scan(&status); err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if status != "active" {
|
|
t.Errorf("default status = %q, want active", status)
|
|
}
|
|
|
|
// 7. Skills have NO version. The frontend has no notion of one, so the
|
|
// column must not exist — inventing it "for symmetry" would create a field
|
|
// nothing can set and nothing can mean.
|
|
var exists int
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT count(*)::int FROM information_schema.columns
|
|
WHERE table_schema='public' AND table_name='skill_definitions' AND column_name='version'`).
|
|
Scan(&exists); err != nil {
|
|
t.Fatalf("look for a version column: %v", err)
|
|
}
|
|
if exists != 0 {
|
|
t.Error("skill_definitions has a version column; skills have no version concept")
|
|
}
|
|
}
|
|
|
|
/* ── 8. Markdown bound ──────────────────────────────────────────────────── */
|
|
|
|
func TestMarkdownSizeBound(t *testing.T) {
|
|
f := newFixture(t, "defs_markdown")
|
|
|
|
for _, table := range []string{agents, skills} {
|
|
t.Run(table, func(t *testing.T) {
|
|
// The largest definition shipped with the product is 3,156 bytes,
|
|
// so anything realistic is far inside the bound.
|
|
ok := f.personal(table, "big-but-fine", f.alice)
|
|
ok.markdown = strings.Repeat("x", 65536)
|
|
mustInsert(t, f, ok)
|
|
|
|
over := f.personal(table, "too-big", f.bob)
|
|
over.markdown = strings.Repeat("x", 65537)
|
|
refused(t, f, over, "markdown_size", "a definition over the size bound")
|
|
|
|
empty := f.personal(table, "empty-md", f.bob)
|
|
empty.markdown = ""
|
|
refused(t, f, empty, "markdown_size", "an empty definition cannot parse")
|
|
})
|
|
}
|
|
}
|
|
|
|
// The Markdown is stored byte-for-byte. A definition has to survive a round
|
|
// trip to a .md file on disk, so anything that rewrote it here — trimming,
|
|
// newline normalisation, unicode folding — would break that.
|
|
func TestMarkdownIsStoredVerbatim(t *testing.T) {
|
|
f := newFixture(t, "defs_verbatim")
|
|
|
|
// A BOM, CRLF endings, trailing spaces and a tab — exactly the four things
|
|
// normalizeDefinition exists to tolerate. The database must not "help" by
|
|
// removing any of them: normalising is the parser's job, on read.
|
|
source := "\ufeff---\r\nid: verbatim\r\nname: Verbatim\r\n---\r\n\r\n# Verbatim \r\n\ttabbed\n"
|
|
r := f.personal(agents, "verbatim", f.alice)
|
|
r.markdown = source
|
|
id := mustInsert(t, f, r)
|
|
|
|
var stored string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT markdown FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&stored); err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if stored != source {
|
|
t.Errorf("the stored Markdown differs from what was written:\n in %q\n out %q", source, stored)
|
|
}
|
|
}
|
|
|
|
/* ── 9, 10, 11, 12. Foreign keys and deletion ───────────────────────────── */
|
|
|
|
func TestForeignKeysAndDeleteBehaviour(t *testing.T) {
|
|
f := newFixture(t, "defs_fk")
|
|
missing := "00000000-0000-0000-0000-000000000000"
|
|
|
|
for _, table := range []string{agents, skills} {
|
|
t.Run(table+"/rejects unknown references", func(t *testing.T) {
|
|
// 9. An organization that does not exist.
|
|
bad := f.personal(table, "bad-org", f.alice)
|
|
bad.orgID = missing
|
|
refused(t, f, bad, "org_id_fkey", "org_id must reference a real organization")
|
|
|
|
// 10. An owner that does not exist.
|
|
bad = f.personal(table, "bad-owner", f.alice)
|
|
bad.owner = &missing
|
|
refused(t, f, bad, "owner_user_id_fkey", "owner_user_id must reference a real user")
|
|
|
|
// 11. An author that does not exist.
|
|
bad = f.organization(table, "bad-author", f.alice)
|
|
bad.createdBy = &missing
|
|
refused(t, f, bad, "created_by_fkey", "created_by must reference a real user")
|
|
})
|
|
}
|
|
|
|
// 12. Deletion, three behaviours, each different and each deliberate.
|
|
t.Run("deleting the owner destroys their personal definitions", func(t *testing.T) {
|
|
carol := f.newUser(t, "carol@example.test")
|
|
mustInsert(t, f, f.personal(agents, "carols-agent", carol))
|
|
mustInsert(t, f, f.personal(skills, "carols-skill", carol))
|
|
|
|
if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, carol); err != nil {
|
|
t.Fatalf("delete the user: %v", err)
|
|
}
|
|
for _, table := range []string{agents, skills} {
|
|
var n int
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
"SELECT count(*)::int FROM "+table+" WHERE definition_id LIKE 'carols-%'").Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("%s: %d personal definitions survive their deleted owner, want 0", table, n)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("deleting the author keeps the organization's definition", func(t *testing.T) {
|
|
dave := f.newUser(t, "dave@example.test")
|
|
id := mustInsert(t, f, f.organization(agents, "daves-shared-agent", dave))
|
|
|
|
if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, dave); err != nil {
|
|
t.Fatalf("delete the user: %v", err)
|
|
}
|
|
var author *string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT created_by::text FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&author); err != nil {
|
|
t.Fatalf("the shared definition did not survive its author: %v", err)
|
|
}
|
|
if author != nil {
|
|
t.Errorf("created_by = %v, want NULL after the author was deleted", *author)
|
|
}
|
|
})
|
|
|
|
t.Run("deleting the organization destroys both tiers", func(t *testing.T) {
|
|
g := newFixture(t, "defs_orgcascade")
|
|
mustInsert(t, g, g.personal(agents, "doomed-personal", g.alice))
|
|
mustInsert(t, g, g.organization(skills, "doomed-shared", g.alice))
|
|
|
|
if _, err := g.pool.Exec(g.ctx, `DELETE FROM organizations WHERE id = $1::uuid`, g.orgID); err != nil {
|
|
t.Fatalf("delete the organization: %v", err)
|
|
}
|
|
for _, table := range []string{agents, skills} {
|
|
var n int
|
|
if err := g.pool.QueryRow(g.ctx, "SELECT count(*)::int FROM "+table).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("%s: %d rows survive their deleted organization, want 0", table, n)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/* ── 13, 14. Uniqueness, per tier ───────────────────────────────────────── */
|
|
|
|
func TestUniquenessPerTier(t *testing.T) {
|
|
f := newFixture(t, "defs_unique")
|
|
|
|
for _, table := range []string{agents, skills} {
|
|
t.Run(table, func(t *testing.T) {
|
|
// 13. One personal definition per id per owner.
|
|
mustInsert(t, f, f.personal(table, "board", f.alice))
|
|
refused(t, f, f.personal(table, "board", f.alice), "personal_key",
|
|
"one user cannot hold two personal definitions of the same id")
|
|
|
|
// A different user may hold their own, which is the whole point of
|
|
// personal definitions.
|
|
mustInsert(t, f, f.personal(table, "board", f.bob))
|
|
|
|
// 14. One organization definition per id per organization.
|
|
mustInsert(t, f, f.organization(table, "board", f.alice))
|
|
refused(t, f, f.organization(table, "board", f.bob), "org_key",
|
|
"one organization cannot hold two shared definitions of the same id")
|
|
|
|
// Personal and organization definitions of the SAME id coexist:
|
|
// that is shadow-by-id, and it is the reason definition_id is not
|
|
// globally unique.
|
|
var personal, shared int
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
"SELECT count(*) FILTER (WHERE visibility='personal'), "+
|
|
"count(*) FILTER (WHERE visibility='organization') "+
|
|
"FROM "+table+" WHERE definition_id = 'board'").Scan(&personal, &shared); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if personal != 2 || shared != 1 {
|
|
t.Errorf("board: %d personal + %d shared, want 2 + 1", personal, shared)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A second organization may hold its own definition of the same id.
|
|
t.Run("across organizations", func(t *testing.T) {
|
|
var otherOrg string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`INSERT INTO organizations (name, slug) VALUES ('Other','other-defs') RETURNING id::text`).
|
|
Scan(&otherOrg); err != nil {
|
|
t.Fatalf("create the second organization: %v", err)
|
|
}
|
|
var erin string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid,'erin@example.test','Erin')
|
|
RETURNING id::text`, otherOrg).Scan(&erin); err != nil {
|
|
t.Fatalf("create a user in the second organization: %v", err)
|
|
}
|
|
r := f.organization(agents, "board", erin)
|
|
r.orgID = otherOrg
|
|
mustInsert(t, f, r)
|
|
})
|
|
}
|
|
|
|
/* ── Schema shape ───────────────────────────────────────────────────────── */
|
|
|
|
func TestDefinitionTablesShape(t *testing.T) {
|
|
f := newFixture(t, "defs_shape")
|
|
|
|
shared := map[string]string{
|
|
"id": "uuid",
|
|
"definition_id": "text",
|
|
"org_id": "uuid",
|
|
"visibility": "text",
|
|
"owner_user_id": "uuid",
|
|
"created_by": "text-or-uuid", // placeholder, replaced below
|
|
"markdown": "text",
|
|
"status": "text",
|
|
"name": "text",
|
|
"description": "text",
|
|
"pages": "ARRAY",
|
|
"created_date": "timestamp with time zone",
|
|
"updated_date": "timestamp with time zone",
|
|
}
|
|
shared["created_by"] = "uuid"
|
|
|
|
nullable := map[string]bool{"owner_user_id": true, "created_by": true}
|
|
|
|
for _, table := range []string{agents, skills} {
|
|
want := map[string]string{}
|
|
for k, v := range shared {
|
|
want[k] = v
|
|
}
|
|
if table == agents {
|
|
want["version"] = "integer"
|
|
}
|
|
|
|
t.Run(table, func(t *testing.T) {
|
|
rows, err := f.pool.Query(f.ctx,
|
|
`SELECT column_name, data_type, is_nullable
|
|
FROM information_schema.columns
|
|
WHERE table_schema='public' AND table_name=$1`, table)
|
|
if err != nil {
|
|
t.Fatalf("read columns: %v", err)
|
|
}
|
|
got := map[string]string{}
|
|
for rows.Next() {
|
|
var name, kind, isNullable string
|
|
if err := rows.Scan(&name, &kind, &isNullable); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
got[name] = kind
|
|
if (isNullable == "YES") != nullable[name] {
|
|
t.Errorf("%s.%s is_nullable=%s, want nullable=%v", table, name, isNullable, nullable[name])
|
|
}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("read columns: %v", err)
|
|
}
|
|
|
|
for name, kind := range want {
|
|
if got[name] == "" {
|
|
t.Errorf("%s.%s is missing", table, name)
|
|
} else if got[name] != kind {
|
|
t.Errorf("%s.%s is %s, want %s", table, name, got[name], kind)
|
|
}
|
|
}
|
|
for name := range got {
|
|
if _, expected := want[name]; !expected {
|
|
t.Errorf("%s has an unexpected column %q", table, name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefinitionIndexes(t *testing.T) {
|
|
f := newFixture(t, "defs_indexes")
|
|
|
|
want := map[string][]string{
|
|
agents: {
|
|
"agent_definitions_pkey",
|
|
"agent_definitions_personal_key",
|
|
"agent_definitions_org_key",
|
|
"agent_definitions_org_visibility_idx",
|
|
"agent_definitions_owner_idx",
|
|
"agent_definitions_published_idx",
|
|
},
|
|
skills: {
|
|
"skill_definitions_pkey",
|
|
"skill_definitions_personal_key",
|
|
"skill_definitions_org_key",
|
|
"skill_definitions_org_visibility_idx",
|
|
"skill_definitions_owner_idx",
|
|
"skill_definitions_active_idx",
|
|
},
|
|
}
|
|
|
|
for table, names := range want {
|
|
rows, err := f.pool.Query(f.ctx,
|
|
`SELECT indexname, indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=$1`, table)
|
|
if err != nil {
|
|
t.Fatalf("list indexes: %v", err)
|
|
}
|
|
got := map[string]string{}
|
|
for rows.Next() {
|
|
var name, def string
|
|
if err := rows.Scan(&name, &def); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
got[name] = def
|
|
}
|
|
rows.Close()
|
|
|
|
for _, name := range names {
|
|
if got[name] == "" {
|
|
t.Errorf("%s: index %s is missing", table, name)
|
|
}
|
|
}
|
|
// The two uniqueness indexes must be partial and unique, or they mean
|
|
// something other than what they are named.
|
|
for _, name := range []string{table + "_personal_key", table + "_org_key"} {
|
|
def := got[name]
|
|
if !strings.Contains(def, "UNIQUE") {
|
|
t.Errorf("%s is not UNIQUE: %s", name, def)
|
|
}
|
|
if !strings.Contains(def, "WHERE") {
|
|
t.Errorf("%s is not partial: %s", name, def)
|
|
}
|
|
}
|
|
}
|
|
|
|
// created_by is deliberately unindexed: attribution only, no listing is
|
|
// keyed by it, and its SET NULL scan happens only when a user is deleted.
|
|
for _, table := range []string{agents, skills} {
|
|
var n int
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT count(*)::int FROM pg_indexes
|
|
WHERE schemaname='public' AND tablename=$1 AND indexdef LIKE '%(created_by)%'`,
|
|
table).Scan(&n); err != nil {
|
|
t.Fatalf("look for a created_by index: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("%s has an index on created_by; it was deliberately omitted", table)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Reversibility ──────────────────────────────────────────────────────── */
|
|
|
|
func TestMigration000005IsReversible(t *testing.T) {
|
|
ctx := context.Background()
|
|
pool := testutil.Sandbox(t, "defs_reversible")
|
|
testutil.ApplyAllMigrations(ctx, t, pool)
|
|
|
|
const up = "000005_agent_skill_definitions.up.sql"
|
|
const down = "000005_agent_skill_definitions.down.sql"
|
|
|
|
exists := func(name string) bool {
|
|
var reg *string
|
|
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, name).Scan(®); err != nil {
|
|
t.Fatalf("to_regclass(%s): %v", name, err)
|
|
}
|
|
return reg != nil
|
|
}
|
|
|
|
for _, table := range []string{agents, skills} {
|
|
if !exists(table) {
|
|
t.Fatalf("%s does not exist before the rollback", table)
|
|
}
|
|
}
|
|
|
|
if err := testutil.ApplyMigration(ctx, t, pool, down); err != nil {
|
|
t.Fatalf("apply %s: %v", down, err)
|
|
}
|
|
for _, table := range []string{agents, skills} {
|
|
if exists(table) {
|
|
t.Errorf("%s survived the rollback", table)
|
|
}
|
|
}
|
|
|
|
// The rollback must reach nothing that predates it.
|
|
for _, table := range []string{"users", "organizations", "sessions", "user_preferences", "job_postings"} {
|
|
if !exists(table) {
|
|
t.Fatalf("the rollback dropped %s, which 000005 did not create", table)
|
|
}
|
|
}
|
|
// And no enum type was created, so none can be left behind.
|
|
var leftover int
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT count(*)::int FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
WHERE n.nspname='public' AND t.typtype='e'
|
|
AND t.typname IN ('definition_visibility','agent_status','skill_status')`).Scan(&leftover); err != nil {
|
|
t.Fatalf("look for leftover types: %v", err)
|
|
}
|
|
if leftover != 0 {
|
|
t.Errorf("%d enum types left behind by the rollback", leftover)
|
|
}
|
|
|
|
// Re-applying restores exactly what was removed.
|
|
if err := testutil.ApplyMigration(ctx, t, pool, up); err != nil {
|
|
t.Fatalf("re-apply %s: %v", up, err)
|
|
}
|
|
for _, table := range []string{agents, skills} {
|
|
if !exists(table) {
|
|
t.Errorf("%s did not come back", table)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every migration has a matching down file, and the set is what we think it is.
|
|
//
|
|
// The list is written out rather than counted. A migration is the one kind of
|
|
// change that cannot be undone by editing a file, so adding one should require
|
|
// naming it here — a bare count would let a stray file slip in by incrementing
|
|
// a number, which is exactly the review nobody performs.
|
|
func TestMigrationPairsAreComplete(t *testing.T) {
|
|
ups := testutil.MigrationFiles(t, ".up.sql")
|
|
downs := testutil.MigrationFiles(t, ".down.sql")
|
|
if len(ups) != len(downs) {
|
|
t.Fatalf("%d up and %d down migrations", len(ups), len(downs))
|
|
}
|
|
for i, up := range ups {
|
|
want := strings.TrimSuffix(up, ".up.sql") + ".down.sql"
|
|
if downs[i] != want {
|
|
t.Errorf("%s has no matching down migration (found %s)", up, downs[i])
|
|
}
|
|
}
|
|
|
|
want := []string{
|
|
"000001_initial_schema.up.sql",
|
|
"000002_application_interview_id.up.sql",
|
|
"000003_drop_screened_consistent_check.up.sql",
|
|
"000004_auth_sessions.up.sql",
|
|
"000005_agent_skill_definitions.up.sql",
|
|
"000006_agent_runs.up.sql",
|
|
"000007_agent_confirmations.up.sql",
|
|
"000008_knowledge.up.sql",
|
|
"000009_confirmation_replay.up.sql",
|
|
"000010_definition_versions.up.sql",
|
|
"000011_employee_roles.up.sql",
|
|
// Phase 3: the OAuth 2.1 authorization server behind the MCP surface.
|
|
// Three tables, added together because they are one feature: a client
|
|
// registers, is issued a code, and exchanges it for tokens.
|
|
"000012_oauth_clients.up.sql",
|
|
"000013_oauth_grants.up.sql",
|
|
"000014_oauth_tokens.up.sql",
|
|
// Phase 5: shared rate limit counters, so a limit means the same thing
|
|
// behind one instance and behind ten.
|
|
"000015_rate_limits.up.sql",
|
|
// A seventh termination reason. The CHECK in 000006 was chosen so
|
|
// this would be a migration rather than an ALTER TYPE; this is it.
|
|
"000016_gateway_failure_termination.up.sql",
|
|
}
|
|
if len(ups) != len(want) {
|
|
t.Fatalf("%d migrations, want %d — update this list deliberately", len(ups), len(want))
|
|
}
|
|
for i, name := range want {
|
|
if ups[i] != name {
|
|
t.Errorf("migration %d is %s, want %s", i+1, ups[i], name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The tables that exist, counted, plus the ones that deliberately do not.
|
|
//
|
|
// The Phase 4B decision was explicit about which tables must NOT appear, and
|
|
// that half of this test is the durable half — the forbidden list below is a
|
|
// design decision, not a snapshot. The count is the snapshot, and it is here so
|
|
// that a table arriving without a decision behind it fails somewhere.
|
|
func TestMigrationsAddOnlyTheTablesWeDecidedOn(t *testing.T) {
|
|
f := newFixture(t, "defs_tablecount")
|
|
|
|
var n int
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT count(*)::int FROM information_schema.tables
|
|
WHERE table_schema='public' AND table_type='BASE TABLE'`).Scan(&n); err != nil {
|
|
t.Fatalf("count tables: %v", err)
|
|
}
|
|
// 17 from 000001, + auth_sessions (000004), + agent_definitions and
|
|
// skill_definitions (000005), + agent_runs (000006), + agent_confirmations
|
|
// (000007), + knowledge_documents and knowledge_chunks (000008),
|
|
// + definition_versions (000010), + employee_roles (000011),
|
|
// + oauth_clients (000012), + oauth_grants (000013), + oauth_tokens
|
|
// (000014), + rate_limits (000015).
|
|
// schema_migrations is golang-migrate's and is absent when the files are
|
|
// applied directly.
|
|
if n != 30 {
|
|
t.Errorf("%d base tables after every migration, want 30", n)
|
|
}
|
|
|
|
// The three OAuth tables, named rather than merely counted. The count
|
|
// above catches a table arriving without a decision; this catches one of
|
|
// these three going missing, which the count alone would not if another
|
|
// arrived in the same change.
|
|
for _, required := range []string{"oauth_clients", "oauth_grants", "oauth_tokens", "rate_limits"} {
|
|
var reg *string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT to_regclass('public.' || $1)::text`, required).Scan(®); err != nil {
|
|
t.Fatalf("check %s: %v", required, err)
|
|
}
|
|
if reg == nil {
|
|
t.Errorf("%s is missing; the MCP OAuth surface cannot work without it", required)
|
|
}
|
|
}
|
|
|
|
// `definition_versions` was on this list, deferred by the Phase 4B decision.
|
|
// It is built now — §3's "specs are immutable once published" needs it, and
|
|
// a run recording an agent_version that resolves to nothing is a record
|
|
// nobody can explain. Removed from the list deliberately rather than
|
|
// silently, which is the whole reason the list is written out.
|
|
for _, forbidden := range []string{
|
|
"definition_permissions", "agent_skills",
|
|
"agent_subagents", "agent_knowledge", "conversations",
|
|
"conversation_messages", "conversation_feedback",
|
|
} {
|
|
var reg *string
|
|
if err := f.pool.QueryRow(f.ctx,
|
|
`SELECT to_regclass('public.' || $1)::text`, forbidden).Scan(®); err != nil {
|
|
t.Fatalf("to_regclass: %v", err)
|
|
}
|
|
if reg != nil {
|
|
t.Errorf("table %s exists; Phase 4B deferred or rejected it", forbidden)
|
|
}
|
|
}
|
|
}
|