Monorepo for Tangled
tangled.org
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}