This repository has no description
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}