package oltp
import (
"fmt"
"strings"
"testing"
)
// purgeKeeps names the repo-keyed tables PurgeRepo deliberately leaves,
// each with the reason. Everything else with a `repo` column must be
// empty for a purged name, so a table added later without a purge line
// fails this test instead of leaking into the next repository to take
// the name (quarry#294).
var purgeKeeps = map[string]string{
// The forge's own error log: read by site admins on the ops page,
// never through a repository, and the rows are about the code path
// that failed rather than anything the repository owned.
"exception": "site-level error log",
}
// seedRepoRow inserts one row naming repo into table, filling every
// column with a value of its declared type. n keeps text and integer
// keys distinct across calls; other integers are 1, which every CHECK
// in the schema admits.
func seedRepoRow(t *testing.T, db *DB, table, repo string, n int) {
t.Helper()
rows, err := db.db.Query(`SELECT name, type, pk FROM pragma_table_info(?)`, table)
if err != nil {
t.Fatal(err)
}
var cols []string
var args []any
for rows.Next() {
var name, typ string
var pk int
if err := rows.Scan(&name, &typ, &pk); err != nil {
t.Fatal(err)
}
cols = append(cols, name)
switch {
case name == "repo":
args = append(args, repo)
case strings.Contains(strings.ToUpper(typ), "INT") && pk > 0:
args = append(args, n)
case strings.Contains(strings.ToUpper(typ), "INT"):
args = append(args, 1)
default:
args = append(args, fmt.Sprintf("%s-%s-%d", table, name, n))
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
q := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s)`, table,
strings.Join(cols, ", "), strings.TrimSuffix(strings.Repeat("?, ", len(cols)), ", "))
if _, err := db.w.Exec(q, args...); err != nil {
t.Fatalf("seed %s: %v", table, err)
}
}
// repoKeyedTables is every table with a column named repo.
func repoKeyedTables(t *testing.T, db *DB) []string {
t.Helper()
rows, err := db.db.Query(`SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c
WHERE m.type = 'table' AND c.name = 'repo' ORDER BY m.name`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
t.Fatal(err)
}
out = append(out, n)
}
return out
}
func countWhere(t *testing.T, db *DB, q string, args ...any) int {
t.Helper()
var n int
if err := db.db.QueryRow(q, args...).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// Every table keyed by repository name is empty for a purged name, and
// a neighbour's rows are untouched.
func TestPurgeRepoEmptiesEveryRepoKeyedTable(t *testing.T) {
db := testDB(t)
tables := repoKeyedTables(t, db)
if len(tables) < 10 {
t.Fatalf("found %d repo-keyed tables; the schema scan is broken", len(tables))
}
for i, table := range tables {
seedRepoRow(t, db, table, "gone", 2*i+1)
seedRepoRow(t, db, table, "kept", 2*i+2)
}
if err := db.PurgeRepo("gone"); err != nil {
t.Fatal(err)
}
for _, table := range tables {
left := countWhere(t, db, fmt.Sprintf(`SELECT count(*) FROM %s WHERE repo = ?`, table), "gone")
if _, keep := purgeKeeps[table]; keep {
if left != 1 {
t.Errorf("%s: %d rows for the purged name; it is kept on purpose", table, left)
}
} else if left != 0 {
t.Errorf("%s: %d rows survived the purge", table, left)
}
if n := countWhere(t, db, fmt.Sprintf(`SELECT count(*) FROM %s WHERE repo = ?`, table), "kept"); n != 1 {
t.Errorf("%s: the neighbour's row went too (%d left)", table, n)
}
}
}
// Records that name the repository somewhere other than a repo column.
func TestPurgeRepoTakesRecordsKeyedOtherwise(t *testing.T) {
db := testDB(t)
for _, name := range []string{"gone", "kept"} {
if err := db.SetAvatar(Avatar{Kind: AvatarRepo, Name: name, Key: "k-" + name,
Mime: "image/png", ETag: "e-" + name, Bytes: 1}); err != nil {
t.Fatal(err)
}
if err := db.IdemRemember("ticket.create|"+name+"|dan", "k1", "d", "r"); err != nil {
t.Fatal(err)
}
}
// Muting is an account preference naming repositories, in the JSON
// list the inbox writes or the space-separated form it still reads.
if err := db.SetAccountPref("ann", "mute", `["gone","kept"]`); err != nil {
t.Fatal(err)
}
if err := db.SetAccountPref("bob", "mute", "kept gone"); err != nil {
t.Fatal(err)
}
if err := db.SetAccountPref("cy", "mute", `["gone.x"]`); err != nil {
t.Fatal(err)
}
// "gone" is somebody's companion forum store; its binding must not
// outlive it and hand the next "gone" to that parent.
if err := db.BindCompanion("parent", "forum", "gone"); err != nil {
t.Fatal(err)
}
if err := db.BindCompanion("kept", "forum", "kept-forum"); err != nil {
t.Fatal(err)
}
if err := db.PurgeRepo("gone"); err != nil {
t.Fatal(err)
}
if _, ok, _ := db.AvatarOf(AvatarRepo, "gone"); ok {
t.Error("the repository's avatar survived the purge")
}
if _, ok, _ := db.AvatarOf(AvatarRepo, "kept"); !ok {
t.Error("the neighbour's avatar went too")
}
if _, _, ok, _ := db.IdemRecall("ticket.create|gone|dan", "k1"); ok {
t.Error("a remembered answer for the purged name would replay into its successor")
}
if _, _, ok, _ := db.IdemRecall("ticket.create|kept|dan", "k1"); !ok {
t.Error("the neighbour's remembered answer went too")
}
for who, want := range map[string]string{"ann": `["kept"]`, "bob": `["kept"]`, "cy": `["gone.x"]`} {
if got := db.AccountPref(who, "mute"); got != want {
t.Errorf("%s's mute list = %s, want %s", who, got, want)
}
}
if _, _, ok, _ := db.CompanionParent("gone"); ok {
t.Error("the purged name is still bound as a companion store")
}
if _, ok, _ := db.Companion("kept", "forum"); !ok {
t.Error("the neighbour's companion binding went too")
}
}
// A reporter token minted for a deleted repository authenticates
// nothing for a new repository of the same name.
func TestReporterTokenDiesWithItsRepository(t *testing.T) {
db := testDB(t)
if err := db.AddRepo("gone", t.TempDir(), "", Public); err != nil {
t.Fatal(err)
}
tok, err := db.MintReporterToken("gone", "ci")
if err != nil {
t.Fatal(err)
}
if err := db.PurgeRepo("gone"); err != nil {
t.Fatal(err)
}
if err := db.AddRepo("gone", t.TempDir(), "", Public); err != nil {
t.Fatal(err)
}
if db.CheckReporterToken("gone", tok) {
t.Fatal("the deleted repository's reporter token authenticates for its successor")
}
if toks, _ := db.ReporterTokens("gone"); len(toks) != 0 {
t.Fatalf("the successor lists %d inherited reporter tokens", len(toks))
}
}
// A chat channel's robot-line log goes when the channel is purged, and
// only that channel's.
func TestPurgeRobotLines(t *testing.T) {
db := testDB(t)
for _, code := range []string{"c0de", "beef"} {
if err := db.RecordRobotLine(code, 7, now()); err != nil {
t.Fatal(err)
}
}
if err := db.PurgeRobotLines("c0de"); err != nil {
t.Fatal(err)
}
if n, _ := db.RobotLines("c0de", 0); n != 0 {
t.Errorf("%d robot lines survived the channel", n)
}
if n, _ := db.RobotLines("beef", 0); n != 1 {
t.Errorf("the neighbour channel has %d robot lines, want 1", n)
}
}