809 lines
28 KiB
Go
809 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",
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|