package spindle
import (
"context"
"database/sql"
"errors"
"slices"
"sync/atomic"
"testing"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/spindle/db"
"tangled.org/core/spindle/secrets"
)
type flakyVault struct {
secrets.Manager
fails atomic.Int32
}
func (f *flakyVault) RemoveAllSecrets(ctx context.Context, repo secrets.RepoIdentifier) error {
if f.fails.Add(-1) >= 0 {
return errors.New("vault is down")
}
return f.Manager.RemoveAllSecrets(ctx, repo)
}
func wantWipeFinished(t *testing.T, s *Spindle, repo syntax.DID) {
t.Helper()
if _, err := s.db.GetWipe(repo); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("the wipe didn't finish: %v", err)
}
if got := secretCount(t, s, repo); got != 0 {
t.Fatalf("secrets = %d after the wipe, want none", got)
}
}
func TestAFailedWipeStepIsRetriedUntilItIsDone(t *testing.T) {
s := newWipeTestSpindle(t)
ctx := context.Background()
repoDid, owner := syntax.DID("did:plc:repo1"), syntax.DID("did:plc:owner1")
seedWipeRepo(t, s, repoDid, owner)
vault := &flakyVault{Manager: s.vault}
vault.fails.Store(1)
s.vault = vault
if err := s.WipeRepo(ctx, repoDid, "test"); err == nil {
t.Fatal("a wipe whose secrets step failed reported success")
}
w, err := s.db.GetWipe(repoDid)
if err != nil {
t.Fatalf("no wipe left to retry: %v", err)
}
if !slices.Equal(w.Steps, []string{"secrets"}) || w.Attempts != 1 || !w.Due.After(time.Now()) {
t.Fatalf("wipe = %+v, want only the secrets step left, one failure and a backoff", w)
}
if rows := repoRows(t, s, repoDid); len(rows) != 0 {
t.Fatalf("the repo is still served mid-wipe: %+v", rows)
}
if got := secretCount(t, s, repoDid); got != 1 {
t.Fatalf("secrets = %d before the retry, want them still there", got)
}
s.retries.now = func() time.Time { return time.Now().Add(2 * time.Hour) }
s.runDueWipe(ctx, repoDid)
wantWipeFinished(t, s, repoDid)
}
func TestAWipeThatWasClaimedFinishesAfterARestart(t *testing.T) {
s := newWipeTestSpindle(t)
repoDid, owner := syntax.DID("did:plc:repo1"), syntax.DID("did:plc:owner1")
seedWipeRepo(t, s, repoDid, owner)
if _, err := s.claimWipeIfRows(context.Background(), repoDid, "test"); err != nil {
t.Fatal(err)
}
s.kickDueRepos()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
did, ok := s.retries.jobs.next(ctx)
if !ok || did != repoDid {
t.Fatalf("the restart queued %q, %v, want the repo with the unfinished wipe", did, ok)
}
s.runRepoJob(context.Background(), did)
wantWipeFinished(t, s, repoDid)
}
func TestARepoWithAWipeRowCantBeRegisteredAgainToResurrectItsData(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
cleanup, err := env.s.claimWipeIfRows(context.Background(), testRepoDid, "test")
if err != nil || cleanup == nil {
t.Fatalf("claim left no cleanup to run, err %v", err)
}
// the knot still names this spindle, but the unfinished wipe keeps the repo out
repo, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid)
if err != nil || repo != nil {
t.Fatalf("a refresh with a wipe unfinished = %+v, %v, want the repo left unserved", repo, err)
}
if err := cleanup(); err != nil {
t.Fatal(err)
}
if env.secrets(t) != 0 {
t.Fatal("the wipe left the repo's secrets")
}
if repo := env.ensure(t); repo == nil {
t.Fatal("a refresh after the wipe is done can't register the repo")
}
if env.secrets(t) != 0 {
t.Fatal("registering the repo again brought its secrets back")
}
}
func TestABanTakesTheReposOfItsSubjectThroughTheWipeTable(t *testing.T) {
s := newWipeTestSpindle(t)
repoDid, owner := syntax.DID("did:plc:repo1"), syntax.DID("did:plc:owner1")
seedWipeRepo(t, s, repoDid, owner)
if err := s.db.PutBan(db.BanEntry{SubjectDid: owner}); err != nil {
t.Fatal(err)
}
if err := s.db.PutBan(db.BanEntry{SubjectDid: "did:plc:ownsnothing"}); err != nil {
t.Fatal(err)
}
s.resumeWipes(context.Background())
if rows := repoRows(t, s, repoDid); len(rows) != 0 {
t.Fatalf("a banned owner's repo is still served: %+v", rows)
}
w, err := s.db.GetWipe(repoDid)
if err != nil || w.Reason != "banned" {
t.Fatalf("wipe = %+v, %v, want one for the repo with the ban as its reason", w, err)
}
if exists, err := s.db.WipeExists("did:plc:ownsnothing"); err != nil || exists {
t.Fatalf("a ban for a did with no repos made a wipe, exists %v, %v", exists, err)
}
s.runRepoJob(context.Background(), repoDid)
wantWipeFinished(t, s, repoDid)
}