Monorepo for Tangled tangled.org
6

Configure Feed

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

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