package spindle
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"testing"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/api/org_tangled"
"tangled.org/core/idresolver"
"tangled.org/core/rbac"
"tangled.org/core/spindle/config"
"tangled.org/core/spindle/db"
"tangled.org/core/spindle/knotclient"
"tangled.org/core/spindle/webhook"
)
const (
testOwner = syntax.DID("did:plc:owner")
testRepoDid = syntax.DID("did:plc:repo")
testBob = syntax.DID("did:plc:bob")
testCarol = syntax.DID("did:plc:carol")
testDave = syntax.DID("did:plc:dave")
)
type fakeKnot struct {
srv *httptest.Server
mu sync.Mutex
records map[string]any
requests int
failing bool
// a proxy in front answers a bare 404, which isn't the repo being gone
bare404 bool
// answers 500 for this collection only, the knot also plays the owner's pds
failCollection string
// runs before the lock, otherwise a stalled hook would block the other requests
beforeAnswer func(r *http.Request)
failVersions bool
failStatus bool
// who wrote each version of a record, oldest first, keyed like records
editors map[string][]syntax.DID
bodies map[string][]map[string]any
deleted map[syntax.DID]bool
}
func newFakeKnot(t *testing.T) *fakeKnot {
t.Helper()
return newFakeKnotBehind(t, nil)
}
func newFakeKnotBehind(t *testing.T, wrap func(http.Handler) http.Handler) *fakeKnot {
t.Helper()
k := &fakeKnot{records: map[string]any{}}
var handler http.Handler = http.HandlerFunc(k.serve)
if wrap != nil {
handler = wrap(handler)
}
k.srv = httptest.NewServer(handler)
t.Cleanup(k.srv.Close)
return k
}
func (k *fakeKnot) host() string {
u, _ := url.Parse(k.srv.URL)
return u.Host
}
func (k *fakeKnot) put(repo syntax.DID, collection, rkey string, value any) {
k.mu.Lock()
defer k.mu.Unlock()
k.records[repo.String()+"|"+collection+"|"+rkey] = value
}
// like the knot, stamps the editor into the record and remembers every version's
func (k *fakeKnot) putVersion(repo syntax.DID, collection, rkey string, editor syntax.DID, value map[string]any) {
value["x-tngl-editor"] = editor.String()
key := repo.String() + "|" + collection + "|" + rkey
k.mu.Lock()
defer k.mu.Unlock()
if k.editors == nil {
k.editors = map[string][]syntax.DID{}
k.bodies = map[string][]map[string]any{}
}
k.records[key] = value
k.editors[key] = append(k.editors[key], editor)
body := make(map[string]any, len(value))
for name, v := range value {
body[name] = v
}
k.bodies[key] = append(k.bodies[key], body)
}
func (k *fakeKnot) erase(repo syntax.DID, collection, rkey string) {
k.mu.Lock()
defer k.mu.Unlock()
delete(k.records, repo.String()+"|"+collection+"|"+rkey)
}
// like knot2, keeps saying a deleted repo was deleted
func (k *fakeKnot) deleteRepo(repo syntax.DID) {
k.mu.Lock()
defer k.mu.Unlock()
for key := range k.records {
if strings.HasPrefix(key, repo.String()+"|") {
delete(k.records, key)
}
}
if k.deleted == nil {
k.deleted = map[syntax.DID]bool{}
}
k.deleted[repo] = true
}
func (k *fakeKnot) declare(owner syntax.DID, rkey string, repo syntax.DID) {
k.put(owner, org_tangled.RepoDeclarationNSID, rkey, org_tangled.RepoDeclaration{LexiconTypeID: org_tangled.RepoDeclarationNSID, Repo: repo.String()})
}
func (k *fakeKnot) undeclare(owner syntax.DID, rkey string) {
k.erase(owner, org_tangled.RepoDeclarationNSID, rkey)
}
func (k *fakeKnot) count() int {
k.mu.Lock()
defer k.mu.Unlock()
return k.requests
}
func (k *fakeKnot) serve(w http.ResponseWriter, r *http.Request) {
if k.beforeAnswer != nil {
k.beforeAnswer(r)
}
k.mu.Lock()
defer k.mu.Unlock()
k.requests++
if k.failing {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
if k.bare404 {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if k.failCollection != "" && q.Get("collection") == k.failCollection {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
prefix := q.Get("repo") + "|" + q.Get("collection") + "|"
switch strings.TrimPrefix(r.URL.Path, "/xrpc/") {
case "com.atproto.repo.getRecord":
value, ok := k.records[prefix+q.Get("rkey")]
if at, found := strings.CutPrefix(q.Get("cid"), "bafyv"); found && ok {
i, err := strconv.Atoi(at)
bodies := k.bodies[prefix+q.Get("rkey")]
if err != nil || i < 0 || i >= len(bodies) {
ok = false
} else {
value = bodies[i]
}
}
if !ok {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "RecordNotFound"})
return
}
json.NewEncoder(w).Encode(map[string]any{"cid": "bafy", "value": value})
case "com.atproto.repo.listRecords":
var rkeys []string
for key := range k.records {
if rkey, ok := strings.CutPrefix(key, prefix); ok {
rkeys = append(rkeys, rkey)
}
}
sort.Strings(rkeys)
// the cursor is just an offset, and a client that sends no limit gets everything
start, _ := strconv.Atoi(q.Get("cursor"))
end := len(rkeys)
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
end = min(start+limit, end)
}
var records []map[string]any
for _, rkey := range rkeys[min(start, end):end] {
records = append(records, map[string]any{"uri": "at://" + q.Get("repo") + "/" + q.Get("collection") + "/" + rkey, "value": k.records[prefix+rkey]})
}
res := map[string]any{"records": records}
if end < len(rkeys) {
res["cursor"] = strconv.Itoa(end)
}
json.NewEncoder(w).Encode(res)
case "com.atproto.sync.getRepoStatus":
if k.failStatus {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
did := q.Get("did")
if k.deleted[syntax.DID(did)] {
json.NewEncoder(w).Encode(map[string]any{"did": did, "active": false, "status": "deleted"})
return
}
for key := range k.records {
if strings.HasPrefix(key, did+"|") {
json.NewEncoder(w).Encode(map[string]any{"did": did, "active": true})
return
}
}
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "RepoNotFound"})
case "org.tangled.repo.listRecordVersions":
if k.failVersions {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
uri := q.Get("uri")
editors, ok := k.editors[strings.ReplaceAll(strings.TrimPrefix(uri, "at://"), "/", "|")]
if !ok {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "RecordNotFound"})
return
}
var versions []map[string]string
for i, editor := range editors {
versions = append(versions, map[string]string{"changeId": fmt.Sprint("change", i), "cid": fmt.Sprint("bafyv", i), "editor": editor.String(), "createdAt": "2026-01-01T00:00:00Z"})
}
json.NewEncoder(w).Encode(map[string]any{"uri": uri, "versions": versions})
default:
http.NotFound(w, r)
}
}
type ensureEnv struct {
s *Spindle
knot *fakeKnot
k knotclient.Knot
}
func newEnsureEnv(t *testing.T) *ensureEnv {
t.Helper()
s := newWipeTestSpindle(t)
s.wh = webhook.New(s.db, false)
s.cfg.Server.Admission = config.AdmissionOpen
env := &ensureEnv{s: s, knot: newFakeKnot(t)}
env.k = knotclient.Knot{Did: syntax.DID("did:web:" + env.knot.host()), Host: env.knot.host()}
dir := identity.NewMockDirectory()
dir.Insert(identity.Identity{
DID: testRepoDid,
Services: map[string]identity.ServiceEndpoint{
"tangled_knot": {Type: "TangledKnot", URL: env.knot.srv.URL},
},
})
s.knots = knotclient.New(dir, http.DefaultClient, true)
for _, owner := range []syntax.DID{testOwner, testNewOwner} {
dir.Insert(identity.Identity{
DID: owner,
Services: map[string]identity.ServiceEndpoint{
"atproto_pds": {Type: "AtprotoPersonalDataServer", URL: env.knot.srv.URL},
},
})
}
s.res = idresolver.NewMockResolver(dir)
return env
}
func (e *ensureEnv) spindleDid() string {
return e.s.cfg.Server.Did().String()
}
func (e *ensureEnv) serve(name string, collaborators ...syntax.DID) {
e.manifest(name, e.spindleDid(), collaborators...)
e.knot.declare(testOwner, strings.ToLower(name), testRepoDid)
}
func (e *ensureEnv) manifest(name, spindle string, collaborators ...syntax.DID) {
e.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testOwner, name, spindle))
for _, c := range collaborators {
e.knot.put(testRepoDid, org_tangled.GraphCollaboratorNSID, c.String(), map[string]string{"createdAt": "2026-01-01T00:00:00Z"})
}
}
func (e *ensureEnv) ensure(t *testing.T) *db.Repo {
t.Helper()
repo, err := e.s.ensureRepo(context.Background(), e.k, testRepoDid)
if err != nil {
t.Fatalf("ensureRepo: %v", err)
}
return repo
}
func (e *ensureEnv) rows(t *testing.T) []db.Repo { return repoRows(t, e.s, testRepoDid) }
func (e *ensureEnv) wantNoRows(t *testing.T) {
t.Helper()
if rows := e.rows(t); len(rows) != 0 {
t.Fatalf("rows = %+v", rows)
}
}
func (e *ensureEnv) banOwner(t *testing.T) {
t.Helper()
if err := e.s.db.PutBan(db.BanEntry{SubjectDid: testOwner}); err != nil {
t.Fatal(err)
}
}
func (e *ensureEnv) collaborator(t *testing.T, who syntax.DID) bool {
return isCollaborator(t, e.s, testRepoDid, who)
}
func (e *ensureEnv) secrets(t *testing.T) int { return secretCount(t, e.s, testRepoDid) }
func (e *ensureEnv) addSecret(t *testing.T) { addTestSecret(t, e.s, testRepoDid) }
func TestEnsureRepoServesAManifestNamingThisSpindle(t *testing.T) {
env := newEnsureEnv(t)
env.serve("Test-Repo", testBob)
repo := env.ensure(t)
if repo == nil {
t.Fatal("repo not served")
}
if repo.Owner != testOwner || repo.Rkey != "test-repo" || repo.Name != "Test-Repo" || repo.Knot != env.knot.host() {
t.Fatalf("repo = %+v", repo)
}
rows := env.rows(t)
if len(rows) != 1 || rows[0].Owner != testOwner || rows[0].Rkey != "test-repo" {
t.Fatalf("rows = %+v", rows)
}
if ok, _ := env.s.e.IsRepoOwner(testOwner.String(), rbac.ThisServer, testRepoDid.String()); !ok {
t.Error("owner has no repo policy")
}
if !env.collaborator(t, testBob) {
t.Error("bob is not a collaborator")
}
}
func TestEnsureRepoNotServedWhenTheManifestIsForSomeoneElse(t *testing.T) {
tests := map[string]string{
"another spindle": "did:web:other.example.com",
"no spindle": "",
}
for name, spindle := range tests {
t.Run(name, func(t *testing.T) {
env := newEnsureEnv(t)
env.manifest("repo", spindle)
env.knot.declare(testOwner, "repo", testRepoDid)
if repo := env.ensure(t); repo != nil {
t.Fatalf("repo = %+v, want it not served", repo)
}
env.wantNoRows(t)
})
}
}
func TestEnsureRepoForgetsARepoWithAMalformedManifestIdentity(t *testing.T) {
tests := map[string]func(*org_tangled.RepoManifest){
"no declaration": func(m *org_tangled.RepoManifest) { m.Declaration = nil },
"malformed owner": func(m *org_tangled.RepoManifest) { m.Declaration.Owner = "not a did" },
"malformed name": func(m *org_tangled.RepoManifest) { m.Declaration.Name = "bad/name" },
}
for name, corrupt := range tests {
t.Run(name, func(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
m := manifestRecord(testOwner, "repo", env.spindleDid())
corrupt(&m)
env.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, m)
if repo := env.ensure(t); repo != nil {
t.Fatalf("repo = %+v, want it not served", repo)
}
env.wantNoRows(t)
if env.secrets(t) != 1 {
t.Error("a bad manifest dropped the secrets")
}
})
}
}
func TestEnsureRepoForgetsARepoWhoseDeclarationPointsElsewhere(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
env.knot.declare(testOwner, "repo", "did:plc:otherrepo")
if env.ensure(t) != nil {
t.Fatal("repo served with a declaration pointing at another repo")
}
env.wantNoRows(t)
if env.secrets(t) != 1 {
t.Error("a hijacked declaration dropped the secrets")
}
}
func TestEnsureRepoKeepsAKnownRepoWhoseDeclarationIsMissing(t *testing.T) {
env := newEnsureEnv(t)
env.serve("old-name")
env.ensure(t)
env.addSecret(t)
// manifest first, the new name can't be served until the declaration lands
env.manifest("New-Name", env.spindleDid())
if env.ensure(t) != nil {
t.Fatal("a repo with no declaration was served under its new name")
}
rows := env.rows(t)
if len(rows) != 1 || rows[0].Rkey != "old-name" || env.secrets(t) != 1 {
t.Fatalf("rows = %+v, secrets = %d, want the old row untouched", rows, env.secrets(t))
}
env.knot.declare(testOwner, "new-name", testRepoDid)
env.ensure(t)
if rows := env.rows(t); len(rows) != 1 || rows[0].Rkey != "new-name" {
t.Fatalf("rows = %+v", rows)
}
}
func TestEnsureRepoReadsTheDeclarationOffTheLiarPdsWhenTheOwnersPdsHasNone(t *testing.T) {
env := newEnsureEnv(t)
env.manifest("repo", env.spindleDid())
liar := newFakeKnot(t)
liar.declare(testOwner, "repo", testRepoDid)
if env.ensure(t) != nil {
t.Fatal("served with no declaration on the owner's pds and no liar pds set")
}
env.s.cfg.Server.LiarPds = liar.srv.URL
if repo := env.ensure(t); repo == nil || repo.Owner != testOwner {
t.Fatalf("repo = %+v, want it served from the liar pds declaration", repo)
}
}
func TestEnsureRepoTrustsTheOwnersPdsOverTheLiarPds(t *testing.T) {
env := newEnsureEnv(t)
env.manifest("repo", env.spindleDid())
env.knot.declare(testOwner, "repo", "did:plc:otherrepo")
liar := newFakeKnot(t)
liar.declare(testOwner, "repo", testRepoDid)
env.s.cfg.Server.LiarPds = liar.srv.URL
if env.ensure(t) != nil {
t.Fatal("the liar pds outvoted the owner's own declaration")
}
env.wantNoRows(t)
}
func TestEnsureRepoDeclarationErrorsAreNotAnAnswer(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.knot.failCollection = org_tangled.RepoDeclarationNSID
if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
t.Fatal("ensureRepo swallowed a pds failure")
}
if rows := env.rows(t); len(rows) != 1 {
t.Fatalf("a pds outage dropped the repo, rows = %+v", rows)
}
}
func TestEnsureRepoSyncsCollaborators(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob, testCarol)
env.ensure(t)
if !env.collaborator(t, testBob) || !env.collaborator(t, testCarol) {
t.Fatal("collaborators not added")
}
env.knot.erase(testRepoDid, org_tangled.GraphCollaboratorNSID, testCarol.String())
env.manifest("repo", env.spindleDid(), testDave)
env.ensure(t)
if !env.collaborator(t, testBob) || !env.collaborator(t, testDave) {
t.Error("bob and dave should be collaborators")
}
if env.collaborator(t, testCarol) {
t.Error("carol kept her access after the knot dropped her")
}
rows, err := env.s.db.ListCollaboratorsByRepoDid(testRepoDid)
if err != nil || len(rows) != 2 {
t.Fatalf("collaborator rows = %+v, %v", rows, err)
}
}
func TestEnsureRepoForgetsARepoThatMoved(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob)
env.ensure(t)
if len(env.rows(t)) != 1 {
t.Fatal("repo not registered")
}
env.addSecret(t)
env.manifest("repo", "did:web:other.example.com", testBob)
if env.ensure(t) != nil {
t.Fatal("moved repo still served")
}
env.wantNoRows(t)
if env.collaborator(t, testBob) {
t.Error("collaborator acl outlived the repo")
}
if ok, _ := env.s.e.IsRepoOwner(testOwner.String(), rbac.ThisServer, testRepoDid.String()); ok {
t.Error("owner acl outlived the repo")
}
if env.secrets(t) != 1 {
t.Fatal("moving to another spindle dropped the secrets")
}
env.serve("repo", testBob)
if env.ensure(t) == nil {
t.Fatal("repo moved back but isn't served")
}
if env.secrets(t) != 1 {
t.Error("secrets are gone after moving back")
}
if !env.collaborator(t, testBob) {
t.Error("collaborator not restored after moving back")
}
}
func TestEnsureRepoWipesARepoDeletedOnItsKnot(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
env.knot.deleteRepo(testRepoDid)
if env.ensure(t) != nil {
t.Fatal("repo served without a manifest")
}
env.wantNoRows(t)
if env.secrets(t) != 0 {
t.Error("secrets of a repo the knot says is deleted were kept")
}
}
func TestEnsureRepoKeepsTheSecretsOfARepoWhoseManifestIsMissing(t *testing.T) {
tests := map[string]func(*fakeKnot){
"the knot still has the repo": func(k *fakeKnot) {
k.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
},
"the knot never heard of the repo": func(k *fakeKnot) {
k.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
k.erase(testRepoDid, org_tangled.GraphCollaboratorNSID, testBob.String())
},
}
for name, lose := range tests {
t.Run(name, func(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob)
env.ensure(t)
env.addSecret(t)
lose(env.knot)
if env.ensure(t) != nil {
t.Fatal("repo served without a manifest")
}
env.wantNoRows(t)
if env.secrets(t) != 1 {
t.Error("a missing manifest dropped the secrets of a repo the knot didn't say was deleted")
}
env.serve("repo", testBob)
if repo := env.ensure(t); repo == nil {
t.Fatal("repo not served again once its manifest came back")
}
if env.secrets(t) != 1 {
t.Error("secrets gone after the repo came back")
}
})
}
}
func TestEnsureRepoKeepsTheRepoWhenTheKnotCantSayWhetherItWasDeleted(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
env.knot.failStatus = true
if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
t.Fatal("a failing repo status was read as an answer")
}
if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
t.Fatalf("a failing repo status changed the repo, rows = %+v", rows)
}
}
func TestEnsureRepoReadsTheManifestOffTheFosterKnotWhenTheRepoKnotHasNone(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
foster := newFakeKnot(t)
foster.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testOwner, "repo", env.spindleDid()))
foster.put(testRepoDid, org_tangled.GraphCollaboratorNSID, testBob.String(), map[string]string{"createdAt": "2026-01-01T00:00:00Z"})
env.s.cfg.Server.FosterKnot = foster.srv.URL
env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
repo := env.ensure(t)
if repo == nil || repo.Knot != env.knot.host() {
t.Fatalf("repo = %+v, want it served from the foster knot's manifest", repo)
}
if env.secrets(t) != 1 {
t.Error("a manifest on the foster knot didn't keep the secrets")
}
if !env.collaborator(t, testBob) {
t.Error("collaborators weren't read off the foster knot")
}
}
func TestEnsureRepoWipesARepoDeletedOnItsKnotThatTheFosterKnotDoesntHave(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
env.s.cfg.Server.FosterKnot = newFakeKnot(t).srv.URL
env.knot.deleteRepo(testRepoDid)
if env.ensure(t) != nil {
t.Fatal("repo served without a manifest")
}
env.wantNoRows(t)
if env.secrets(t) != 0 {
t.Error("secrets of a repo deleted on its knot were kept")
}
}
func TestEnsureRepoKeepsTheRepoWhenTheFosterKnotIsDown(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
foster := newFakeKnot(t)
foster.failing = true
env.s.cfg.Server.FosterKnot = foster.srv.URL
env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
t.Fatal("a failing foster knot was read as an answer")
}
if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
t.Fatalf("a failing foster knot wiped the repo, rows = %+v", rows)
}
}
func TestEnsureRepoKeepsARepoWhenAnotherKnotLosesTheManifest(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
if err := env.s.db.AddRepo(db.Repo{Knot: "old-knot.example", Owner: testOwner, Rkey: "repo", RepoDid: testRepoDid}); err != nil {
t.Fatal(err)
}
env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, "self")
env.ensure(t)
if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
t.Fatalf("a new knot without the repo wiped it, rows = %+v", rows)
}
}
func TestEnsureRepoKeepsAnOldRowWhoseKnotAnswersABare404(t *testing.T) {
env := newEnsureEnv(t)
if err := env.s.db.AddRepo(db.Repo{Knot: env.knot.host(), Owner: testOwner, Rkey: "repo", RepoDid: testRepoDid}); err != nil {
t.Fatal(err)
}
env.addSecret(t)
env.knot.bare404 = true
if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
t.Fatal("a bare 404 was read as an answer")
}
if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
t.Fatalf("a bare 404 wiped the repo, rows = %+v", rows)
}
}
func TestEnsureRepoKeepsTheRepoWhenTheKnotIsDown(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
env.knot.failing = true
if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
t.Fatal("ensureRepo hid a knot failure")
}
if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
t.Fatalf("a knot outage dropped the repo, rows = %+v", rows)
}
}
func TestEnsureRepoRenameKeepsOneRow(t *testing.T) {
env := newEnsureEnv(t)
env.serve("old-name")
env.ensure(t)
env.serve("New-Name")
env.ensure(t)
rows := env.rows(t)
if len(rows) != 1 || rows[0].Rkey != "new-name" {
t.Fatalf("rows = %+v", rows)
}
if repo, err := env.s.db.GetRepoByDid(testRepoDid); err != nil || repo.Name != "New-Name" {
t.Fatalf("repo = %+v, %v", repo, err)
}
}
func TestEnsureRepoRefusesOwnersOutsideTheAllowList(t *testing.T) {
env := newEnsureEnv(t)
env.s.cfg.Server.Admission = config.AdmissionClosed
env.serve("repo")
if err := db.AddToAllowlist(env.s.db, "did:plc:someoneelse"); err != nil {
t.Fatal(err)
}
if env.ensure(t) != nil {
t.Fatal("repo of a non-member served")
}
env.wantNoRows(t)
}
func TestEnsureRepoShouldFollowAdmission(t *testing.T) {
for _, tc := range []struct {
name string
admission config.Admission
admin bool
listed bool
banned bool
served bool
}{
{name: "closed with an empty allow-list", admission: config.AdmissionClosed},
{name: "closed and allow-listed", admission: config.AdmissionClosed, listed: true, served: true},
{name: "closed and an admin", admission: config.AdmissionClosed, admin: true, served: true},
{name: "closed and banned", admission: config.AdmissionClosed, listed: true, banned: true},
{name: "closed and a banned admin", admission: config.AdmissionClosed, admin: true, banned: true},
{name: "open stranger", admission: config.AdmissionOpen, served: true},
{name: "open and banned", admission: config.AdmissionOpen, banned: true},
{name: "open and a banned admin", admission: config.AdmissionOpen, admin: true, banned: true},
} {
t.Run(tc.name, func(t *testing.T) {
env := newEnsureEnv(t)
env.s.cfg.Server.Admission = tc.admission
env.serve("repo")
if tc.admin {
env.s.cfg.Server.Admins = []string{testOwner.String()}
}
if tc.listed {
if err := db.AddToAllowlist(env.s.db, testOwner); err != nil {
t.Fatal(err)
}
}
if tc.banned {
env.banOwner(t)
}
if served := env.ensure(t) != nil; served != tc.served {
t.Fatalf("served = %v, want %v, rows = %+v", served, tc.served, env.rows(t))
}
})
}
}
func TestEnsureRepoRefusesBannedOwners(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.addSecret(t)
env.banOwner(t)
if env.ensure(t) != nil {
t.Fatal("banned owner's repo served")
}
env.wantNoRows(t)
if env.secrets(t) != 0 {
t.Error("a ban kept the secrets")
}
}
func TestRegisterRepoChecksTheBanAtTheCommitPoint(t *testing.T) {
env := newEnsureEnv(t)
env.banOwner(t)
repo := &db.Repo{Knot: env.knot.host(), Owner: testOwner, Rkey: "repo", RepoDid: testRepoDid}
if err := env.s.registerRepo(context.Background(), slog.Default(), repo); !errors.Is(err, errBanned) {
t.Fatalf("registerRepo = %v, want errBanned", err)
}
if rows := env.rows(t); len(rows) != 0 {
t.Fatalf("a banned owner was registered, rows = %+v", rows)
}
}
func TestEnsureRepoTransfersToTheDeclaredOwner(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testNewOwner, "repo", env.spindleDid()))
env.knot.declare(testNewOwner, "repo", testRepoDid)
env.ensure(t)
rows := env.rows(t)
if len(rows) != 1 || rows[0].Owner != testNewOwner {
t.Fatalf("rows = %+v", rows)
}
if ok, _ := env.s.e.IsRepoOwner(testOwner.String(), rbac.ThisServer, testRepoDid.String()); ok {
t.Error("previous owner kept the repo policy")
}
}
func TestBackfillOwnersBootstrapsConfiguredOwner(t *testing.T) {
d, _ := newTestSpindleDB(t)
owner := syntax.DID("did:plc:spindleowner")
cfg := &config.Config{}
cfg.Server.Owner = owner.String()
s := &Spindle{db: d, l: slog.Default(), cfg: cfg}
if dids, err := s.backfillOwners(); err != nil || len(dids) != 1 || dids[0] != owner {
t.Fatalf("backfill owners = %v, %v, want [%s]", dids, err, owner)
}
}
func TestBackfillOwnersFailsWithoutTheWholeList(t *testing.T) {
d, _ := newTestSpindleDB(t)
s := &Spindle{db: d, l: slog.Default(), cfg: &config.Config{}}
d.Close()
if dids, err := s.backfillOwners(); err == nil {
t.Fatalf("backfill owners = %v from a closed db, want an error", dids)
}
}