Monorepo for Tangled tangled.org
1

Configure Feed

Select the types of activity you want to include in your feed.

idresolver,spindle: add regressions tests for all paths that use perms

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 26, 2026, 8:39 PM +0300) commit e8e27f99 parent f3d0c30f change-id xyotnuvu
+1329
+10
idresolver/mock.go
··· 1 + package idresolver 2 + 3 + import "github.com/bluesky-social/indigo/atproto/identity" 4 + 5 + func NewMockResolver(dir identity.Directory) *Resolver { 6 + return &Resolver{ 7 + directory: dir, 8 + base: &identity.BaseDirectory{}, 9 + } 10 + }
+206
spindle/ingester_test.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "log/slog" 7 + "strings" 8 + "tangled.org/core/jetstream" 6 9 "testing" 7 10 8 11 "github.com/bluesky-social/indigo/atproto/syntax" 9 12 "github.com/bluesky-social/jetstream/pkg/models" 10 13 11 14 "tangled.org/core/api/tangled" 15 + "tangled.org/core/rbac" 12 16 "tangled.org/core/spindle/config" 13 17 "tangled.org/core/tapc" 14 18 ) ··· 85 89 } 86 90 } 87 91 } 92 + 93 + func TestIngestMember_RBAC(t *testing.T) { 94 + d, e := newTestSpindleDB(t) 95 + 96 + cfg := &config.Config{} 97 + cfg.Server.Hostname = "spindle.test" 98 + 99 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 100 + if jcerr != nil { 101 + t.Fatalf("NewJetstreamClient: %v", jcerr) 102 + } 103 + 104 + s := &Spindle{ 105 + db: d, 106 + e: e, 107 + l: slog.Default(), 108 + cfg: cfg, 109 + jc: jc, 110 + rootCtx: context.Background(), 111 + } 112 + 113 + actorDid := "did:plc:adminactor" 114 + subjectDid := "did:plc:newmember" 115 + rbacDomain := rbac.ThisServer 116 + 117 + memberRecord := tangled.SpindleMember{ 118 + Instance: "spindle.test", 119 + Subject: subjectDid, 120 + } 121 + memberRecordJson, _ := json.Marshal(memberRecord) 122 + 123 + evt := &models.Event{ 124 + Did: actorDid, 125 + Kind: models.EventKindCommit, 126 + Commit: &models.Commit{ 127 + Operation: models.CommitOperationCreate, 128 + Collection: tangled.SpindleMemberNSID, 129 + RKey: "member-rkey-1", 130 + Record: memberRecordJson, 131 + }, 132 + } 133 + 134 + err := s.ingestMember(context.Background(), evt) 135 + if err == nil { 136 + t.Fatal("expected permission denied error, got nil") 137 + } 138 + if !strings.Contains(err.Error(), "permission denied") { 139 + t.Fatalf("expected permission denied, got error: %v", err) 140 + } 141 + 142 + var dbCount int 143 + err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) 144 + if err != nil { 145 + t.Fatalf("DB query error: %v", err) 146 + } 147 + if dbCount > 0 { 148 + t.Fatal("spindle member was registered in DB on failed auth") 149 + } 150 + 151 + err = e.AddSpindle(rbacDomain) 152 + if err != nil { 153 + t.Fatalf("AddSpindle: %v", err) 154 + } 155 + err = e.AddSpindleOwner(rbacDomain, actorDid) 156 + if err != nil { 157 + t.Fatalf("AddSpindleOwner: %v", err) 158 + } 159 + 160 + err = s.ingestMember(context.Background(), evt) 161 + if err != nil { 162 + t.Fatalf("ingestMember failed for authorized actor: %v", err) 163 + } 164 + 165 + err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) 166 + if err != nil || dbCount != 1 { 167 + t.Fatalf("expected exactly 1 member in DB, got: %d (err: %v)", dbCount, err) 168 + } 169 + 170 + isMember, err := e.IsSpindleMember(subjectDid, rbacDomain) 171 + if err != nil || !isMember { 172 + t.Fatalf("expected subject to be spindle member in Casbin, got: %t (err: %v)", isMember, err) 173 + } 174 + 175 + deleteEvt := &models.Event{ 176 + Did: actorDid, 177 + Kind: models.EventKindCommit, 178 + Commit: &models.Commit{ 179 + Operation: models.CommitOperationDelete, 180 + Collection: tangled.SpindleMemberNSID, 181 + RKey: "member-rkey-1", 182 + }, 183 + } 184 + 185 + err = s.ingestMember(context.Background(), deleteEvt) 186 + if err != nil { 187 + t.Fatalf("ingestMember delete failed: %v", err) 188 + } 189 + 190 + err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) 191 + if err != nil || dbCount != 0 { 192 + t.Fatalf("expected 0 members in DB after delete, got: %d (err: %v)", dbCount, err) 193 + } 194 + 195 + isMember, err = e.IsSpindleMember(subjectDid, rbacDomain) 196 + if err != nil || isMember { 197 + t.Fatalf("expected subject to NOT be spindle member in Casbin, got: %t (err: %v)", isMember, err) 198 + } 199 + } 200 + 201 + func TestIngestMember_ForgeDeleteRejection(t *testing.T) { 202 + d, e := newTestSpindleDB(t) 203 + 204 + cfg := &config.Config{} 205 + cfg.Server.Hostname = "spindle.test" 206 + 207 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 208 + if jcerr != nil { 209 + t.Fatalf("NewJetstreamClient: %v", jcerr) 210 + } 211 + 212 + s := &Spindle{ 213 + db: d, 214 + e: e, 215 + l: slog.Default(), 216 + cfg: cfg, 217 + jc: jc, 218 + rootCtx: context.Background(), 219 + } 220 + 221 + adminDid := "did:plc:adminactor" 222 + bobDid := "did:plc:bobactor" 223 + subjectDid := "did:plc:newmember" 224 + rbacDomain := rbac.ThisServer 225 + 226 + err := e.AddSpindle(rbacDomain) 227 + if err != nil { 228 + t.Fatalf("AddSpindle: %v", err) 229 + } 230 + err = e.AddSpindleOwner(rbacDomain, adminDid) 231 + if err != nil { 232 + t.Fatalf("AddSpindleOwner: %v", err) 233 + } 234 + 235 + memberRecord := tangled.SpindleMember{ 236 + Instance: "spindle.test", 237 + Subject: subjectDid, 238 + } 239 + memberRecordJson, _ := json.Marshal(memberRecord) 240 + 241 + evt := &models.Event{ 242 + Did: adminDid, 243 + Kind: models.EventKindCommit, 244 + Commit: &models.Commit{ 245 + Operation: models.CommitOperationCreate, 246 + Collection: tangled.SpindleMemberNSID, 247 + RKey: "member-rkey-1", 248 + Record: memberRecordJson, 249 + }, 250 + } 251 + 252 + err = s.ingestMember(context.Background(), evt) 253 + if err != nil { 254 + t.Fatalf("ingestMember failed for admin: %v", err) 255 + } 256 + 257 + var dbCount int 258 + err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) 259 + if err != nil || dbCount != 1 { 260 + t.Fatalf("expected member in DB, got: %d (err: %v)", dbCount, err) 261 + } 262 + 263 + isMember, err := e.IsSpindleMember(subjectDid, rbacDomain) 264 + if err != nil || !isMember { 265 + t.Fatalf("expected subject to be spindle member, got %t (err: %v)", isMember, err) 266 + } 267 + 268 + // bob tries to delete alice's spindle member record, must reject forged delete 269 + deleteEvt := &models.Event{ 270 + Did: bobDid, // Bob is the actor 271 + Kind: models.EventKindCommit, 272 + Commit: &models.Commit{ 273 + Operation: models.CommitOperationDelete, 274 + Collection: tangled.SpindleMemberNSID, 275 + RKey: "member-rkey-1", 276 + }, 277 + } 278 + 279 + err = s.ingestMember(context.Background(), deleteEvt) 280 + if err != nil { 281 + t.Fatalf("ingestMember delete returned error: %v", err) 282 + } 283 + 284 + err = d.QueryRow(`select count(*) from spindle_members where subject = ?`, subjectDid).Scan(&dbCount) 285 + if err != nil || dbCount != 1 { 286 + t.Fatalf("member was deleted from DB, expected remaining, count: %d (err: %v)", dbCount, err) 287 + } 288 + 289 + isMember, err = e.IsSpindleMember(subjectDid, rbacDomain) 290 + if err != nil || !isMember { 291 + t.Fatal("member policy was removed from Casbin by forged delete") 292 + } 293 + }
+694
spindle/tapclient_test.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "strings" 8 + "tangled.org/core/jetstream" 9 + "testing" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/eventconsumer" 16 + "tangled.org/core/idresolver" 17 + "tangled.org/core/rbac" 18 + "tangled.org/core/spindle/config" 19 + "tangled.org/core/spindle/db" 20 + 21 + "tangled.org/core/tapc" 22 + ) 23 + 24 + type mockDirectory struct { 25 + ident *identity.Identity 26 + } 27 + 28 + func (m *mockDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) { 29 + return m.ident, nil 30 + } 31 + 32 + func (m *mockDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) { 33 + return m.ident, nil 34 + } 35 + 36 + func (m *mockDirectory) Lookup(ctx context.Context, id syntax.AtIdentifier) (*identity.Identity, error) { 37 + return m.ident, nil 38 + } 39 + 40 + func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error { 41 + return nil 42 + } 43 + 44 + func TestProcessRepo_MembershipCheck(t *testing.T) { 45 + d, e := newTestSpindleDB(t) 46 + 47 + cfg := &config.Config{} 48 + cfg.Server.Hostname = "spindle.test" 49 + 50 + ccfg := eventconsumer.NewConsumerConfig() 51 + ccfg.Logger = slog.Default() 52 + ks := eventconsumer.NewConsumer(*ccfg) 53 + 54 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 55 + if jcerr != nil { 56 + t.Fatalf("NewJetstreamClient: %v", jcerr) 57 + } 58 + s := &Spindle{ 59 + db: d, 60 + e: e, 61 + l: slog.Default(), 62 + cfg: cfg, 63 + ks: ks, 64 + jc: jc, 65 + rootCtx: context.Background(), 66 + } 67 + 68 + tap := &Tap{ 69 + spindle: s, 70 + logger: slog.Default(), 71 + } 72 + 73 + ownerDid := syntax.DID("did:plc:memberowner") 74 + nonMemberDid := syntax.DID("did:plc:nonmemberowner") 75 + repoDid := "did:plc:testrepo123" 76 + 77 + err := e.AddSpindle(rbac.ThisServer) 78 + if err != nil { 79 + t.Fatalf("AddSpindle: %v", err) 80 + } 81 + err = e.AddSpindleMember(rbac.ThisServer, ownerDid.String()) 82 + if err != nil { 83 + t.Fatalf("AddSpindleMember: %v", err) 84 + } 85 + 86 + recNonMember := tangled.Repo{ 87 + Knot: "knot.test", 88 + RepoDid: &repoDid, 89 + Spindle: &cfg.Server.Hostname, 90 + CreatedAt: time.Now().Format(time.RFC3339), 91 + } 92 + recNonMemberJson, _ := json.Marshal(recNonMember) 93 + 94 + err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 95 + Live: true, 96 + Did: nonMemberDid, 97 + Rkey: "test-repo-rkey", 98 + Collection: syntax.NSID(tangled.RepoNSID), 99 + Action: tapc.RecordCreateAction, 100 + Record: recNonMemberJson, 101 + }) 102 + if err != nil { 103 + t.Fatalf("processRepo returned error for non-member: %v", err) 104 + } 105 + 106 + _, err = d.GetRepoByOwnerRkey(nonMemberDid, "test-repo-rkey") 107 + if err == nil { 108 + t.Fatal("repo for non-member was registered in DB, expected rejection") 109 + } 110 + 111 + recMember := tangled.Repo{ 112 + Knot: "knot.test", 113 + RepoDid: &repoDid, 114 + Spindle: &cfg.Server.Hostname, 115 + CreatedAt: time.Now().Format(time.RFC3339), 116 + } 117 + recMemberJson, _ := json.Marshal(recMember) 118 + 119 + err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 120 + Live: true, 121 + Did: ownerDid, 122 + Rkey: "test-repo-rkey", 123 + Collection: syntax.NSID(tangled.RepoNSID), 124 + Action: tapc.RecordCreateAction, 125 + Record: recMemberJson, 126 + }) 127 + if err == nil { 128 + t.Fatal("expected git clone error for valid member, but got nil") 129 + } 130 + 131 + if !strings.Contains(err.Error(), "setting up sparse-clone git repo") { 132 + t.Fatalf("expected sparse-clone error, got: %v", err) 133 + } 134 + } 135 + 136 + func TestProcessPull_PushAllowedCheck(t *testing.T) { 137 + d, e := newTestSpindleDB(t) 138 + 139 + cfg := &config.Config{} 140 + cfg.Server.Hostname = "spindle.test" 141 + 142 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 143 + if jcerr != nil { 144 + t.Fatalf("NewJetstreamClient: %v", jcerr) 145 + } 146 + s := &Spindle{ 147 + db: d, 148 + e: e, 149 + l: slog.Default(), 150 + cfg: cfg, 151 + res: idresolver.DefaultResolver("https://plc.test"), 152 + jc: jc, 153 + rootCtx: context.Background(), 154 + } 155 + 156 + repoOwnerDid := syntax.DID("did:plc:repoowner") 157 + nonPusherDid := syntax.DID("did:plc:nonpusher") 158 + pusherDid := syntax.DID("did:plc:pusher") 159 + repoDid := syntax.DID("did:plc:testrepo123") 160 + 161 + err := d.AddRepo(db.Repo{ 162 + Knot: "knot.test", 163 + Owner: repoOwnerDid, 164 + Rkey: "test-repo-rkey", 165 + RepoDid: repoDid, 166 + CreatedAt: time.Now().Format(time.RFC3339), 167 + }) 168 + if err != nil { 169 + t.Fatalf("AddRepo: %v", err) 170 + } 171 + 172 + err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) 173 + if err != nil { 174 + t.Fatalf("AddRepo permissions: %v", err) 175 + } 176 + err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) 177 + if err != nil { 178 + t.Fatalf("AddCollaborator: %v", err) 179 + } 180 + 181 + pullRecord := tangled.RepoPull{ 182 + Target: &tangled.RepoPull_Target{ 183 + Branch: "main", 184 + Repo: repoDid.String(), 185 + }, 186 + Source: &tangled.RepoPull_Source{ 187 + Branch: "feature", 188 + Repo: nil, // branch-based PR (source repo is nil) 189 + }, 190 + } 191 + pullRecordJson, _ := json.Marshal(pullRecord) 192 + 193 + err = s.processPull(context.Background(), &tapc.RecordEventData{ 194 + Live: true, 195 + Did: nonPusherDid, 196 + Rkey: "pull-rkey-1", 197 + Collection: syntax.NSID(tangled.RepoPullNSID), 198 + Action: tapc.RecordCreateAction, 199 + Record: pullRecordJson, 200 + }) 201 + if err != nil { 202 + t.Fatalf("processPull returned error for non-pusher: %v", err) 203 + } 204 + 205 + // fetch fails because plc/pds are not real 206 + err = s.processPull(context.Background(), &tapc.RecordEventData{ 207 + Live: true, 208 + Did: pusherDid, 209 + Rkey: "pull-rkey-2", 210 + Collection: syntax.NSID(tangled.RepoPullNSID), 211 + Action: tapc.RecordCreateAction, 212 + Record: pullRecordJson, 213 + }) 214 + if err == nil { 215 + t.Fatal("expected error from fetchLatestSubmission for valid pusher, but got nil") 216 + } 217 + 218 + if !strings.Contains(err.Error(), "checking push access") && !strings.Contains(err.Error(), "resolve PR owner") && !strings.Contains(err.Error(), "invalid memory address") { 219 + t.Fatalf("expected failed identity resolution or connection error, got: %v", err) 220 + } 221 + } 222 + 223 + func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { 224 + d, e := newTestSpindleDB(t) 225 + 226 + cfg := &config.Config{} 227 + cfg.Server.Hostname = "spindle.test" 228 + 229 + ccfg := eventconsumer.NewConsumerConfig() 230 + ccfg.Logger = slog.Default() 231 + ks := eventconsumer.NewConsumer(*ccfg) 232 + 233 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 234 + if jcerr != nil { 235 + t.Fatalf("NewJetstreamClient: %v", jcerr) 236 + } 237 + s := &Spindle{ 238 + db: d, 239 + e: e, 240 + l: slog.Default(), 241 + cfg: cfg, 242 + ks: ks, 243 + jc: jc, 244 + rootCtx: context.Background(), 245 + } 246 + 247 + tap := &Tap{ 248 + spindle: s, 249 + logger: slog.Default(), 250 + } 251 + 252 + aliceDid := syntax.DID("did:plc:alice") 253 + bobDid := syntax.DID("did:plc:bob") 254 + repoDid := "did:plc:sharedrepo" 255 + 256 + err := e.AddSpindle(rbac.ThisServer) 257 + if err != nil { 258 + t.Fatalf("AddSpindle: %v", err) 259 + } 260 + err = e.AddSpindleMember(rbac.ThisServer, aliceDid.String()) 261 + if err != nil { 262 + t.Fatalf("AddSpindleMember alice: %v", err) 263 + } 264 + err = e.AddSpindleMember(rbac.ThisServer, bobDid.String()) 265 + if err != nil { 266 + t.Fatalf("AddSpindleMember bob: %v", err) 267 + } 268 + 269 + err = d.AddRepo(db.Repo{ 270 + Knot: "knot.test", 271 + Owner: aliceDid, 272 + Rkey: "alice-repo", 273 + RepoDid: syntax.DID(repoDid), 274 + CreatedAt: time.Now().Format(time.RFC3339), 275 + }) 276 + if err != nil { 277 + t.Fatalf("d.AddRepo: %v", err) 278 + } 279 + 280 + // bob tries to register alice's repo did, must reject the hijack 281 + recBob := tangled.Repo{ 282 + Knot: "knot.test", 283 + RepoDid: &repoDid, 284 + Spindle: &cfg.Server.Hostname, 285 + CreatedAt: time.Now().Format(time.RFC3339), 286 + } 287 + recBobJson, _ := json.Marshal(recBob) 288 + 289 + err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 290 + Live: true, 291 + Did: bobDid, 292 + Rkey: "bob-repo", 293 + Collection: syntax.NSID(tangled.RepoNSID), 294 + Action: tapc.RecordCreateAction, 295 + Record: recBobJson, 296 + }) 297 + if err != nil { 298 + t.Fatalf("processRepo returned error on duplicate repoDid hijack attempt: %v", err) 299 + } 300 + 301 + _, err = d.GetRepoByOwnerRkey(bobDid, "bob-repo") 302 + if err == nil { 303 + t.Fatal("bob successfully hijacked alice's repoDid in DB, expected rejection") 304 + } 305 + } 306 + 307 + func TestProcessCollaborator_RBAC(t *testing.T) { 308 + d, e := newTestSpindleDB(t) 309 + 310 + cfg := &config.Config{} 311 + cfg.Server.Hostname = "spindle.test" 312 + 313 + ownerDid := syntax.DID("did:plc:repoowner") 314 + otherDid := syntax.DID("did:plc:otheractor") 315 + subjectDid := syntax.DID("did:plc:collabsubject") 316 + repoDid := syntax.DID("did:plc:testrepo123") 317 + 318 + h, err := syntax.ParseHandle("collabsubject.test") 319 + if err != nil { 320 + t.Fatalf("syntax.ParseHandle: %v", err) 321 + } 322 + mockIdent := &identity.Identity{ 323 + DID: subjectDid, 324 + Handle: h, 325 + } 326 + resolver := idresolver.NewMockResolver(&mockDirectory{ident: mockIdent}) 327 + 328 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 329 + if jcerr != nil { 330 + t.Fatalf("NewJetstreamClient: %v", jcerr) 331 + } 332 + s := &Spindle{ 333 + db: d, 334 + e: e, 335 + l: slog.Default(), 336 + cfg: cfg, 337 + res: resolver, 338 + jc: jc, 339 + rootCtx: context.Background(), 340 + } 341 + 342 + tap := &Tap{ 343 + spindle: s, 344 + logger: slog.Default(), 345 + } 346 + 347 + err = d.AddRepo(db.Repo{ 348 + Knot: "knot.test", 349 + Owner: ownerDid, 350 + Rkey: "test-repo-rkey", 351 + RepoDid: repoDid, 352 + CreatedAt: time.Now().Format(time.RFC3339), 353 + }) 354 + if err != nil { 355 + t.Fatalf("AddRepo: %v", err) 356 + } 357 + 358 + collabRecord := tangled.RepoCollaborator{ 359 + Subject: subjectDid.String(), 360 + Repo: repoDid.String(), 361 + } 362 + collabRecordJson, _ := json.Marshal(collabRecord) 363 + 364 + err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ 365 + Live: true, 366 + Did: otherDid, 367 + Rkey: "collab-rkey-1", 368 + Collection: syntax.NSID(tangled.RepoCollaboratorNSID), 369 + Action: tapc.RecordCreateAction, 370 + Record: collabRecordJson, 371 + }) 372 + if err != nil { 373 + t.Fatalf("processCollaborator returned error: %v", err) 374 + } 375 + 376 + _, err = d.GetRepoCollaborator(otherDid, "collab-rkey-1") 377 + if err == nil { 378 + t.Fatal("collaborator from non-owner was registered in DB") 379 + } 380 + 381 + err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ 382 + Live: true, 383 + Did: ownerDid, 384 + Rkey: "collab-rkey-2", 385 + Collection: syntax.NSID(tangled.RepoCollaboratorNSID), 386 + Action: tapc.RecordCreateAction, 387 + Record: collabRecordJson, 388 + }) 389 + if err != nil { 390 + t.Fatalf("processCollaborator returned error: %v", err) 391 + } 392 + _, err = d.GetRepoCollaborator(ownerDid, "collab-rkey-2") 393 + if err == nil { 394 + t.Fatal("collaborator registered despite missing Casbin invite permission") 395 + } 396 + 397 + err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()) 398 + if err != nil { 399 + t.Fatalf("AddRepo permissions: %v", err) 400 + } 401 + 402 + err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ 403 + Live: true, 404 + Did: ownerDid, 405 + Rkey: "collab-rkey-3", 406 + Collection: syntax.NSID(tangled.RepoCollaboratorNSID), 407 + Action: tapc.RecordCreateAction, 408 + Record: collabRecordJson, 409 + }) 410 + if err != nil { 411 + t.Fatalf("processCollaborator failed for authorized owner: %v", err) 412 + } 413 + 414 + c, err := d.GetRepoCollaborator(ownerDid, "collab-rkey-3") 415 + if err != nil { 416 + t.Fatalf("GetRepoCollaborator error: %v", err) 417 + } 418 + if c.Subject != subjectDid || c.RepoDid != repoDid { 419 + t.Fatalf("unexpected collaborator: %+v", c) 420 + } 421 + 422 + ok, err := e.IsRepoCollaborator(subjectDid.String(), rbac.ThisServer, repoDid.String()) 423 + if err != nil || !ok { 424 + t.Fatalf("Casbin policy for collaborator missing or err: %v", err) 425 + } 426 + 427 + err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ 428 + Live: true, 429 + Did: ownerDid, 430 + Rkey: "collab-rkey-3", 431 + Collection: syntax.NSID(tangled.RepoCollaboratorNSID), 432 + Action: tapc.RecordDeleteAction, 433 + }) 434 + if err != nil { 435 + t.Fatalf("delete collaborator process returned error: %v", err) 436 + } 437 + 438 + _, err = d.GetRepoCollaborator(ownerDid, "collab-rkey-3") 439 + if err == nil { 440 + t.Fatal("collaborator DB row remained after deletion") 441 + } 442 + 443 + ok, err = e.IsRepoCollaborator(subjectDid.String(), rbac.ThisServer, repoDid.String()) 444 + if err != nil || ok { 445 + t.Fatal("Casbin policy for collaborator remained after deletion") 446 + } 447 + } 448 + 449 + func TestTeardownRepo_RBAC(t *testing.T) { 450 + d, e := newTestSpindleDB(t) 451 + 452 + cfg := &config.Config{} 453 + cfg.Server.Hostname = "spindle.test" 454 + 455 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 456 + if jcerr != nil { 457 + t.Fatalf("NewJetstreamClient: %v", jcerr) 458 + } 459 + s := &Spindle{ 460 + db: d, 461 + e: e, 462 + l: slog.Default(), 463 + cfg: cfg, 464 + jc: jc, 465 + rootCtx: context.Background(), 466 + } 467 + 468 + tap := &Tap{ 469 + spindle: s, 470 + logger: slog.Default(), 471 + } 472 + 473 + ownerDid := syntax.DID("did:plc:repoowner") 474 + repoDid := syntax.DID("did:plc:testrepo123") 475 + collabDid := syntax.DID("did:plc:collab") 476 + 477 + err := d.AddRepo(db.Repo{ 478 + Knot: "knot.test", 479 + Owner: ownerDid, 480 + Rkey: "test-repo-rkey", 481 + RepoDid: repoDid, 482 + CreatedAt: time.Now().Format(time.RFC3339), 483 + }) 484 + if err != nil { 485 + t.Fatalf("AddRepo DB: %v", err) 486 + } 487 + 488 + err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()) 489 + if err != nil { 490 + t.Fatalf("AddRepo policy: %v", err) 491 + } 492 + 493 + err = d.AddRepoCollaborator(db.RepoCollaborator{ 494 + OwnerDid: ownerDid, 495 + Rkey: "collab-rkey", 496 + Subject: collabDid, 497 + RepoDid: repoDid, 498 + }) 499 + if err != nil { 500 + t.Fatalf("AddCollaborator DB: %v", err) 501 + } 502 + 503 + err = e.AddCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) 504 + if err != nil { 505 + t.Fatalf("AddCollaborator policy: %v", err) 506 + } 507 + 508 + err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 509 + Live: true, 510 + Did: ownerDid, 511 + Rkey: "test-repo-rkey", 512 + Collection: syntax.NSID(tangled.RepoNSID), 513 + Action: tapc.RecordDeleteAction, 514 + }) 515 + if err != nil { 516 + t.Fatalf("processRepo delete returned error: %v", err) 517 + } 518 + 519 + _, err = d.GetRepoByOwnerRkey(ownerDid, "test-repo-rkey") 520 + if err == nil { 521 + t.Fatal("repo remained in DB after delete") 522 + } 523 + 524 + collabs, err := d.ListCollaboratorsByRepoDid(repoDid) 525 + if err != nil { 526 + t.Fatalf("ListCollaboratorsByRepoDid: %v", err) 527 + } 528 + if len(collabs) > 0 { 529 + t.Fatal("collaborators remained in DB after delete") 530 + } 531 + 532 + ok, err := e.IsRepoOwner(ownerDid.String(), rbac.ThisServer, repoDid.String()) 533 + if err != nil || ok { 534 + t.Fatal("repo owner policy remained in Casbin after delete") 535 + } 536 + 537 + ok, err = e.IsRepoCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) 538 + if err != nil || ok { 539 + t.Fatal("collaborator policy remained in Casbin after delete") 540 + } 541 + } 542 + 543 + func TestProcessRepo_ForgeDeleteRejection(t *testing.T) { 544 + d, e := newTestSpindleDB(t) 545 + 546 + cfg := &config.Config{} 547 + cfg.Server.Hostname = "spindle.test" 548 + 549 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 550 + if jcerr != nil { 551 + t.Fatalf("NewJetstreamClient: %v", jcerr) 552 + } 553 + 554 + s := &Spindle{ 555 + db: d, 556 + e: e, 557 + l: slog.Default(), 558 + cfg: cfg, 559 + jc: jc, 560 + rootCtx: context.Background(), 561 + } 562 + 563 + tap := &Tap{ 564 + spindle: s, 565 + logger: slog.Default(), 566 + } 567 + 568 + aliceDid := syntax.DID("did:plc:alice") 569 + bobDid := syntax.DID("did:plc:bob") 570 + repoDid := syntax.DID("did:plc:sharedrepo") 571 + 572 + err := d.AddRepo(db.Repo{ 573 + Knot: "knot.test", 574 + Owner: aliceDid, 575 + Rkey: "test-repo-rkey", 576 + RepoDid: repoDid, 577 + CreatedAt: time.Now().Format(time.RFC3339), 578 + }) 579 + if err != nil { 580 + t.Fatalf("AddRepo DB: %v", err) 581 + } 582 + 583 + err = e.AddRepo(aliceDid.String(), rbac.ThisServer, repoDid.String()) 584 + if err != nil { 585 + t.Fatalf("AddRepo policy: %v", err) 586 + } 587 + 588 + // bob tries to delete alice's repo, must reject forged delete 589 + err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 590 + Live: true, 591 + Did: bobDid, 592 + Rkey: "test-repo-rkey", 593 + Collection: syntax.NSID(tangled.RepoNSID), 594 + Action: tapc.RecordDeleteAction, 595 + }) 596 + if err != nil { 597 + t.Fatalf("processRepo returned error on delete: %v", err) 598 + } 599 + 600 + _, err = d.GetRepoByOwnerRkey(aliceDid, "test-repo-rkey") 601 + if err != nil { 602 + t.Fatalf("Alice's repo was deleted or error: %v", err) 603 + } 604 + 605 + ok, err := e.IsRepoOwner(aliceDid.String(), rbac.ThisServer, repoDid.String()) 606 + if err != nil || !ok { 607 + t.Fatal("Alice's owner policy was removed from Casbin by forged delete") 608 + } 609 + } 610 + 611 + func TestProcessCollaborator_ForgeDeleteRejection(t *testing.T) { 612 + d, e := newTestSpindleDB(t) 613 + 614 + cfg := &config.Config{} 615 + cfg.Server.Hostname = "spindle.test" 616 + 617 + jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 618 + if jcerr != nil { 619 + t.Fatalf("NewJetstreamClient: %v", jcerr) 620 + } 621 + 622 + s := &Spindle{ 623 + db: d, 624 + e: e, 625 + l: slog.Default(), 626 + cfg: cfg, 627 + res: idresolver.DefaultResolver("https://plc.test"), 628 + jc: jc, 629 + rootCtx: context.Background(), 630 + } 631 + 632 + tap := &Tap{ 633 + spindle: s, 634 + logger: slog.Default(), 635 + } 636 + 637 + ownerDid := syntax.DID("did:plc:repoowner") 638 + bobDid := syntax.DID("did:plc:bob") 639 + collabDid := syntax.DID("did:plc:collab") 640 + repoDid := syntax.DID("did:plc:testrepo123") 641 + 642 + err := d.AddRepo(db.Repo{ 643 + Knot: "knot.test", 644 + Owner: ownerDid, 645 + Rkey: "test-repo-rkey", 646 + RepoDid: repoDid, 647 + CreatedAt: time.Now().Format(time.RFC3339), 648 + }) 649 + if err != nil { 650 + t.Fatalf("AddRepo: %v", err) 651 + } 652 + 653 + err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()) 654 + if err != nil { 655 + t.Fatalf("AddRepo permissions: %v", err) 656 + } 657 + 658 + err = d.AddRepoCollaborator(db.RepoCollaborator{ 659 + OwnerDid: ownerDid, 660 + Rkey: "collab-rkey", 661 + Subject: collabDid, 662 + RepoDid: repoDid, 663 + }) 664 + if err != nil { 665 + t.Fatalf("AddRepoCollaborator: %v", err) 666 + } 667 + 668 + err = e.AddCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) 669 + if err != nil { 670 + t.Fatalf("AddCollaborator policy: %v", err) 671 + } 672 + 673 + // bob tries to delete alice's collaborator, must reject forged delete 674 + err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{ 675 + Live: true, 676 + Did: bobDid, 677 + Rkey: "collab-rkey", 678 + Collection: syntax.NSID(tangled.RepoCollaboratorNSID), 679 + Action: tapc.RecordDeleteAction, 680 + }) 681 + if err != nil { 682 + t.Fatalf("processCollaborator delete returned error: %v", err) 683 + } 684 + 685 + _, err = d.GetRepoCollaborator(ownerDid, "collab-rkey") 686 + if err != nil { 687 + t.Fatalf("collaborator was deleted from DB: %v", err) 688 + } 689 + 690 + ok, err := e.IsRepoCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String()) 691 + if err != nil || !ok { 692 + t.Fatal("collaborator policy was removed from Casbin by forged delete") 693 + } 694 + }
+419
spindle/xrpc/xrpc_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "github.com/bluesky-social/indigo/atproto/identity" 8 + "log/slog" 9 + "net/http" 10 + "net/http/httptest" 11 + "path/filepath" 12 + "strings" 13 + "testing" 14 + "time" 15 + 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/rbac" 20 + "tangled.org/core/spindle/config" 21 + "tangled.org/core/spindle/db" 22 + "tangled.org/core/spindle/models" 23 + "tangled.org/core/spindle/secrets" 24 + ) 25 + 26 + type mockTrigger struct { 27 + triggered bool 28 + } 29 + 30 + func (m *mockTrigger) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) { 31 + m.triggered = true 32 + return syntax.ParseATURI("at://did:plc:repoowner/sh.tangled.ci.pipeline/testrkey") 33 + } 34 + 35 + func newTestXrpcDB(t *testing.T) (*db.DB, *rbac.Enforcer) { 36 + t.Helper() 37 + p := filepath.Join(t.TempDir(), "spindle_xrpc.db") 38 + d, err := db.Make(context.Background(), p) 39 + if err != nil { 40 + t.Fatalf("db.Make: %v", err) 41 + } 42 + t.Cleanup(func() { d.Close() }) 43 + e, err := rbac.NewEnforcer(p) 44 + if err != nil { 45 + t.Fatalf("rbac.NewEnforcer: %v", err) 46 + } 47 + e.E.EnableAutoSave(true) 48 + return d, e 49 + } 50 + 51 + func TestTriggerPipeline_RBAC(t *testing.T) { 52 + d, e := newTestXrpcDB(t) 53 + 54 + repoOwnerDid := syntax.DID("did:plc:repoowner") 55 + nonPusherDid := syntax.DID("did:plc:nonpusher") 56 + pusherDid := syntax.DID("did:plc:pusher") 57 + repoDid := syntax.DID("did:plc:testrepo123") 58 + 59 + err := d.AddRepo(db.Repo{ 60 + Knot: "knot.test", 61 + Owner: repoOwnerDid, 62 + Rkey: "test-repo-rkey", 63 + RepoDid: repoDid, 64 + CreatedAt: time.Now().Format(time.RFC3339), 65 + }) 66 + if err != nil { 67 + t.Fatalf("AddRepo: %v", err) 68 + } 69 + 70 + err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) 71 + if err != nil { 72 + t.Fatalf("AddRepo permissions: %v", err) 73 + } 74 + err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) 75 + if err != nil { 76 + t.Fatalf("AddCollaborator: %v", err) 77 + } 78 + 79 + trigger := &mockTrigger{} 80 + x := &Xrpc{ 81 + Logger: slog.Default(), 82 + Db: d, 83 + Enforcer: e, 84 + Config: &config.Config{}, 85 + Trigger: trigger, 86 + } 87 + 88 + sendReq := func(actor syntax.DID, input tangled.CiTriggerPipeline_Input) (*httptest.ResponseRecorder, int) { 89 + body, _ := json.Marshal(input) 90 + req := httptest.NewRequest(http.MethodPost, "/com.atproto.repo.createRecord", bytes.NewReader(body)) 91 + ctx := context.WithValue(req.Context(), ActorDid, actor) 92 + req = req.WithContext(ctx) 93 + 94 + w := httptest.NewRecorder() 95 + x.TriggerPipeline(w, req) 96 + return w, w.Code 97 + } 98 + 99 + sha := "0123456789abcdef0123456789abcdef01234567" 100 + ref := "refs/heads/main" 101 + 102 + input := tangled.CiTriggerPipeline_Input{ 103 + Repo: repoDid.String(), 104 + Trigger: &tangled.CiTriggerPipeline_Input_Trigger{ 105 + CiTrigger_Manual: &tangled.CiTrigger_Manual{ 106 + Sha: sha, 107 + Ref: &ref, 108 + }, 109 + }, 110 + } 111 + 112 + w, code := sendReq(pusherDid, input) 113 + if code != http.StatusOK { 114 + t.Fatalf("expected 200 for pusher, got %d (body: %s)", code, w.Body.String()) 115 + } 116 + if !trigger.triggered { 117 + t.Fatal("expected pipeline trigger to be called") 118 + } 119 + 120 + trigger.triggered = false 121 + 122 + w, code = sendReq(nonPusherDid, input) 123 + if code != http.StatusBadRequest { 124 + t.Fatalf("expected 400 for non-pusher, got %d", code) 125 + } 126 + if !strings.Contains(w.Body.String(), "AccessControl") { 127 + t.Fatalf("expected AccessControl, got: %s", w.Body.String()) 128 + } 129 + if trigger.triggered { 130 + t.Fatal("expected pipeline trigger not to be called for non-pusher") 131 + } 132 + 133 + badInput := input 134 + badInput.Repo = "did:plc:unknownrepo" 135 + w, code = sendReq(pusherDid, badInput) 136 + if code != http.StatusBadRequest { 137 + t.Fatalf("expected 400 for unknown repo, got %d", code) 138 + } 139 + if !strings.Contains(w.Body.String(), "RepoNotFound") { 140 + t.Fatalf("expected RepoNotFound, got: %s", w.Body.String()) 141 + } 142 + } 143 + 144 + func TestCancelPipeline_RBAC(t *testing.T) { 145 + d, e := newTestXrpcDB(t) 146 + 147 + repoOwnerDid := syntax.DID("did:plc:repoowner") 148 + nonPusherDid := syntax.DID("did:plc:nonpusher") 149 + pusherDid := syntax.DID("did:plc:pusher") 150 + repoDid := syntax.DID("did:plc:testrepo123") 151 + 152 + err := d.AddRepo(db.Repo{ 153 + Knot: "knot.test", 154 + Owner: repoOwnerDid, 155 + Rkey: "test-repo-rkey", 156 + RepoDid: repoDid, 157 + CreatedAt: time.Now().Format(time.RFC3339), 158 + }) 159 + if err != nil { 160 + t.Fatalf("AddRepo: %v", err) 161 + } 162 + 163 + err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) 164 + if err != nil { 165 + t.Fatalf("AddRepo permissions: %v", err) 166 + } 167 + err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) 168 + if err != nil { 169 + t.Fatalf("AddCollaborator: %v", err) 170 + } 171 + 172 + pipelineTid := "3mrkp6iz6os2o" 173 + repoDidStr := repoDid.String() 174 + tpl := tangled.Pipeline{ 175 + TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ 176 + Kind: "manual", 177 + Repo: &tangled.Pipeline_TriggerRepo{ 178 + RepoDid: &repoDidStr, 179 + Knot: "knot.test", 180 + Did: repoOwnerDid.String(), 181 + }, 182 + }, 183 + Workflows: []*tangled.Pipeline_Workflow{ 184 + {Name: "test-workflow"}, 185 + }, 186 + } 187 + err = d.CreatePipelineEvent(pipelineTid, tpl, nil) 188 + if err != nil { 189 + t.Fatalf("CreatePipelineEvent: %v", err) 190 + } 191 + 192 + _, err = d.Exec(`UPDATE pipelines SET repo_did = ? WHERE id = ?`, repoDid.String(), pipelineTid) 193 + if err != nil { 194 + t.Fatalf("Update pipeline repo association: %v", err) 195 + } 196 + 197 + x := &Xrpc{ 198 + Logger: slog.Default(), 199 + Db: d, 200 + Enforcer: e, 201 + Config: &config.Config{}, 202 + Engines: make(map[string]models.Engine), 203 + } 204 + 205 + sendReq := func(actor syntax.DID, input tangled.CiCancelPipeline_Input) (*httptest.ResponseRecorder, int) { 206 + body, _ := json.Marshal(input) 207 + req := httptest.NewRequest(http.MethodPost, "/com.atproto.repo.createRecord", bytes.NewReader(body)) 208 + ctx := context.WithValue(req.Context(), ActorDid, actor) 209 + req = req.WithContext(ctx) 210 + 211 + w := httptest.NewRecorder() 212 + x.CancelPipeline(w, req) 213 + return w, w.Code 214 + } 215 + 216 + input := tangled.CiCancelPipeline_Input{ 217 + Repo: repoDid.String(), 218 + Pipeline: pipelineTid, 219 + } 220 + 221 + w, code := sendReq(pusherDid, input) 222 + if code != http.StatusOK { 223 + t.Fatalf("expected 200 for pusher, got %d (body: %s)", code, w.Body.String()) 224 + } 225 + 226 + w, code = sendReq(nonPusherDid, input) 227 + if code != http.StatusBadRequest { 228 + t.Fatalf("expected 400 for non-pusher, got %d", code) 229 + } 230 + if !strings.Contains(w.Body.String(), "AccessControl") { 231 + t.Fatalf("expected AccessControl, got: %s", w.Body.String()) 232 + } 233 + } 234 + 235 + type mockDirectory struct { 236 + ident *identity.Identity 237 + } 238 + 239 + func (m *mockDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) { 240 + return m.ident, nil 241 + } 242 + 243 + func (m *mockDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) { 244 + return m.ident, nil 245 + } 246 + 247 + func (m *mockDirectory) Lookup(ctx context.Context, id syntax.AtIdentifier) (*identity.Identity, error) { 248 + return m.ident, nil 249 + } 250 + 251 + func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error { 252 + return nil 253 + } 254 + 255 + func TestSecrets_RBAC(t *testing.T) { 256 + d, e := newTestXrpcDB(t) 257 + 258 + repoOwnerDid := syntax.DID("did:plc:repoowner") 259 + nonPusherDid := syntax.DID("did:plc:nonpusher") 260 + pusherDid := syntax.DID("did:plc:pusher") 261 + repoDid := syntax.DID("did:plc:testrepo123") 262 + 263 + err := d.AddRepo(db.Repo{ 264 + Knot: "knot.test", 265 + Owner: repoOwnerDid, 266 + Rkey: "test-repo-rkey", 267 + RepoDid: repoDid, 268 + CreatedAt: time.Now().Format(time.RFC3339), 269 + }) 270 + if err != nil { 271 + t.Fatalf("AddRepo: %v", err) 272 + } 273 + 274 + err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String()) 275 + if err != nil { 276 + t.Fatalf("AddRepo permissions: %v", err) 277 + } 278 + err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String()) 279 + if err != nil { 280 + t.Fatalf("AddCollaborator: %v", err) 281 + } 282 + 283 + vault, err := secrets.NewSQLiteManager(":memory:") 284 + if err != nil { 285 + t.Fatalf("secrets.NewSQLiteManager: %v", err) 286 + } 287 + 288 + var ts *httptest.Server 289 + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 290 + if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.repo.getRecord") { 291 + w.Header().Set("Content-Type", "application/json") 292 + _, _ = w.Write([]byte(`{ 293 + "uri": "at://did:plc:repoowner/sh.tangled.repo/test-repo-rkey", 294 + "cid": "bafybeigdyrzt5s2nuxwos7552", 295 + "value": { 296 + "$type": "sh.tangled.repo", 297 + "knot": "knot.test", 298 + "repoDid": "did:plc:testrepo123", 299 + "spindle": "spindle.test", 300 + "createdAt": "2026-07-26T12:00:00Z" 301 + } 302 + }`)) 303 + return 304 + } 305 + w.WriteHeader(http.StatusNotFound) 306 + })) 307 + defer ts.Close() 308 + 309 + h, err := syntax.ParseHandle("repoowner.test") 310 + if err != nil { 311 + t.Fatalf("syntax.ParseHandle: %v", err) 312 + } 313 + 314 + mockIdent := &identity.Identity{ 315 + DID: repoOwnerDid, 316 + Handle: h, 317 + Services: map[string]identity.ServiceEndpoint{ 318 + "atproto_pds": { 319 + Type: "AtprotoPersonalDataServer", 320 + URL: ts.URL, 321 + }, 322 + }, 323 + } 324 + 325 + resolver := idresolver.NewMockResolver(&mockDirectory{ident: mockIdent}) 326 + 327 + x := &Xrpc{ 328 + Logger: slog.Default(), 329 + Db: d, 330 + Enforcer: e, 331 + Config: &config.Config{}, 332 + Resolver: resolver, 333 + Vault: vault, 334 + } 335 + 336 + addInput := tangled.RepoAddSecret_Input{ 337 + Repo: "at://did:plc:repoowner/sh.tangled.repo/test-repo-rkey", 338 + Key: "MY_SECRET", 339 + Value: "supersecret", 340 + } 341 + 342 + sendAdd := func(actor syntax.DID, input tangled.RepoAddSecret_Input) (*httptest.ResponseRecorder, int) { 343 + body, _ := json.Marshal(input) 344 + req := httptest.NewRequest(http.MethodPost, "/"+tangled.RepoAddSecretNSID, bytes.NewReader(body)) 345 + ctx := context.WithValue(req.Context(), ActorDid, actor) 346 + req = req.WithContext(ctx) 347 + w := httptest.NewRecorder() 348 + x.AddSecret(w, req) 349 + return w, w.Code 350 + } 351 + 352 + w, code := sendAdd(pusherDid, addInput) 353 + if code != http.StatusOK { 354 + t.Fatalf("expected 200 for add secret, got %d (body: %s)", code, w.Body.String()) 355 + } 356 + 357 + w, code = sendAdd(nonPusherDid, addInput) 358 + if code != http.StatusUnauthorized { 359 + t.Fatalf("expected 401 for unauthorized add secret, got %d", code) 360 + } 361 + 362 + sendList := func(actor syntax.DID, repo string) (*httptest.ResponseRecorder, int) { 363 + req := httptest.NewRequest(http.MethodGet, "/"+tangled.RepoListSecretsNSID+"?repo="+repo, nil) 364 + ctx := context.WithValue(req.Context(), ActorDid, actor) 365 + req = req.WithContext(ctx) 366 + w := httptest.NewRecorder() 367 + x.ListSecrets(w, req) 368 + return w, w.Code 369 + } 370 + 371 + w, code = sendList(pusherDid, addInput.Repo) 372 + if code != http.StatusOK { 373 + t.Fatalf("expected 200 for list secrets, got %d (body: %s)", code, w.Body.String()) 374 + } 375 + 376 + var listOut tangled.RepoListSecrets_Output 377 + if err := json.Unmarshal(w.Body.Bytes(), &listOut); err != nil { 378 + t.Fatalf("failed to decode list secrets output: %v", err) 379 + } 380 + if len(listOut.Secrets) != 1 || listOut.Secrets[0].Key != "MY_SECRET" { 381 + t.Fatalf("unexpected secrets list: %+v", listOut.Secrets) 382 + } 383 + 384 + w, code = sendList(nonPusherDid, addInput.Repo) 385 + if code != http.StatusUnauthorized { 386 + t.Fatalf("expected 401 for unauthorized list secrets, got %d", code) 387 + } 388 + 389 + removeInput := tangled.RepoRemoveSecret_Input{ 390 + Repo: addInput.Repo, 391 + Key: "MY_SECRET", 392 + } 393 + 394 + sendRemove := func(actor syntax.DID, input tangled.RepoRemoveSecret_Input) (*httptest.ResponseRecorder, int) { 395 + body, _ := json.Marshal(input) 396 + req := httptest.NewRequest(http.MethodPost, "/"+tangled.RepoRemoveSecretNSID, bytes.NewReader(body)) 397 + ctx := context.WithValue(req.Context(), ActorDid, actor) 398 + req = req.WithContext(ctx) 399 + w := httptest.NewRecorder() 400 + x.RemoveSecret(w, req) 401 + return w, w.Code 402 + } 403 + 404 + w, code = sendRemove(pusherDid, removeInput) 405 + if code != http.StatusOK { 406 + t.Fatalf("expected 200 for remove secret, got %d (body: %s)", code, w.Body.String()) 407 + } 408 + 409 + w, code = sendList(pusherDid, addInput.Repo) 410 + if code != http.StatusOK { 411 + t.Fatalf("list secrets failed: %d", code) 412 + } 413 + if err := json.Unmarshal(w.Body.Bytes(), &listOut); err != nil { 414 + t.Fatalf("failed to decode: %v", err) 415 + } 416 + if len(listOut.Secrets) != 0 { 417 + t.Fatal("secret was not removed") 418 + } 419 + }