package oltp
import (
"errors"
"testing"
)
func countRows(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
}
func TestDeleteRefusedWhileOwningRepos(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("bob", "pw-bob-12345", false); err != nil {
t.Fatal(err)
}
if err := db.AddRepo("bobrepo", t.TempDir(), "", "public"); err != nil {
t.Fatal(err)
}
if err := db.SetOwner("bobrepo", "bob"); err != nil {
t.Fatal(err)
}
err := db.DeleteAccount("bob")
var ref Refusal
if !errors.As(err, &ref) {
t.Fatalf("delete with owned repos: want Refusal, got %v", err)
}
if _, ok := db.AccountByName("bob"); !ok {
t.Fatal("refused delete still removed the account")
}
if db.Tombstoned("bob") {
t.Fatal("refused delete tombstoned the name")
}
// Once transferred, the delete goes through.
if err := db.SetOwner("bobrepo", ""); err != nil {
t.Fatal(err)
}
if err := db.DeleteAccount("bob"); err != nil {
t.Fatal(err)
}
}
func TestDeletedAccountStaysDeleted(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("admin", "pw-admin-12345", true); err != nil {
t.Fatal(err)
}
if err := db.AddAccount("bob", "pw-bob-12345", false); err != nil {
t.Fatal(err)
}
if err := db.AddRepo("shared", t.TempDir(), "", "public"); err != nil {
t.Fatal(err)
}
if err := db.Grant("bob", "shared", RepoAdmin); err != nil {
t.Fatal(err)
}
if err := db.Grant("admin", "shared", RepoAdmin); err != nil {
t.Fatal(err)
}
value, tok, err := db.MintToken("bob", TokenSpec{Name: "t", Scopes: []string{ScopeSync}})
if err != nil {
t.Fatal(err)
}
// RFC 0021 leftovers: a migrated push row mirroring a sync_token row
// kept for rollback, a spent secret, and a recorded carrier.
for _, q := range []string{
`CREATE TABLE sync_token (user TEXT PRIMARY KEY, sealed TEXT NOT NULL, created TEXT NOT NULL)`,
`INSERT INTO sync_token VALUES ('bob', 'sealed', 'x')`,
`UPDATE token SET migrated = 1, verifier = 'sealed', ended_secret = 'old' WHERE account = 'bob'`,
`INSERT INTO token (id, account, name, verifier, sealed, scope, created, expires, migrated, ended_secret) VALUES ('t2', 'bob', 'push', 'v2', 1, 'sync', 'x', 'y', 1, 'old2')`,
`INSERT INTO artifact_carrier (repo, artifact, login, at) VALUES ('shared', 'abc', 'bob', 'x')`,
`INSERT INTO push_device (account, endpoint_hash, ua, at) VALUES ('bob', 'h', 'ua', 'x')`,
`INSERT INTO account_pref (account, key, value) VALUES ('bob', 'k', 'v')`,
`INSERT INTO forum_sub (repo, account, thread, state, at) VALUES ('shared', 'bob', 't', 'sub', 'x')`,
`INSERT INTO sync_user (repo, user, secret) VALUES ('shared', 'bob', 's')`,
`INSERT INTO landing_auto (repo, landing, account) VALUES ('shared', 'L1', 'bob')`,
} {
if _, err := db.w.Exec(q); err != nil {
t.Fatal(err)
}
}
sess, _, ok := db.NewSession("bob", "pw-bob-12345")
if !ok {
t.Fatal("no session")
}
if _, err := db.SetPendingEmail("bob", "[email protected]"); err != nil {
t.Fatal(err)
}
if _, err := db.ResetPassword("bob"); err != nil {
t.Fatal(err)
}
if _, err := db.w.Exec(`INSERT INTO team (name, created) VALUES ('crew', 'x')`); err != nil {
t.Fatal(err)
}
if _, err := db.w.Exec(`INSERT INTO team_member (team, account) VALUES ('crew', 'bob')`); err != nil {
t.Fatal(err)
}
if err := db.DeleteAccount("bob"); err != nil {
t.Fatal(err)
}
if _, ok := db.SessionAccount(sess); ok {
t.Error("session survived the delete")
}
if _, ok := db.ResolveToken("bob", value, ScopeSync); ok {
t.Error("token survived the delete")
}
for table, col := range map[string]string{
"token": "account", "session": "account", "repo_caps": "account",
"team_member": "account", "sync_token": "user", "artifact_carrier": "login",
"push_device": "account", "account_pref": "account", "forum_sub": "account",
"sync_user": "user", "landing_auto": "account", "email_token": "account", "invite": "account",
} {
if n := countRows(t, db, `SELECT count(*) FROM `+table+` WHERE `+col+` = 'bob'`); n != 0 {
t.Errorf("%s still has %d rows for bob", table, n)
}
}
_ = tok
if !db.Tombstoned("bob") {
t.Fatal("name not tombstoned")
}
var ref Refusal
for _, n := range []string{"Bob", "BOB"} {
if err := db.AddAccount(n, "pw-bob-67890", false); !errors.As(err, &ref) {
t.Errorf("re-create as %q: want Refusal, got %v", n, err)
}
if err := db.AddTeam(n, "", "", "admin"); !errors.As(err, &ref) {
t.Errorf("team %q: want Refusal, got %v", n, err)
}
if err := db.AddRepo(n, t.TempDir(), "", "public"); !errors.As(err, &ref) {
t.Errorf("repo %q: want Refusal, got %v", n, err)
}
}
if _, _, err := db.ImportUsers("shared", "pc", []ImportedUser{{Login: "bob", PWHash: "h", Caps: "s"}}); err != nil {
t.Fatal(err)
}
if _, ok := db.AccountByName("bob"); ok {
t.Error("import-users re-created the deleted account")
}
if err := db.AddAccount("bob", "pw-bob-67890", false); !errors.As(err, &ref) {
t.Errorf("re-create: want Refusal, got %v", err)
}
if _, err := db.InviteAccount("bob", "[email protected]", false); !errors.As(err, &ref) {
t.Errorf("re-invite: want Refusal, got %v", err)
}
if _, ok := db.AccountByName("bob"); ok {
t.Error("bob exists again")
}
if _, ok := db.ResolveToken("bob", value, ScopeSync); ok {
t.Error("old token resolves")
}
if n := countRows(t, db, `SELECT count(*) FROM repo_caps WHERE account = 'bob'`); n != 0 {
t.Error("grants reattached")
}
// A fossil user linked to bob cannot be re-linked either.
if _, _, err := db.ReplaceFossilUsers("shared", []FossilUser{{Login: "bob", Caps: "o"}}); err != nil {
t.Fatal(err)
}
if _, err := db.LinkFossilUser("admin", "shared", "bob", "bob"); err == nil {
t.Error("fossil link to a deleted name succeeded")
}
}
func TestDeleteRefusedForSoleTeamOwner(t *testing.T) {
db := testDB(t)
for _, n := range []string{"bob", "carol"} {
if err := db.AddAccount(n, "pw-"+n+"-12345", false); err != nil {
t.Fatal(err)
}
}
if _, err := db.w.Exec(`INSERT INTO team (name, created) VALUES ('crew', 'x')`); err != nil {
t.Fatal(err)
}
if err := db.SetTeamMember("crew", "bob", "", true, true); err != nil {
t.Fatal(err)
}
var ref Refusal
if err := db.DeleteAccount("bob"); !errors.As(err, &ref) {
t.Fatalf("sole team owner: want Refusal, got %v", err)
}
if db.Tombstoned("bob") {
t.Fatal("refused delete tombstoned the name")
}
// A second owner makes it deletable.
if err := db.SetTeamMember("crew", "carol", "", true, true); err != nil {
t.Fatal(err)
}
if err := db.DeleteAccount("bob"); err != nil {
t.Fatal(err)
}
}
func TestDeleteOfNoSuchAccountTombstonesNothing(t *testing.T) {
db := testDB(t)
if err := db.DeleteAccount("nosuch"); !errors.Is(err, ErrNoAccount) {
t.Fatalf("want ErrNoAccount, got %v", err)
}
if db.Tombstoned("nosuch") {
t.Fatal("a typo was tombstoned")
}
}
func TestSweepDropsAutoAcceptsOfMissingAccounts(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "pw-dan-12345", false); err != nil {
t.Fatal(err)
}
for _, a := range []string{"dan", "ghost"} {
if err := db.SetAutoAccept("r", "L-"+a, a); err != nil {
t.Fatal(err)
}
}
got, err := db.AutoAccepts()
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Account != "dan" {
t.Fatalf("AutoAccepts = %+v, want only dan's", got)
}
if n := countRows(t, db, `SELECT count(*) FROM landing_auto WHERE account = 'ghost'`); n != 0 {
t.Fatal("the ghost's arm was not disarmed")
}
}
// The --repo bootstrap honours the tombstone like every other path.
func TestEnsureRepoRefusesADeletedName(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("carol", "pw-carol-12345", false); err != nil {
t.Fatal(err)
}
if err := db.DeleteAccount("carol"); err != nil {
t.Fatal(err)
}
if err := db.EnsureRepo("Carol", t.TempDir()); err == nil {
t.Fatal("FAIL: EnsureRepo(Carol) after carol was deleted = nil, want refusal")
}
if err := db.EnsureRepo("dave", t.TempDir()); err != nil {
t.Fatalf("FAIL: EnsureRepo(dave) = %v, want nil", err)
}
}