Monorepo for Tangled tangled.org
2

Configure Feed

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

1package appview 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "io" 9 "log/slog" 10 "net/url" 11 "path/filepath" 12 "testing" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 jmodels "github.com/bluesky-social/jetstream/pkg/models" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/appview/models" 19 "tangled.org/core/appview/notify" 20 "tangled.org/core/appview/repoverify" 21 "tangled.org/core/orm" 22 "tangled.org/core/rbac" 23) 24 25func mustKnotURL(t *testing.T, raw string) *url.URL { 26 t.Helper() 27 u, err := repoverify.ParseKnotEndpoint(raw, true) 28 if err != nil { 29 t.Fatalf("ParseKnotEndpoint(%q): %v", raw, err) 30 } 31 return u 32} 33 34func acceptOwner(t *testing.T, e *jmodels.Event) repoverify.Verifier { 35 t.Helper() 36 knot := mustKnotURL(t, "https://knot.example") 37 return func(_ context.Context, repoDid repoverify.RepoDid) (repoverify.Result, error) { 38 return repoverify.Result{ 39 RepoDid: repoDid, 40 OwnerDid: repoverify.OwnerDid(e.Did), 41 KnotURL: knot, 42 }, nil 43 } 44} 45 46func stubVerifier(result repoverify.Result, err error) repoverify.Verifier { 47 return func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) { 48 return result, err 49 } 50} 51 52type spyNotifier struct { 53 notify.BaseNotifier 54 creates int 55 deletes int 56 renames int 57} 58 59func (s *spyNotifier) NewRepo(_ context.Context, _ *models.Repo) { s.creates++ } 60func (s *spyNotifier) DeleteRepo(_ context.Context, _ *models.Repo) { s.deletes++ } 61func (s *spyNotifier) RenameRepo(_ context.Context, _ syntax.DID, _, _ *models.Repo) { 62 s.renames++ 63} 64 65func newTestIngester(t *testing.T) (*Ingester, *spyNotifier) { 66 t.Helper() 67 path := filepath.Join(t.TempDir(), "test.db") 68 d, err := db.Make(context.Background(), path) 69 if err != nil { 70 t.Fatalf("db.Make: %v", err) 71 } 72 t.Cleanup(func() { d.Close() }) 73 enforcer, err := rbac.NewEnforcer(path) 74 if err != nil { 75 t.Fatalf("rbac.NewEnforcer: %v", err) 76 } 77 78 spy := &spyNotifier{} 79 ing := &Ingester{ 80 Db: d, 81 Enforcer: enforcer, 82 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 83 Notifier: spy, 84 } 85 return ing, spy 86} 87 88func withVerifier(ing *Ingester, v repoverify.Verifier) *Ingester { 89 ing.Verifier = v 90 return ing 91} 92 93func ingestAcceptingOwner(t *testing.T, ing *Ingester, e *jmodels.Event) error { 94 t.Helper() 95 ing.Verifier = acceptOwner(t, e) 96 return ing.ingestRepo(context.Background(), e) 97} 98 99func seedRepoRow(t *testing.T, ing *Ingester, did, knot, name, rkey, repoDid string) *models.Repo { 100 t.Helper() 101 tx, err := ing.Db.Begin() 102 if err != nil { 103 t.Fatalf("Begin: %v", err) 104 } 105 repo := &models.Repo{ 106 Did: did, 107 Name: name, 108 Knot: knot, 109 Rkey: rkey, 110 RepoDid: repoDid, 111 } 112 if err := db.AddRepo(tx, repo); err != nil { 113 t.Fatalf("AddRepo: %v", err) 114 } 115 if err := tx.Commit(); err != nil { 116 t.Fatalf("Commit: %v", err) 117 } 118 return repo 119} 120 121func ptr[T any](v T) *T { return &v } 122 123func makeEvent(t *testing.T, op string, did, rkey string, record tangled.Repo) *jmodels.Event { 124 t.Helper() 125 raw, err := json.Marshal(record) 126 if err != nil { 127 t.Fatalf("marshal record: %v", err) 128 } 129 return &jmodels.Event{ 130 Did: did, 131 Kind: jmodels.EventKindCommit, 132 Commit: &jmodels.Commit{ 133 Operation: op, 134 Collection: tangled.RepoNSID, 135 RKey: rkey, 136 Record: raw, 137 }, 138 } 139} 140 141func makeDeleteEvent(did, rkey string) *jmodels.Event { 142 return &jmodels.Event{ 143 Did: did, 144 Kind: jmodels.EventKindCommit, 145 Commit: &jmodels.Commit{ 146 Operation: jmodels.CommitOperationDelete, 147 Collection: tangled.RepoNSID, 148 RKey: rkey, 149 }, 150 } 151} 152 153func loadRepo(t *testing.T, ing *Ingester, did, rkey string) *models.Repo { 154 t.Helper() 155 r, err := db.GetRepo(ing.Db, 156 orm.FilterEq("did", did), 157 orm.FilterEq("rkey", rkey), 158 ) 159 if err != nil { 160 t.Fatalf("GetRepo: %v", err) 161 } 162 return r 163} 164 165func assertRepoOwnerPermissions(t *testing.T, ing *Ingester, owner, knot, repo string) { 166 t.Helper() 167 for _, perm := range []string{"repo:settings", "repo:push", "repo:owner"} { 168 ok, err := ing.Enforcer.E.Enforce(owner, knot, repo, perm) 169 if err != nil { 170 t.Fatalf("Enforce(%q): %v", perm, err) 171 } 172 if !ok { 173 t.Fatalf("owner missing %s permission for %s", perm, repo) 174 } 175 } 176} 177 178func assertNoRepoPolicies(t *testing.T, ing *Ingester, knot, repo string) { 179 t.Helper() 180 for _, perm := range []string{"repo:settings", "repo:push", "repo:owner", "repo:delete", "repo:invite", "repo:collaborator"} { 181 policies, err := ing.Enforcer.E.GetFilteredPolicy(1, knot, repo, perm) 182 if err != nil { 183 t.Fatalf("GetFilteredPolicy(%q): %v", perm, err) 184 } 185 if len(policies) != 0 { 186 t.Fatalf("expected no %s policies for %s, got %v", perm, repo, policies) 187 } 188 } 189} 190 191func TestIngestRepo_CreateInsertsNewRow(t *testing.T) { 192 ing, spy := newTestIngester(t) 193 194 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 195 Knot: "knot.example", 196 Name: ptr("MyRepo"), 197 Description: ptr("a test repo"), 198 RepoDid: ptr("did:plc:repo1"), 199 }) 200 201 if err := ingestAcceptingOwner(t, ing, e); err != nil { 202 t.Fatalf("ingestRepo: %v", err) 203 } 204 205 r := loadRepo(t, ing, "did:plc:akshay", "myrepo") 206 if r.Name != "MyRepo" { 207 t.Errorf("name = %q, want %q", r.Name, "MyRepo") 208 } 209 if r.Description != "a test repo" { 210 t.Errorf("description = %q", r.Description) 211 } 212 if r.RepoDid != "did:plc:repo1" { 213 t.Errorf("repoDid = %q", r.RepoDid) 214 } 215 if spy.creates != 1 { 216 t.Errorf("NewRepo called %d times, want 1", spy.creates) 217 } 218 assertRepoOwnerPermissions(t, ing, "did:plc:akshay", "knot.example", "did:plc:repo1") 219} 220 221func TestIngestRepo_CreateSkipsIfRowExists(t *testing.T) { 222 ing, spy := newTestIngester(t) 223 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "myrepo", "did:plc:repo1") 224 225 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 226 Knot: "knot.example", 227 Name: ptr("myrepo"), 228 RepoDid: ptr("did:plc:repo1"), 229 }) 230 231 if err := ingestAcceptingOwner(t, ing, e); err != nil { 232 t.Fatalf("ingestRepo: %v", err) 233 } 234 if spy.creates != 0 { 235 t.Errorf("row already exists, NewRepo should not be called but was called %d times", spy.creates) 236 } 237 assertRepoOwnerPermissions(t, ing, "did:plc:akshay", "knot.example", "did:plc:repo1") 238} 239 240func TestIngestRepo_CreateCascadesRename(t *testing.T) { 241 ing, spy := newTestIngester(t) 242 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldname", "did:plc:repo1") 243 244 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ 245 Knot: "knot.example", 246 Name: ptr("NewName"), 247 RepoDid: ptr("did:plc:repo1"), 248 }) 249 250 if err := ingestAcceptingOwner(t, ing, e); err != nil { 251 t.Fatalf("ingestRepo: %v", err) 252 } 253 254 _, err := db.GetRepo(ing.Db, 255 orm.FilterEq("did", "did:plc:akshay"), 256 orm.FilterEq("rkey", "oldname"), 257 ) 258 if !errors.Is(err, sql.ErrNoRows) { 259 t.Errorf("old rkey row should be gone, got err = %v", err) 260 } 261 262 r := loadRepo(t, ing, "did:plc:akshay", "newname") 263 if r.Name != "NewName" { 264 t.Errorf("name = %q, want %q", r.Name, "NewName") 265 } 266 if r.RepoDid != "did:plc:repo1" { 267 t.Errorf("repoDid = %q", r.RepoDid) 268 } 269 270 hint, err := db.LookupRepoRename(ing.Db, "did:plc:akshay", "oldname") 271 if err != nil { 272 t.Fatalf("LookupRepoRename: %v", err) 273 } 274 if hint == nil { 275 t.Fatal("expected rename history, got nil") 276 } 277 278 if spy.renames != 1 { 279 t.Errorf("RenameRepo called %d times, want 1", spy.renames) 280 } 281 if spy.creates != 0 { 282 t.Errorf("rename should not create: NewRepo called %d times, want 0", spy.creates) 283 } 284} 285 286func TestIngestRepo_CreateNoRepoDidSkipped(t *testing.T) { 287 ing, spy := newTestIngester(t) 288 289 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 290 Knot: "knot.example", 291 Name: ptr("myrepo"), 292 }) 293 294 if err := ingestAcceptingOwner(t, ing, e); err != nil { 295 t.Fatalf("ingestRepo: %v", err) 296 } 297 if spy.creates != 0 { 298 t.Errorf("NewRepo called %d times, want 0", spy.creates) 299 } 300} 301 302func TestIngestRepo_UpdateMetadata(t *testing.T) { 303 ing, _ := newTestIngester(t) 304 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 305 306 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 307 Knot: "knot.example", 308 Name: ptr("foo"), 309 Description: ptr("updated description"), 310 Website: ptr("https://example.com"), 311 Topics: []string{"go", "test"}, 312 RepoDid: ptr("did:plc:repo1"), 313 }) 314 315 if err := ingestAcceptingOwner(t, ing, e); err != nil { 316 t.Fatalf("ingestRepo: %v", err) 317 } 318 319 r := loadRepo(t, ing, "did:plc:akshay", "foo") 320 if r.Description != "updated description" { 321 t.Errorf("description = %q", r.Description) 322 } 323 if r.Website != "https://example.com" { 324 t.Errorf("website = %q", r.Website) 325 } 326 if got := r.TopicStr(); got != "go test" { 327 t.Errorf("topics = %q", got) 328 } 329} 330 331func TestIngestRepo_UpdateDisplayName(t *testing.T) { 332 ing, _ := newTestIngester(t) 333 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 334 335 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 336 Knot: "knot.example", 337 Name: ptr("Foo"), 338 RepoDid: ptr("did:plc:repo1"), 339 }) 340 341 if err := ingestAcceptingOwner(t, ing, e); err != nil { 342 t.Fatalf("ingestRepo: %v", err) 343 } 344 345 r := loadRepo(t, ing, "did:plc:akshay", "foo") 346 if r.Name != "Foo" { 347 t.Errorf("name = %q, want %q", r.Name, "Foo") 348 } 349 if r.Rkey != "foo" { 350 t.Errorf("rkey should be unchanged but got %q, want %q", r.Rkey, "foo") 351 } 352} 353 354func TestIngestRepo_UpdateNothingChangedNoOp(t *testing.T) { 355 ing, _ := newTestIngester(t) 356 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 357 358 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 359 Knot: "knot.example", 360 Name: ptr("foo"), 361 RepoDid: ptr("did:plc:repo1"), 362 }) 363 364 if err := ingestAcceptingOwner(t, ing, e); err != nil { 365 t.Fatalf("ingestRepo: %v", err) 366 } 367 368 r := loadRepo(t, ing, "did:plc:akshay", "foo") 369 if r.Name != "foo" { 370 t.Errorf("name = %q, want unchanged %q", r.Name, "foo") 371 } 372} 373 374func TestIngestRepo_UnknownRowSkipped(t *testing.T) { 375 ops := []string{jmodels.CommitOperationUpdate, jmodels.CommitOperationDelete} 376 for _, op := range ops { 377 t.Run(op, func(t *testing.T) { 378 ing, _ := newTestIngester(t) 379 380 var e *jmodels.Event 381 switch op { 382 case jmodels.CommitOperationUpdate: 383 e = makeEvent(t, op, "did:plc:nobody", "ghost", tangled.Repo{ 384 Knot: "knot.example", 385 Name: ptr("ghost"), 386 RepoDid: ptr("did:plc:nope"), 387 }) 388 case jmodels.CommitOperationDelete: 389 e = makeDeleteEvent("did:plc:nobody", "ghost") 390 } 391 392 if err := ingestAcceptingOwner(t, ing, e); err != nil { 393 t.Fatalf("ingestRepo: %v", err) 394 } 395 }) 396 } 397} 398 399func TestIngestRepo_UpdateNoRepoDidSkipped(t *testing.T) { 400 ing, _ := newTestIngester(t) 401 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 402 403 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 404 Knot: "knot.example", 405 Name: ptr("bar"), 406 }) 407 408 if err := ingestAcceptingOwner(t, ing, e); err != nil { 409 t.Fatalf("ingestRepo: %v", err) 410 } 411 412 r := loadRepo(t, ing, "did:plc:akshay", "foo") 413 if r.Name != "foo" { 414 t.Errorf("name = %q, want unchanged %q", r.Name, "foo") 415 } 416} 417 418func TestIngestRepo_DeleteRemovesRow(t *testing.T) { 419 ing, _ := newTestIngester(t) 420 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 421 422 e := makeDeleteEvent("did:plc:akshay", "foo") 423 if err := ingestAcceptingOwner(t, ing, e); err != nil { 424 t.Fatalf("ingestRepo: %v", err) 425 } 426 427 _, err := db.GetRepo(ing.Db, 428 orm.FilterEq("did", "did:plc:akshay"), 429 orm.FilterEq("rkey", "foo"), 430 ) 431 if !errors.Is(err, sql.ErrNoRows) { 432 t.Errorf("expected row to be deleted, got err = %v", err) 433 } 434} 435 436func TestIngestRepo_DeleteWipesRbac(t *testing.T) { 437 ing, _ := newTestIngester(t) 438 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 439 if err := ing.ensureRepoOwnerPermissions("did:plc:akshay", "knot.example", "did:plc:repo1"); err != nil { 440 t.Fatalf("ensureRepoOwnerPermissions: %v", err) 441 } 442 if err := ing.Enforcer.AddCollaborator("did:plc:boltless", "knot.example", "did:plc:repo1"); err != nil { 443 t.Fatalf("AddCollaborator: %v", err) 444 } 445 if err := ing.Enforcer.E.SavePolicy(); err != nil { 446 t.Fatalf("SavePolicy: %v", err) 447 } 448 assertRepoOwnerPermissions(t, ing, "did:plc:akshay", "knot.example", "did:plc:repo1") 449 450 e := makeDeleteEvent("did:plc:akshay", "foo") 451 if err := ingestAcceptingOwner(t, ing, e); err != nil { 452 t.Fatalf("ingestRepo: %v", err) 453 } 454 455 assertNoRepoPolicies(t, ing, "knot.example", "did:plc:repo1") 456} 457 458func TestIngestRepo_MalformedRecord(t *testing.T) { 459 ing, _ := newTestIngester(t) 460 461 e := &jmodels.Event{ 462 Did: "did:plc:akshay", 463 Kind: jmodels.EventKindCommit, 464 Commit: &jmodels.Commit{ 465 Operation: jmodels.CommitOperationUpdate, 466 Collection: tangled.RepoNSID, 467 RKey: "rkey1", 468 Record: json.RawMessage("{not json"), 469 }, 470 } 471 472 if err := ingestAcceptingOwner(t, ing, e); err == nil { 473 t.Errorf("ingestRepo with malformed record: err = nil, want error") 474 } 475} 476 477func TestIngestRepo_RenameDeleteSequenceNoTornState(t *testing.T) { 478 ing, spy := newTestIngester(t) 479 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldname", "did:plc:repo1") 480 481 if _, err := ing.Db.Exec( 482 `insert into stars (did, rkey, subject_type, subject) values (?, ?, ?, ?)`, 483 "did:plc:boltless", "star1", "repo", "did:plc:repo1", 484 ); err != nil { 485 t.Fatalf("seed star: %v", err) 486 } 487 488 createEvt := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ 489 Knot: "knot.example", 490 Name: ptr("NewName"), 491 RepoDid: ptr("did:plc:repo1"), 492 }) 493 if err := ingestAcceptingOwner(t, ing, createEvt); err != nil { 494 t.Fatalf("ingest create: %v", err) 495 } 496 497 deleteEvt := makeDeleteEvent("did:plc:akshay", "oldname") 498 if err := ingestAcceptingOwner(t, ing, deleteEvt); err != nil { 499 t.Fatalf("ingest delete: %v", err) 500 } 501 502 r := loadRepo(t, ing, "did:plc:akshay", "newname") 503 if r.Name != "NewName" { 504 t.Errorf("name = %q, want %q", r.Name, "NewName") 505 } 506 if r.RepoDid != "did:plc:repo1" { 507 t.Errorf("repoDid = %q, want %q", r.RepoDid, "did:plc:repo1") 508 } 509 510 _, err := db.GetRepo(ing.Db, 511 orm.FilterEq("did", "did:plc:akshay"), 512 orm.FilterEq("rkey", "oldname"), 513 ) 514 if !errors.Is(err, sql.ErrNoRows) { 515 t.Errorf("old rkey should be gone, got err = %v", err) 516 } 517 518 var starSubject string 519 if err := ing.Db.QueryRow(`select subject from stars where did = ?`, "did:plc:boltless").Scan(&starSubject); err != nil { 520 t.Fatalf("query star: %v", err) 521 } 522 if starSubject != "did:plc:repo1" { 523 t.Errorf("star subject = %q, want %q", starSubject, "did:plc:repo1") 524 } 525 526 if spy.renames != 1 { 527 t.Errorf("RenameRepo called %d times, want 1", spy.renames) 528 } 529 if spy.creates != 0 { 530 t.Errorf("rename should not create: NewRepo called %d times, want 0", spy.creates) 531 } 532 if spy.deletes != 0 { 533 t.Errorf("old rkey already gone, DeleteRepo should not be called but was called %d times", spy.deletes) 534 } 535} 536 537func TestIngestRepo_CreateFallsBackToRkeyForName(t *testing.T) { 538 ing, _ := newTestIngester(t) 539 540 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 541 Knot: "knot.example", 542 RepoDid: ptr("did:plc:repo1"), 543 }) 544 545 if err := ingestAcceptingOwner(t, ing, e); err != nil { 546 t.Fatalf("ingestRepo: %v", err) 547 } 548 549 r := loadRepo(t, ing, "did:plc:akshay", "myrepo") 550 if r.Name != "myrepo" { 551 t.Errorf("name should fall back to rkey: got %q, want %q", r.Name, "myrepo") 552 } 553} 554 555func TestIngestRepo_CreateSquatRejected(t *testing.T) { 556 ing, spy := newTestIngester(t) 557 558 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "squatrepo", tangled.Repo{ 559 Knot: "knot.example", 560 RepoDid: ptr("did:plc:akshays-repo"), 561 }) 562 563 withVerifier(ing, stubVerifier(repoverify.Result{ 564 RepoDid: "did:plc:akshays-repo", 565 OwnerDid: "did:plc:akshay", 566 KnotURL: mustKnotURL(t, "https://knot.example"), 567 }, nil)) 568 569 if err := ing.ingestRepo(context.Background(), e); err != nil { 570 t.Fatalf("ingestRepo: %v", err) 571 } 572 573 if _, err := db.GetRepo(ing.Db, 574 orm.FilterEq("did", "did:plc:boltless"), 575 orm.FilterEq("rkey", "squatrepo"), 576 ); !errors.Is(err, sql.ErrNoRows) { 577 t.Fatalf("boltless's squat row should not exist, got err=%v", err) 578 } 579 if spy.creates != 0 { 580 t.Errorf("NewRepo called %d times despite rejection", spy.creates) 581 } 582} 583 584func TestIngestRepo_CreateHijackExistingRepoRejected(t *testing.T) { 585 ing, spy := newTestIngester(t) 586 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 587 588 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "takeover", tangled.Repo{ 589 Knot: "knot.example", 590 RepoDid: ptr("did:plc:akshays-repo"), 591 }) 592 593 withVerifier(ing, stubVerifier(repoverify.Result{ 594 RepoDid: "did:plc:akshays-repo", 595 OwnerDid: "did:plc:akshay", 596 KnotURL: mustKnotURL(t, "https://knot.example"), 597 }, nil)) 598 599 if err := ing.ingestRepo(context.Background(), e); err != nil { 600 t.Fatalf("ingestRepo: %v", err) 601 } 602 603 akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 604 if akshay.Did != "did:plc:akshay" || akshay.Rkey != "akshayskey" { 605 t.Errorf("akshay's row mutated: %+v", akshay) 606 } 607 if spy.renames != 0 { 608 t.Errorf("RenameRepo called %d times despite rejection", spy.renames) 609 } 610} 611 612func TestIngestRepo_CreateRenameIgnoresRkeyDrift(t *testing.T) { 613 ing, spy := newTestIngester(t) 614 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldrkey", "did:plc:akshays-repo") 615 616 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newrkey", tangled.Repo{ 617 Knot: "knot.example", 618 Name: ptr("newname"), 619 RepoDid: ptr("did:plc:akshays-repo"), 620 }) 621 622 withVerifier(ing, stubVerifier(repoverify.Result{ 623 RepoDid: "did:plc:akshays-repo", 624 OwnerDid: "did:plc:akshay", 625 KnotURL: mustKnotURL(t, "https://knot.example"), 626 }, nil)) 627 628 if err := ing.ingestRepo(context.Background(), e); err != nil { 629 t.Fatalf("ingestRepo: %v", err) 630 } 631 632 r := loadRepo(t, ing, "did:plc:akshay", "newrkey") 633 if r.Name != "newname" { 634 t.Errorf("rename did not apply despite matching owner: name=%q", r.Name) 635 } 636 if spy.renames != 1 { 637 t.Errorf("RenameRepo called %d times, want 1", spy.renames) 638 } 639} 640 641func TestIngestRepo_CreateVerifierTransientErrorPropagates(t *testing.T) { 642 ing, spy := newTestIngester(t) 643 644 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 645 Knot: "knot.example", 646 RepoDid: ptr("did:plc:akshays-repo"), 647 }) 648 649 withVerifier(ing, stubVerifier(repoverify.Result{}, errors.New("knot unreachable"))) 650 651 err := ing.ingestRepo(context.Background(), e) 652 if err == nil { 653 t.Fatalf("expected error on transient verifier failure, got nil") 654 } 655 if spy.creates != 0 { 656 t.Errorf("NewRepo called %d times despite verifier error", spy.creates) 657 } 658} 659 660func TestIngestRepo_UpdateRejectsOwnerMismatch(t *testing.T) { 661 ing, _ := newTestIngester(t) 662 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 663 664 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "akshayskey", tangled.Repo{ 665 Knot: "knot.example", 666 Description: ptr("boltless hijacks metadata"), 667 RepoDid: ptr("did:plc:akshays-repo"), 668 }) 669 670 withVerifier(ing, stubVerifier(repoverify.Result{ 671 RepoDid: "did:plc:akshays-repo", 672 OwnerDid: "did:plc:akshay", 673 KnotURL: mustKnotURL(t, "https://knot.example"), 674 }, nil)) 675 676 if err := ing.ingestRepo(context.Background(), e); err != nil { 677 t.Fatalf("ingestRepo: %v", err) 678 } 679 680 akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 681 if akshay.Description == "boltless hijacks metadata" { 682 t.Errorf("update by non-owner applied: %+v", akshay) 683 } 684} 685 686func TestIngestRepo_CreateInvalidRepoDidRejected(t *testing.T) { 687 ing, spy := newTestIngester(t) 688 689 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 690 Knot: "knot.example", 691 RepoDid: ptr("did:plc:"), 692 }) 693 694 verifierCalled := false 695 withVerifier(ing, func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) { 696 verifierCalled = true 697 return repoverify.Result{}, nil 698 }) 699 700 if err := ing.ingestRepo(context.Background(), e); err != nil { 701 t.Fatalf("ingestRepo: %v", err) 702 } 703 if verifierCalled { 704 t.Errorf("verifier was called with an invalid repoDid") 705 } 706 if spy.creates != 0 { 707 t.Errorf("NewRepo called %d times despite invalid repoDid", spy.creates) 708 } 709} 710 711func TestIngestRepo_NilVerifierFailsClosed(t *testing.T) { 712 ing, spy := newTestIngester(t) 713 714 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 715 Knot: "knot.example", 716 RepoDid: ptr("did:plc:akshays-repo"), 717 }) 718 719 err := ing.ingestRepo(context.Background(), e) 720 if err == nil { 721 t.Fatalf("expected error when Verifier is nil, got nil") 722 } 723 if spy.creates != 0 { 724 t.Errorf("NewRepo called %d times despite nil verifier", spy.creates) 725 } 726} 727 728func TestIngestRepo_CreateRejectsKnotMismatch(t *testing.T) { 729 ing, spy := newTestIngester(t) 730 731 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 732 Knot: "evil.example", 733 RepoDid: ptr("did:plc:akshays-repo"), 734 }) 735 736 withVerifier(ing, stubVerifier(repoverify.Result{ 737 RepoDid: "did:plc:akshays-repo", 738 OwnerDid: "did:plc:akshay", 739 KnotURL: mustKnotURL(t, "https://knot.example"), 740 }, nil)) 741 742 if err := ing.ingestRepo(context.Background(), e); err != nil { 743 t.Fatalf("ingestRepo: %v", err) 744 } 745 if _, err := db.GetRepo(ing.Db, 746 orm.FilterEq("did", "did:plc:akshay"), 747 orm.FilterEq("rkey", "myrepo"), 748 ); !errors.Is(err, sql.ErrNoRows) { 749 t.Fatalf("row should not be created for spoofed knot, err=%v", err) 750 } 751 if spy.creates != 0 { 752 t.Errorf("NewRepo called %d times despite knot mismatch", spy.creates) 753 } 754} 755 756func TestIngestRepo_UpdateRejectsKnotMismatch(t *testing.T) { 757 ing, _ := newTestIngester(t) 758 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 759 760 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ 761 Knot: "evil.example", 762 Description: ptr("redirected clone target"), 763 RepoDid: ptr("did:plc:akshays-repo"), 764 }) 765 766 withVerifier(ing, stubVerifier(repoverify.Result{ 767 RepoDid: "did:plc:akshays-repo", 768 OwnerDid: "did:plc:akshay", 769 KnotURL: mustKnotURL(t, "https://knot.example"), 770 }, nil)) 771 772 if err := ing.ingestRepo(context.Background(), e); err != nil { 773 t.Fatalf("ingestRepo: %v", err) 774 } 775 akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 776 if akshay.Description == "redirected clone target" { 777 t.Errorf("update with spoofed knot applied: %+v", akshay) 778 } 779 if akshay.Knot != "knot.example" { 780 t.Errorf("row knot mutated to %q, want knot.example", akshay.Knot) 781 } 782} 783 784func TestIngestRepo_UpdateRejectsRepoDidMutation(t *testing.T) { 785 ing, _ := newTestIngester(t) 786 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 787 788 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ 789 Knot: "knot.example", 790 Description: ptr("sneaky repoDid swap"), 791 RepoDid: ptr("did:plc:other-repo"), 792 }) 793 794 withVerifier(ing, stubVerifier(repoverify.Result{ 795 RepoDid: "did:plc:other-repo", 796 OwnerDid: "did:plc:akshay", 797 KnotURL: mustKnotURL(t, "https://knot.example"), 798 }, nil)) 799 800 if err := ing.ingestRepo(context.Background(), e); err != nil { 801 t.Fatalf("ingestRepo: %v", err) 802 } 803 akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 804 if akshay.RepoDid != "did:plc:akshays-repo" { 805 t.Errorf("repoDid mutated to %q, want did:plc:akshays-repo", akshay.RepoDid) 806 } 807 if akshay.Description == "sneaky repoDid swap" { 808 t.Errorf("metadata from repoDid-mutating update applied: %+v", akshay) 809 } 810}