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