package db
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// These tests need no database. They cover the logic that decides things —
// transition legality, token comparison, batch duplicate detection — plus the
// agreement between schema.sql and the migration, which is otherwise only
// discovered on a fresh install.
func TestProposalBranch(t *testing.T) {
for _, tc := range []struct {
id int
want string
}{
{1, "proposals/1"},
{42, "proposals/42"},
{1000000, "proposals/1000000"},
} {
if got := ProposalBranch(tc.id); got != tc.want {
t.Errorf("ProposalBranch(%d) = %q, want %q", tc.id, got, tc.want)
}
}
if !strings.HasPrefix(ProposalBranch(7), BranchPrefix) {
t.Errorf("ProposalBranch must stay under the refs-rule prefix %q", BranchPrefix)
}
}
func TestHashTokenAndMatches(t *testing.T) {
tok, err := GenerateToken()
if err != nil {
t.Fatalf("generate token: %v", err)
}
if len(tok) < 40 {
t.Fatalf("token %q is implausibly short for %d bytes of entropy", tok, TokenBytes)
}
hash := HashToken(tok)
if len(hash) != sha256.Size {
t.Fatalf("HashToken returned %d bytes, want %d", len(hash), sha256.Size)
}
if strings.Contains(string(hash), tok) {
t.Fatal("hash must not contain the token")
}
if !TokenMatches(hash, tok) {
t.Fatal("TokenMatches rejected the token it hashed")
}
if TokenMatches(hash, tok+"x") {
t.Fatal("TokenMatches accepted a different token")
}
if TokenMatches(hash, "") {
t.Fatal("TokenMatches accepted the empty token")
}
if TokenMatches(nil, tok) {
t.Fatal("TokenMatches accepted a nil stored hash")
}
if TokenMatches(hash[:16], tok) {
t.Fatal("TokenMatches accepted a truncated stored hash")
}
// Two mints must differ; a repeated value would mean the generator is not
// actually random and every token would be the same credential.
other, err := GenerateToken()
if err != nil {
t.Fatalf("generate token: %v", err)
}
if other == tok {
t.Fatal("GenerateToken returned the same value twice")
}
}
func TestDuplicateDocIDs(t *testing.T) {
ref := func(id, path string) DocRef {
return DocRef{ID: docID(t, id), Path: path}
}
for _, tc := range []struct {
name string
in []DocRef
want []string
}{
{"empty", nil, nil},
{"unique", []DocRef{ref("SPEC-1", "a.md"), ref("SPEC-2", "b.md")}, nil},
{
"same id twice at different paths",
[]DocRef{ref("SPEC-1", "a.md"), ref("SPEC-1", "b.md")},
[]string{"SPEC-1"},
},
{
"leading zeros are a different id",
[]DocRef{ref("SPEC-7", "a.md"), ref("SPEC-0007", "b.md")},
nil,
},
{
"several duplicates, sorted",
[]DocRef{
ref("SPEC-2", "a.md"), ref("SPEC-1", "b.md"),
ref("SPEC-2", "c.md"), ref("SPEC-1", "d.md"),
},
[]string{"SPEC-1", "SPEC-2"},
},
} {
t.Run(tc.name, func(t *testing.T) {
got := DuplicateDocIDs(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("DuplicateDocIDs = %v, want %v", got, tc.want)
}
for i, id := range got {
if id.String() != tc.want[i] {
t.Fatalf("DuplicateDocIDs[%d] = %s, want %s", i, id, tc.want[i])
}
}
})
}
}
func TestCollisionErrorWrapsErrDocIDTaken(t *testing.T) {
err := &CollisionError{Collisions: []Collision{{
DocID: docID(t, "SPEC-7"),
Existing: &Document{ID: docID(t, "SPEC-7"), SpaceID: 3, Path: "specs/0007.md"},
}}}
if !errors.Is(err, ErrDocIDTaken) {
t.Fatal("CollisionError must match ErrDocIDTaken")
}
msg := err.Error()
for _, want := range []string{"SPEC-7", "space 3", "specs/0007.md"} {
if !strings.Contains(msg, want) {
t.Errorf("collision message %q does not name %q", msg, want)
}
}
}
// fakeQuerier satisfies Querier but not beginner, standing in for a Store that
// is already bound to a transaction.
type fakeQuerier struct{}
func (fakeQuerier) ExecContext(context.Context, string, ...any) (sql.Result, error) {
return nil, errors.New("unexpected Exec")
}
func (fakeQuerier) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
return nil, errors.New("unexpected Query")
}
func (fakeQuerier) QueryRowContext(context.Context, string, ...any) *sql.Row { return nil }
func TestInTxRefusesToNest(t *testing.T) {
s := NewStore(fakeQuerier{})
called := false
err := s.InTx(context.Background(), func(*Store) error {
called = true
return nil
})
if !errors.Is(err, ErrNoTransaction) {
t.Fatalf("InTx on a non-beginner = %v, want ErrNoTransaction", err)
}
if called {
t.Fatal("InTx ran the body without a transaction")
}
}
// TestResolveArgumentsValidated covers the guards that reject before any SQL is
// issued; the fake Querier would error if a query were attempted.
func TestResolveArgumentsValidated(t *testing.T) {
s := NewStore(fakeQuerier{})
ctx := context.Background()
if err := s.MarkProposalMerged(ctx, 1, core.Approval("bogus"), "abc"); !errors.Is(err, core.ErrInvalidApproval) {
t.Fatalf("merge with a bogus approval = %v, want ErrInvalidApproval", err)
}
if err := s.MarkProposalMerged(ctx, 1, core.ApprovalHuman, ""); err == nil {
t.Fatal("merge without a merged rev must fail")
}
if err := s.MergeProposal(ctx, Merge{ProposalID: 1, Approval: core.ApprovalPolicy}); err == nil {
t.Fatal("MergeProposal without a merged rev must fail")
}
if _, err := s.ListProposalsByState(ctx, core.ProposalState("closed"), 0); !errors.Is(err, core.ErrInvalidState) {
t.Fatalf("list with a bogus state = %v, want ErrInvalidState", err)
}
if _, err := s.OpenProposal(ctx, &Proposal{SpaceID: 1, Title: "t", BaseRev: "abc"}); err == nil {
t.Fatal("OpenProposal without provenance must fail")
}
if _, err := s.OpenProposal(ctx, &Proposal{
SpaceID: 1, Title: "t", Agent: "a", AgentSession: "s",
}); err == nil {
t.Fatal("OpenProposal without a base rev must fail")
}
if _, err := s.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "../etc"}); !errors.Is(err, core.ErrInvalidName) {
t.Fatalf("CreateSpace with a traversing name = %v, want ErrInvalidName", err)
}
if _, err := s.CreateProject(ctx, core.ProjectRef{Owner: "bigbes", Name: "../etc"}); !errors.Is(err, core.ErrInvalidName) {
t.Fatalf("CreateProject with a traversing name = %v, want ErrInvalidName", err)
}
// The meta-project is an address that resolves to a filter excluding
// nothing. A row claiming its name could only shadow it, so no door
// creates one.
if _, err := s.CreateProject(ctx, core.ProjectRef{
Owner: "bigbes", Name: core.MetaProjectName,
}); !errors.Is(err, core.ErrReservedName) {
t.Fatalf("CreateProject of the meta-project = %v, want ErrReservedName", err)
}
if _, err := s.CreateAgentToken(ctx, "", HashToken("x")); err == nil {
t.Fatal("CreateAgentToken without a name must fail")
}
if _, err := s.CreateAgentToken(ctx, "ci", []byte("short")); err == nil {
t.Fatal("CreateAgentToken with a non-sha256 hash must fail")
}
if _, err := s.AuthenticateAgentToken(ctx, ""); !errors.Is(err, ErrNotFound) {
t.Fatalf("authenticating an empty token = %v, want ErrNotFound", err)
}
if err := s.SetDigestMark(ctx, "", time.Now()); err == nil {
t.Fatal("SetDigestMark without an owner must fail")
}
if err := s.SetDigestMark(ctx, "bigbes", time.Time{}); err == nil {
t.Fatal("SetDigestMark with a zero timestamp must fail")
}
if _, err := s.SetIndexStamp(ctx, 1, ""); err == nil {
t.Fatal("SetIndexStamp without a rev must fail")
}
if err := s.UpsertDocIDs(ctx, 1, []DocRef{
{ID: docID(t, "SPEC-1"), Path: "a.md"},
{ID: docID(t, "SPEC-1"), Path: "b.md"},
}, "abc"); !errors.Is(err, ErrDocIDDuplicate) {
t.Fatalf("upsert with an in-batch duplicate = %v, want ErrDocIDDuplicate", err)
}
if err := s.UpsertDocIDs(ctx, 1, []DocRef{
{ID: docID(t, "SPEC-1"), Path: "../escape.md"},
}, "abc"); !errors.Is(err, core.ErrInvalidPath) {
t.Fatalf("upsert with a traversing path = %v, want ErrInvalidPath", err)
}
// An empty batch is a legal no-op and must not reach SQL.
if err := s.UpsertDocIDs(ctx, 1, nil, "abc"); err != nil {
t.Fatalf("empty upsert = %v, want nil", err)
}
if c, err := s.CheckDocIDCollisions(ctx, 1, nil); err != nil || c != nil {
t.Fatalf("empty collision check = %v, %v; want nil, nil", c, err)
}
}
// TestTransitionGuardMatchesCore pins the guard this package relies on: the
// UPDATE ... WHERE state = 'open' clause is only correct because open is the
// sole state anything may leave.
func TestTransitionGuardMatchesCore(t *testing.T) {
legal := map[[2]core.ProposalState]bool{
{core.StateOpen, core.StateMerged}: true,
{core.StateOpen, core.StateRejected}: true,
}
for _, from := range core.ProposalStates() {
for _, to := range core.ProposalStates() {
want := legal[[2]core.ProposalState{from, to}]
if got := core.ValidTransition(from, to); got != want {
t.Errorf("ValidTransition(%s, %s) = %v, want %v", from, to, got, want)
}
}
}
if err := core.StateOpen.CanTransitionTo(core.StateOpen); !errors.Is(err, core.ErrInvalidTransition) {
t.Fatalf("open->open = %v, want ErrInvalidTransition", err)
}
if err := core.StateMerged.CanTransitionTo(core.StateRejected); !errors.Is(err, core.ErrInvalidTransition) {
t.Fatalf("merged->rejected = %v, want ErrInvalidTransition", err)
}
}
func TestNullable(t *testing.T) {
if nullable("") != nil {
t.Error("empty string must become SQL NULL")
}
if nullable("x") != any("x") {
t.Error("non-empty string must pass through")
}
}
// TestSchemaMatchesMigration is the check a fresh install would otherwise fail:
// schema.sql is the authoritative DDL, the migrations under migrations/ build
// the same objects in the same order, and the two drifting apart means a
// migrated database and a freshly created one are different databases.
//
// Every migration is replayed, not just the first: a new table added to
// schema.sql and to 0002 must appear in both, and appending it to only one of
// them is exactly the drift this test exists to catch.
func TestSchemaMatchesMigration(t *testing.T) {
schema, err := os.ReadFile("../schema.sql")
if err != nil {
t.Fatalf("read schema.sql: %v", err)
}
files, err := filepath.Glob("../migrations/*.sql")
if err != nil {
t.Fatalf("glob migrations: %v", err)
}
if len(files) == 0 {
t.Fatal("no migrations found")
}
sort.Strings(files) // brant applies them in filename order
var ups, downs []string
for _, f := range files {
migration, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
up, down, ok := splitBrant(string(migration))
if !ok {
t.Fatalf("%s is missing a `-- +brant Up` / `-- +brant Down` pair", f)
}
ups = append(ups, normalizeStatements(up)...)
downs = append(downs, normalizeStatements(down)...)
}
fromSchema := normalizeStatements(string(schema))
fromMigration := ups
if len(fromSchema) != len(fromMigration) {
t.Fatalf("schema.sql has %d statements, migration Up has %d:\n%v\n%v",
len(fromSchema), len(fromMigration), fromSchema, fromMigration)
}
for i := range fromSchema {
if fromSchema[i] != fromMigration[i] {
t.Errorf("statement %d differs:\n schema.sql: %s\n migration : %s",
i, fromSchema[i], fromMigration[i])
}
}
// Every table created must be dropped, so a Down actually undoes the Up.
created := tableNames(fromSchema, "CREATE TABLE ")
dropped := tableNames(downs, "DROP TABLE ")
if len(created) == 0 {
t.Fatal("no CREATE TABLE statements found in schema.sql")
}
for _, name := range created {
if !contains(dropped, name) {
t.Errorf("table %q is created by Up but not dropped by Down", name)
}
}
// The design deliberately omits this one; adding it needs a design change,
// not a quiet migration.
for _, absent := range []string{"comment"} {
if contains(created, absent) {
t.Errorf("table %q is deliberately absent from v1", absent)
}
}
for _, want := range []string{"space", "document_id", "proposal", "agent_token",
"index_stamp", "digest_mark", "project", "project_space"} {
if !contains(created, want) {
t.Errorf("table %q is missing from schema.sql", want)
}
}
}
func splitBrant(src string) (up, down string, ok bool) {
i := strings.Index(src, "-- +brant Up")
j := strings.Index(src, "-- +brant Down")
if i < 0 || j < 0 || j < i {
return "", "", false
}
return src[i+len("-- +brant Up") : j], src[j+len("-- +brant Down"):], true
}
var (
lineComment = regexp.MustCompile(`(?m)--.*$`)
whitespace = regexp.MustCompile(`\s+`)
)
// normalizeStatements strips line comments, collapses whitespace and splits on
// ';', so two DDL files that differ only in commentary and indentation compare
// equal.
func normalizeStatements(src string) []string {
src = lineComment.ReplaceAllString(src, " ")
var out []string
for _, stmt := range strings.Split(src, ";") {
stmt = strings.TrimSpace(whitespace.ReplaceAllString(stmt, " "))
stmt = strings.ReplaceAll(stmt, "( ", "(")
stmt = strings.ReplaceAll(stmt, " )", ")")
if stmt != "" {
out = append(out, stmt)
}
}
return out
}
func tableNames(stmts []string, prefix string) []string {
var out []string
for _, s := range stmts {
if !strings.HasPrefix(s, prefix) {
continue
}
rest := strings.TrimPrefix(s, prefix)
if i := strings.IndexAny(rest, " ("); i >= 0 {
rest = rest[:i]
}
out = append(out, strings.Trim(rest, `"`))
}
return out
}
func contains(hay []string, needle string) bool {
for _, h := range hay {
if h == needle {
return true
}
}
return false
}