package spindle
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/api/org_tangled"
"tangled.org/core/jetstream"
"tangled.org/core/rbac"
"tangled.org/core/spindle/db"
"tangled.org/core/spindle/knotclient"
)
const testNewOwner = syntax.DID("did:plc:newowner")
// the same call manual triggers and pull request pipelines use
func (e *ensureEnv) canPush(t *testing.T, who syntax.DID) bool {
t.Helper()
ok, err := e.s.e.IsPushAllowed(who.String(), rbac.ThisServer, testRepoDid.String())
if err != nil {
t.Fatal(err)
}
return ok
}
func (e *ensureEnv) grant(who syntax.DID) {
e.knot.put(testRepoDid, org_tangled.GraphCollaboratorNSID, who.String(), map[string]string{"createdAt": "2026-01-01T00:00:00Z"})
}
func (e *ensureEnv) revoke(who syntax.DID) {
e.knot.erase(testRepoDid, org_tangled.GraphCollaboratorNSID, who.String())
}
func (e *ensureEnv) wantPush(t *testing.T, who syntax.DID, want bool) {
t.Helper()
if got := e.canPush(t, who); got != want {
t.Errorf("IsPushAllowed(%s) = %v, want %v", who, got, want)
}
}
func (e *ensureEnv) wantNoPushFor(t *testing.T, who ...syntax.DID) {
t.Helper()
for _, w := range who {
e.wantPush(t, w, false)
}
}
func (e *ensureEnv) collaboratorRows(t *testing.T) []db.RepoCollaborator {
t.Helper()
rows, err := e.s.db.ListCollaboratorsByRepoDid(testRepoDid)
if err != nil {
t.Fatal(err)
}
return rows
}
func (e *ensureEnv) wantNoCollaboratorRows(t *testing.T) {
t.Helper()
if rows := e.collaboratorRows(t); len(rows) != 0 {
t.Fatalf("collaborator rows = %+v", rows)
}
}
func collaboratorCommit(repo syntax.DID, rkey syntax.DID, operation string) *jetstream.Event {
return &jetstream.Event{
Did: repo.String(),
Kind: jetstream.EventKindCommit,
Commit: &jetstream.Commit{
Operation: operation,
Collection: org_tangled.GraphCollaboratorNSID,
RKey: rkey.String(),
Record: json.RawMessage(`{}`),
},
}
}
func (e *ensureEnv) ingest(t *testing.T, event *jetstream.Event) {
t.Helper()
if err := e.s.ingest()(context.Background(), event); err != nil {
t.Fatal(err)
}
}
func TestOwnerAlwaysPushesAndStrangersNever(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob)
env.ensure(t)
env.wantPush(t, testOwner, true)
env.wantPush(t, testBob, true)
env.wantNoPushFor(t, testCarol, "did:plc:nobody", "")
env.revoke(testBob)
env.ensure(t)
env.wantPush(t, testOwner, true)
env.wantNoPushFor(t, testBob, testCarol)
}
func TestCollaboratorCreateEventForAGrantTheKnotLacksGrantsNothing(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationCreate))
env.wantNoPushFor(t, testBob)
env.wantPush(t, testOwner, true)
env.wantNoCollaboratorRows(t)
}
func TestCollaboratorDeleteEventForAGrantTheKnotKeepsRemovesNothing(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob)
env.ensure(t)
env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationDelete))
env.wantPush(t, testBob, true)
if rows := env.collaboratorRows(t); len(rows) != 1 {
t.Fatalf("collaborator rows = %+v", rows)
}
}
func TestCollaboratorEventsFromNonRepoDidsGrantNothing(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
before := env.knot.count()
// anyone can write this record into their own repo, only the served repo's did counts
events := map[string]*jetstream.Event{
"naming themselves": collaboratorCommit(testBob, testBob, jetstream.CommitOperationCreate),
"naming the served repo": collaboratorCommit(testBob, testRepoDid, jetstream.CommitOperationCreate),
"from the owner's pds": collaboratorCommit(testOwner, testBob, jetstream.CommitOperationCreate),
"naming someone else": collaboratorCommit(testCarol, testBob, jetstream.CommitOperationUpdate),
}
for name, event := range events {
t.Run(name, func(t *testing.T) {
env.ingest(t, event)
env.wantNoPushFor(t, testBob, testCarol, testRepoDid)
})
}
env.wantNoCollaboratorRows(t)
if got := env.knot.count(); got != before {
t.Errorf("events for repos spindle doesn't serve cost %d knot requests", got-before)
}
}
func TestCollaboratorGrantAndRemovalReachPushAccessThroughTheIngester(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo")
env.ensure(t)
env.wantNoPushFor(t, testBob)
env.grant(testBob)
env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationCreate))
env.wantPush(t, testBob, true)
env.wantNoPushFor(t, testCarol)
env.revoke(testBob)
env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationDelete))
env.wantNoPushFor(t, testBob)
env.wantNoCollaboratorRows(t)
env.wantPush(t, testOwner, true)
}
func TestForgettingOrWipingARepoTakesPushAccessFromEveryone(t *testing.T) {
tests := map[string]func(*testing.T, *ensureEnv){
"manifest names another spindle": func(t *testing.T, env *ensureEnv) {
env.manifest("repo", "did:web:other.example.com")
},
"declaration points elsewhere": func(t *testing.T, env *ensureEnv) {
env.knot.declare(testOwner, "repo", "did:plc:otherrepo")
},
"manifest has no declaration": func(t *testing.T, env *ensureEnv) {
m := manifestRecord(testOwner, "repo", env.spindleDid())
m.Declaration = nil
env.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, m)
},
"manifest deleted": func(t *testing.T, env *ensureEnv) {
env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
},
"owner banned": func(t *testing.T, env *ensureEnv) {
env.banOwner(t)
},
}
for name, leave := range tests {
t.Run(name, func(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob, testCarol)
env.ensure(t)
env.wantPush(t, testOwner, true)
env.wantPush(t, testBob, true)
leave(t, env)
if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err != nil {
t.Fatal(err)
}
env.wantNoPushFor(t, testOwner, testBob, testCarol)
env.requireNewRepoStartsClean(t)
})
}
t.Run("wiped directly", func(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob, testCarol)
env.ensure(t)
if err := env.s.WipeRepo(context.Background(), testRepoDid, "test"); err != nil {
t.Fatal(err)
}
env.wantNoPushFor(t, testOwner, testBob, testCarol)
env.requireNewRepoStartsClean(t)
})
}
// a new repo at the same did must not inherit the old owner or collaborators
func (e *ensureEnv) requireNewRepoStartsClean(t *testing.T) {
t.Helper()
e.revoke(testBob)
e.revoke(testCarol)
e.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testNewOwner, "repo", e.spindleDid()))
e.knot.declare(testNewOwner, "repo", testRepoDid)
e.ensure(t)
e.wantPush(t, testNewOwner, true)
e.wantNoPushFor(t, testOwner, testBob, testCarol)
e.wantNoCollaboratorRows(t)
}
func TestEnsureRepoKeepsCollaboratorsWhenTheListIsPastThePageCap(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob)
env.ensure(t)
// the cap is maxListPages of 100 each, and bob is not in this list
const flood = 3300
for i := range flood {
env.grant(syntax.DID(fmt.Sprintf("did:plc:flood%d", i)))
}
env.revoke(testBob)
if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
t.Fatal("ensureRepo accepted a list it had to cut")
}
env.wantPush(t, testBob, true)
env.wantPush(t, testOwner, true)
env.wantNoPushFor(t, "did:plc:flood0", "did:plc:flood3299")
if rows := env.collaboratorRows(t); len(rows) != 1 || rows[0].Subject != testBob {
t.Fatalf("collaborator rows = %+v, want only bob", rows)
}
}
func TestCollaboratorEventWithAnOversizedListGoesInTheRetrySet(t *testing.T) {
env := newEnsureEnv(t)
env.serve("repo", testBob)
env.ensure(t)
for i := range 3300 {
env.grant(syntax.DID(fmt.Sprintf("did:plc:flood%d", i)))
}
env.ingest(t, collaboratorCommit(testRepoDid, "did:plc:flood0", jetstream.CommitOperationCreate))
if attempts, err := env.s.db.RepoRetryAttempts(testRepoDid); err != nil || attempts != 1 {
t.Fatalf("retry attempts = %d, %v, want the repo in the retry set", attempts, err)
}
env.wantPush(t, testBob, true)
env.wantNoPushFor(t, "did:plc:flood0")
}