mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
517 lines
18 KiB
Go
517 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// userFK is one foreign key pointing at users(id), with its ON DELETE action.
|
|
type userFK struct {
|
|
Table string
|
|
Column string
|
|
Action rune // pg_constraint.confdeltype: 'c' cascade, 'n' set null, 'a'/'r' block
|
|
}
|
|
|
|
// userFKs reads every FK referencing users(id) straight from the catalog, so the
|
|
// tests below cover the schema as it actually is rather than a list that rots.
|
|
func userFKs(t *testing.T, st *Store, ctx context.Context) []userFK {
|
|
t.Helper()
|
|
rows, err := st.pool.Query(ctx, `
|
|
SELECT c.conrelid::regclass::text, a.attname, c.confdeltype::text
|
|
FROM pg_constraint c
|
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
|
|
WHERE c.contype = 'f' AND c.confrelid = 'users'::regclass
|
|
ORDER BY 1, 2`)
|
|
if err != nil {
|
|
t.Fatalf("read user FKs: %v", err)
|
|
}
|
|
fks, err := collectRows(rows, func(r pgx.Row) (userFK, error) {
|
|
var f userFK
|
|
var action string
|
|
err := r.Scan(&f.Table, &f.Column, &action)
|
|
if action != "" {
|
|
f.Action = rune(action[0])
|
|
}
|
|
return f, err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("scan user FKs: %v", err)
|
|
}
|
|
return fks
|
|
}
|
|
|
|
// bootstrapAdmin creates the instance's first account, which store.CreateUser
|
|
// automatically promotes to superadmin. Deletion tests call this first so their
|
|
// subject is an ORDINARY user — otherwise the subject is itself the last site
|
|
// admin and every deletion is (correctly) refused.
|
|
func bootstrapAdmin(t *testing.T, st *Store, ctx context.Context) *User {
|
|
t.Helper()
|
|
admin, err := st.CreateUser(ctx, "instanceadmin", "")
|
|
if err != nil {
|
|
t.Fatalf("create bootstrap admin: %v", err)
|
|
}
|
|
if !admin.CapManageUsers {
|
|
t.Fatal("the first account was not promoted to site admin; fixture assumption is stale")
|
|
}
|
|
return admin
|
|
}
|
|
|
|
// populatedDeletionFixture builds an account that has touched as much of the
|
|
// schema as it reasonably can — owned repeater, someone else's shared repeater,
|
|
// passkey, login, auth code, email token, profile link, org membership, console
|
|
// session, command log, maintenance entry, rename history — so the deletion
|
|
// tests are exercising real rows rather than an empty account.
|
|
func populatedDeletionFixture(t *testing.T, st *Store, ctx context.Context) (victim *User, ownRepeater *Repeater) {
|
|
t.Helper()
|
|
bootstrapAdmin(t, st, ctx)
|
|
victim, err := st.CreateUser(ctx, "victim", "Vic Tim")
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
other, err := st.CreateUser(ctx, "bystander", "")
|
|
if err != nil {
|
|
t.Fatalf("create other: %v", err)
|
|
}
|
|
|
|
ownRepeater, err = st.CreateRepeater(ctx, &Repeater{
|
|
OwnerID: victim.ID, Name: "Theirs", PublicKeyHex: strings.Repeat("a", 64),
|
|
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create repeater: %v", err)
|
|
}
|
|
// A repeater owned by someone else, shared with the victim (with a grant):
|
|
// their access goes, the repeater itself must not.
|
|
theirs, err := st.CreateRepeater(ctx, &Repeater{
|
|
OwnerID: other.ID, Name: "Not theirs", PublicKeyHex: strings.Repeat("b", 64),
|
|
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create other repeater: %v", err)
|
|
}
|
|
if _, err := st.AddShare(ctx, theirs.ID, victim.ID); err != nil {
|
|
t.Fatalf("add share: %v", err)
|
|
}
|
|
catalog, err := st.ListCommands(ctx)
|
|
if err != nil || len(catalog) == 0 {
|
|
t.Fatalf("list commands: %v", err)
|
|
}
|
|
if err := st.SetShareCommands(ctx, theirs.ID, victim.ID, []int64{catalog[0].ID}); err != nil {
|
|
t.Fatalf("set share commands: %v", err)
|
|
}
|
|
|
|
if err := st.AddCredential(ctx, victim.ID, []byte("cred-id"), []byte(`{}`), "laptop"); err != nil {
|
|
t.Fatalf("add credential: %v", err)
|
|
}
|
|
loginID, err := st.CreateLogin(ctx, victim.ID)
|
|
if err != nil {
|
|
t.Fatalf("create login: %v", err)
|
|
}
|
|
if _, err := st.CreateAuthCode(ctx, victim.ID, loginID, "/"); err != nil {
|
|
t.Fatalf("create auth code: %v", err)
|
|
}
|
|
if _, err := st.CreateEmailToken(ctx, victim.ID, PurposeResetPassword, "", time.Hour); err != nil {
|
|
t.Fatalf("create email token: %v", err)
|
|
}
|
|
if err := st.ReplaceUserLinks(ctx, victim.ID, []UserLink{{Platform: "web", URL: "https://example.com"}}); err != nil {
|
|
t.Fatalf("replace links: %v", err)
|
|
}
|
|
// A rename, so username_changes already holds rows for this user — including
|
|
// the IP and user agent it records.
|
|
if err := st.SetUsername(ctx, victim.ID, "victim2", UsernameChangeContext{
|
|
ChangedBy: victim.ID, IP: "203.0.113.7", UserAgent: "Mozilla/5.0 (test)",
|
|
}, false); err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
// Console, command and maintenance history against SOMEONE ELSE'S repeater.
|
|
// That's the case worth testing: activity on a node that outlives the account
|
|
// must stay in its owner's audit trail, anonymized. (The same rows on their own
|
|
// repeater would simply cascade away with it, proving nothing about SET NULL.)
|
|
sessID, err := st.StartConsoleSession(ctx, theirs.ID, victim.ID)
|
|
if err != nil {
|
|
t.Fatalf("start console session: %v", err)
|
|
}
|
|
if _, err := st.LogCommand(ctx, theirs.ID, victim.ID, sessID, catalog[0].ID, "ver"); err != nil {
|
|
t.Fatalf("log command: %v", err)
|
|
}
|
|
if err := st.AddMaintenanceEntry(ctx, theirs.ID, victim.ID, "Vic Tim", "swapped antenna", time.Now()); err != nil {
|
|
t.Fatalf("add maintenance: %v", err)
|
|
}
|
|
return victim, ownRepeater
|
|
}
|
|
|
|
// TestDeleteUserLeavesNoReferences is the invariant that matters most for a
|
|
// privacy feature: after deletion, NO row anywhere still references the deleted
|
|
// id. It reads the FK list from the catalog rather than hardcoding tables, so a
|
|
// table added later without a cascade fails here instead of quietly retaining
|
|
// personal data (or blocking deletion outright with a NO ACTION rule).
|
|
func TestDeleteUserLeavesNoReferences(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
victim, _ := populatedDeletionFixture(t, st, ctx)
|
|
|
|
fks := userFKs(t, st, ctx)
|
|
// Guard the guard: if the introspection query ever stops finding anything,
|
|
// this test would pass by checking nothing at all.
|
|
if len(fks) < 15 {
|
|
t.Fatalf("found only %d FKs referencing users; introspection is broken", len(fks))
|
|
}
|
|
for _, fk := range fks {
|
|
if fk.Action != 'c' && fk.Action != 'n' {
|
|
t.Errorf("%s.%s references users with ON DELETE %q: deletion would be blocked, "+
|
|
"not cascaded — every reference to a user must cascade or null out",
|
|
fk.Table, fk.Column, string(fk.Action))
|
|
}
|
|
}
|
|
|
|
if err := st.DeleteUser(ctx, victim.ID); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
|
|
for _, fk := range fks {
|
|
var n int
|
|
q := "SELECT count(*) FROM " + fk.Table + " WHERE " + fk.Column + " = $1" //nolint:gosec // identifiers come from the catalog, not user input
|
|
if err := st.pool.QueryRow(ctx, q, victim.ID).Scan(&n); err != nil {
|
|
t.Fatalf("count %s.%s: %v", fk.Table, fk.Column, err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("%s.%s still has %d row(s) referencing the deleted user", fk.Table, fk.Column, n)
|
|
}
|
|
}
|
|
if _, err := st.GetUserByID(ctx, victim.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("GetUserByID after delete = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// TestDeleteUserKeepsAnonymizedHistory: the operational record of what was done
|
|
// to a repeater must outlive the account that did it (migration 0020's promise),
|
|
// and other people's repeaters must survive their access being deleted.
|
|
func TestDeleteUserKeepsAnonymizedHistory(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
victim, ownRepeater := populatedDeletionFixture(t, st, ctx)
|
|
|
|
// The victim's own repeater goes; the one merely shared with them stays.
|
|
var otherRepeaters int
|
|
if err := st.pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM repeaters WHERE owner_id <> $1`, victim.ID).Scan(&otherRepeaters); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := st.DeleteUser(ctx, victim.ID); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
|
|
var owned int
|
|
if err := st.pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM repeaters WHERE public_id = $1`, ownRepeater.PublicID).Scan(&owned); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if owned != 0 {
|
|
t.Fatal("the deleted user's own repeater survived")
|
|
}
|
|
var left int
|
|
if err := st.pool.QueryRow(ctx, `SELECT count(*) FROM repeaters`).Scan(&left); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if left != otherRepeaters {
|
|
t.Fatalf("repeaters left = %d, want %d (other people's must survive)", left, otherRepeaters)
|
|
}
|
|
|
|
// The command log keeps its write-time username snapshot, with the user nulled.
|
|
var sender *string
|
|
var uid *int64
|
|
if err := st.pool.QueryRow(ctx,
|
|
`SELECT sender_username, user_id FROM command_log LIMIT 1`).Scan(&sender, &uid); err != nil {
|
|
t.Fatalf("read command log: %v", err)
|
|
}
|
|
if uid != nil {
|
|
t.Fatal("command_log.user_id was not nulled")
|
|
}
|
|
if sender == nil || *sender == "" {
|
|
t.Fatal("command_log lost its sender_username snapshot; the audit trail is now anonymous AND empty")
|
|
}
|
|
|
|
// Same for the maintenance history on the surviving repeater: the entry stays,
|
|
// attributed to the write-time name rather than to nobody.
|
|
entries, err := st.ListMaintenance(ctx, theirsID(t, st, ctx))
|
|
if err != nil {
|
|
t.Fatalf("list maintenance: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("maintenance entries = %d, want 1 (the entry outlives its author)", len(entries))
|
|
}
|
|
if entries[0].AuthorID != nil {
|
|
t.Fatal("maintenance author_id was not nulled")
|
|
}
|
|
if entries[0].AuthorName != "Vic Tim" {
|
|
t.Fatalf("maintenance author name = %q, want the write-time snapshot", entries[0].AuthorName)
|
|
}
|
|
}
|
|
|
|
// theirsID returns the id of the surviving (other owner's) repeater in the
|
|
// deletion fixture.
|
|
func theirsID(t *testing.T, st *Store, ctx context.Context) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
if err := st.pool.QueryRow(ctx,
|
|
`SELECT id FROM repeaters WHERE name = 'Not theirs'`).Scan(&id); err != nil {
|
|
t.Fatalf("find surviving repeater: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// TestDeleteUserScrubsRenameHistory: the rename audit trail SURVIVES deletion
|
|
// (the username cooldown is built on it), so the personal data inside it has to
|
|
// be erased by hand — the FK only nulls user_id. An IP address left behind here
|
|
// would outlive the account forever, which is both a broken promise and the kind
|
|
// of thing a privacy policy must not have to be vague about.
|
|
func TestDeleteUserScrubsRenameHistory(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
victim, _ := populatedDeletionFixture(t, st, ctx)
|
|
|
|
// Precondition: the rename really did record an IP and user agent.
|
|
var before int
|
|
if err := st.pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM username_changes WHERE ip IS NOT NULL OR user_agent IS NOT NULL`).Scan(&before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before == 0 {
|
|
t.Fatal("fixture recorded no IP/user agent; this test would prove nothing")
|
|
}
|
|
|
|
if err := st.DeleteUser(ctx, victim.ID); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
|
|
var leaked int
|
|
if err := st.pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM username_changes WHERE ip IS NOT NULL OR user_agent IS NOT NULL`).Scan(&leaked); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if leaked != 0 {
|
|
t.Errorf("%d rename row(s) still hold an IP or user agent after the account was deleted", leaked)
|
|
}
|
|
// The rows themselves must remain, or the username cooldown has nothing to
|
|
// reserve against.
|
|
var rows int
|
|
if err := st.pool.QueryRow(ctx, `SELECT count(*) FROM username_changes`).Scan(&rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows == 0 {
|
|
t.Fatal("the rename history was deleted outright; the username cooldown depends on it")
|
|
}
|
|
}
|
|
|
|
// TestDeleteUserReservesUsername: a freed handle can't be claimed immediately.
|
|
// Public profiles live at /u/{username} and @handles are quoted in logs and
|
|
// maintenance notes, so an instantly-reusable name would let someone inherit a
|
|
// departed user's history.
|
|
func TestDeleteUserReservesUsername(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
bootstrapAdmin(t, st, ctx)
|
|
u, err := st.CreateUser(ctx, "departed", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.DeleteUser(ctx, u.ID); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
|
|
// Nobody may take the freed name — not a new signup...
|
|
if _, err := st.CreateUser(ctx, "departed", ""); !errors.Is(err, ErrUsernameReserved) {
|
|
t.Fatalf("CreateUser on a freed name = %v, want ErrUsernameReserved", err)
|
|
}
|
|
// ...nor an existing account renaming into it.
|
|
other, err := st.CreateUser(ctx, "opportunist", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = st.SetUsername(ctx, other.ID, "departed", UsernameChangeContext{ChangedBy: other.ID}, false)
|
|
if !errors.Is(err, ErrUsernameReserved) {
|
|
t.Fatalf("rename into a freed name = %v, want ErrUsernameReserved", err)
|
|
}
|
|
}
|
|
|
|
// TestDeleteUserSiteAdminGuard: the last administrator can't delete themselves
|
|
// out of the instance, but one of two can.
|
|
func TestDeleteUserSiteAdminGuard(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
first, err := st.CreateUser(ctx, "admin1", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetCapabilities(ctx, first.ID, true, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.DeleteUser(ctx, first.ID); !errors.Is(err, ErrLastSiteAdmin) {
|
|
t.Fatalf("deleting the only admin = %v, want ErrLastSiteAdmin", err)
|
|
}
|
|
|
|
second, err := st.CreateUser(ctx, "admin2", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetCapabilities(ctx, second.ID, true, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.DeleteUser(ctx, first.ID); err != nil {
|
|
t.Fatalf("deleting one of two admins: %v", err)
|
|
}
|
|
// And now the remaining one is the last, so they're stuck too.
|
|
if err := st.DeleteUser(ctx, second.ID); !errors.Is(err, ErrLastSiteAdmin) {
|
|
t.Fatalf("deleting the now-last admin = %v, want ErrLastSiteAdmin", err)
|
|
}
|
|
}
|
|
|
|
// TestDeleteUserOrgRules covers all three org outcomes: an org with other members
|
|
// but no other admin BLOCKS deletion; a solo org is deleted with the account; an
|
|
// org with another admin simply loses a member and survives intact.
|
|
func TestDeleteUserOrgRules(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
bootstrapAdmin(t, st, ctx)
|
|
leaver, err := st.CreateUser(ctx, "leaver", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
member, err := st.CreateUser(ctx, "member", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// (a) sole admin, other members present → blocked.
|
|
shared, err := st.CreateOrg(ctx, "Shared Club", leaver.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.AddOrgMember(ctx, shared.ID, member.ID, "member"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// (b) solo org → goes with the account.
|
|
solo, err := st.CreateOrg(ctx, "Solo Org", leaver.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := st.DeleteUser(ctx, leaver.ID); !errors.Is(err, ErrSoleOrgAdmin) {
|
|
t.Fatalf("deleting the sole admin of a populated org = %v, want ErrSoleOrgAdmin", err)
|
|
}
|
|
// Nothing was half-applied by the refused deletion.
|
|
if _, err := st.GetOrg(ctx, solo.ID); err != nil {
|
|
t.Fatalf("solo org was deleted by a refused account deletion: %v", err)
|
|
}
|
|
if _, err := st.GetUserByID(ctx, leaver.ID); err != nil {
|
|
t.Fatalf("user was deleted despite the block: %v", err)
|
|
}
|
|
|
|
// Promoting someone else clears the block.
|
|
if err := st.SetOrgMemberRole(ctx, shared.ID, member.ID, "admin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.DeleteUser(ctx, leaver.ID); err != nil {
|
|
t.Fatalf("DeleteUser after promoting a second admin: %v", err)
|
|
}
|
|
|
|
// (c) the shared org survives, minus the leaver; the solo org is gone.
|
|
if _, err := st.GetOrg(ctx, shared.ID); err != nil {
|
|
t.Fatalf("shared org did not survive: %v", err)
|
|
}
|
|
members, err := st.ListOrgMembers(ctx, shared.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(members) != 1 || members[0].UserID != member.ID {
|
|
t.Fatalf("shared org members = %+v, want just the promoted member", members)
|
|
}
|
|
if _, err := st.GetOrg(ctx, solo.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("solo org = %v, want ErrNotFound (deleted with its only member)", err)
|
|
}
|
|
}
|
|
|
|
// TestPreviewUserDeletion: the confirm page's numbers must match what deletion
|
|
// actually does — including the steward count that decides whether a repeater
|
|
// can be handed over instead of destroyed.
|
|
func TestPreviewUserDeletion(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
victim, ownRepeater := populatedDeletionFixture(t, st, ctx)
|
|
|
|
// Give the owned repeater a steward, so the preview can offer a transfer.
|
|
successor, err := st.CreateUser(ctx, "successor", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.AddShare(ctx, ownRepeater.ID, successor.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetShareSteward(ctx, ownRepeater.ID, successor.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
solo, err := st.CreateOrg(ctx, "Solo", victim.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
p, err := st.PreviewUserDeletion(ctx, victim.ID)
|
|
if err != nil {
|
|
t.Fatalf("PreviewUserDeletion: %v", err)
|
|
}
|
|
if p.Blocked() {
|
|
t.Fatalf("preview reports blocked, want deletable: %+v", p)
|
|
}
|
|
if len(p.Repeaters) != 1 || p.Repeaters[0].PublicID != ownRepeater.PublicID {
|
|
t.Fatalf("preview repeaters = %+v, want the one they own", p.Repeaters)
|
|
}
|
|
if p.Repeaters[0].Stewards != 1 {
|
|
t.Fatalf("preview steward count = %d, want 1 (a transfer is possible)", p.Repeaters[0].Stewards)
|
|
}
|
|
if len(p.OrgsDeleted) != 1 || p.OrgsDeleted[0].ID != solo.ID {
|
|
t.Fatalf("preview OrgsDeleted = %+v, want the solo org", p.OrgsDeleted)
|
|
}
|
|
if p.SharedWithUser != 1 {
|
|
t.Fatalf("preview SharedWithUser = %d, want 1", p.SharedWithUser)
|
|
}
|
|
if p.Passkeys != 1 {
|
|
t.Fatalf("preview Passkeys = %d, want 1", p.Passkeys)
|
|
}
|
|
|
|
// A blocked account reports the blocking org rather than a clean bill.
|
|
blocker, err := st.CreateUser(ctx, "blocked", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
club, err := st.CreateOrg(ctx, "Club", blocker.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.AddOrgMember(ctx, club.ID, victim.ID, "member"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bp, err := st.PreviewUserDeletion(ctx, blocker.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bp.Blocked() || len(bp.OrgsBlocked) != 1 || bp.OrgsBlocked[0].ID != club.ID {
|
|
t.Fatalf("preview for a sole admin = %+v, want blocked on the club", bp)
|
|
}
|
|
}
|
|
|
|
// TestDeleteUserMissing: deleting an already-deleted account is ErrNotFound, not
|
|
// a silent success.
|
|
func TestDeleteUserMissing(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := orgTestStore(t)
|
|
if err := st.DeleteUser(ctx, 999999); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("DeleteUser(missing) = %v, want ErrNotFound", err)
|
|
}
|
|
}
|