This repository has no description
0

Configure Feed

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

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