diff --git a/cli/check_test.go b/cli/check_test.go new file mode 100644 index 0000000..34c56d5 --- /dev/null +++ b/cli/check_test.go @@ -0,0 +1,397 @@ +package main + +import ( + "fmt" + "testing" +) + +// Fake identity directory: handle <-> DID mappings +type fakeIdentity struct { + handleToDID map[string]string + didToHandle map[string]string +} + +func newFakeIdentity(mappings map[string]string) *fakeIdentity { + fi := &fakeIdentity{ + handleToDID: make(map[string]string), + didToHandle: make(map[string]string), + } + for handle, did := range mappings { + fi.handleToDID[handle] = did + fi.didToHandle[did] = handle + } + return fi +} + +func (fi *fakeIdentity) resolveHandle(handle string) (string, error) { + did, ok := fi.handleToDID[handle] + if !ok { + return "", fmt.Errorf("slingshot returned 404") + } + return did, nil +} + +func (fi *fakeIdentity) resolveDidToHandle(did string) (string, error) { + handle, ok := fi.didToHandle[did] + if !ok { + return "", fmt.Errorf("slingshot returned 404") + } + return handle, nil +} + +// fakeGraph represents a vouch graph: voucherDID -> set of subjectDIDs they vouch for. +// The microcosm API returns the reverse: given a target, who vouches for them. +type fakeGraph struct { + // vouches[subject] = set of DIDs that vouch for subject + vouchers map[string][]string +} + +func newFakeGraph() *fakeGraph { + return &fakeGraph{vouchers: make(map[string][]string)} +} + +// addVouch records that voucher vouches for subject. +func (fg *fakeGraph) addVouch(voucher, subject string) { + fg.vouchers[subject] = append(fg.vouchers[subject], voucher) +} + +// fetchVouchers mimics the microcosm API: returns DIDs that vouch for targetDID. +func (fg *fakeGraph) fetchVouchers(targetDID string) ([]string, error) { + return fg.vouchers[targetDID], nil +} + +func makeDeps(identity *fakeIdentity, graph *fakeGraph, myDID string, myVouches []string) checkDeps { + return checkDeps{ + myDID: myDID, + resolveHandle: identity.resolveHandle, + resolveDidToHandle: identity.resolveDidToHandle, + fetchVouchers: graph.fetchVouchers, + listMyVouches: func() ([]string, error) { + return myVouches, nil + }, + } +} + +func TestCheck_DirectVouch(t *testing.T) { + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "bob.bsky.social": "did:plc:bob", + }) + + graph := newFakeGraph() + // Graph doesn't matter for direct vouch — we check myVouches first + + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob"}) + + result, err := checkWithDeps("bob.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if result.targetDID != "did:plc:bob" { + t.Fatalf("expected targetDID did:plc:bob, got %s", result.targetDID) + } + if result.paths != nil { + t.Fatalf("expected nil paths for direct vouch, got %v", result.paths) + } +} + +func TestCheck_NoRoutes(t *testing.T) { + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "charlie.bsky.social": "did:plc:charlie", + }) + + graph := newFakeGraph() + // No one vouches for charlie + + deps := makeDeps(identity, graph, "did:plc:alice", []string{}) + + result, err := checkWithDeps("charlie.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 0 { + t.Fatalf("expected no paths, got %v", result.paths) + } +} + +func TestCheck_TwoHopPath(t *testing.T) { + // alice -> bob -> charlie + // alice vouches for bob, bob vouches for charlie + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "bob.bsky.social": "did:plc:bob", + "charlie.bsky.social": "did:plc:charlie", + }) + + graph := newFakeGraph() + graph.addVouch("did:plc:bob", "did:plc:charlie") // bob vouches for charlie + + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob"}) + + result, err := checkWithDeps("charlie.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 1 { + t.Fatalf("expected 1 path, got %d", len(result.paths)) + } + + path := result.paths[0] + expected := []string{"did:plc:alice", "did:plc:bob", "did:plc:charlie"} + if len(path) != len(expected) { + t.Fatalf("expected path len %d, got %d", len(expected), len(path)) + } + for i := range expected { + if path[i] != expected[i] { + t.Fatalf("path[%d]: expected %s, got %s", i, expected[i], path[i]) + } + } + + // Check handle resolution + if result.handleMap["did:plc:bob"] != "bob.bsky.social" { + t.Fatalf("expected bob handle, got %s", result.handleMap["did:plc:bob"]) + } +} + +func TestCheck_ThreeHopPath(t *testing.T) { + // alice -> bob -> carol -> dave + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "bob.bsky.social": "did:plc:bob", + "carol.bsky.social": "did:plc:carol", + "dave.bsky.social": "did:plc:dave", + }) + + graph := newFakeGraph() + graph.addVouch("did:plc:carol", "did:plc:dave") // carol vouches for dave + graph.addVouch("did:plc:bob", "did:plc:carol") // bob vouches for carol + + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob"}) + + result, err := checkWithDeps("dave.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 1 { + t.Fatalf("expected 1 path, got %d", len(result.paths)) + } + + path := result.paths[0] + expected := []string{"did:plc:alice", "did:plc:bob", "did:plc:carol", "did:plc:dave"} + if len(path) != len(expected) { + t.Fatalf("expected path len %d, got %d", len(expected), len(path)) + } + for i := range expected { + if path[i] != expected[i] { + t.Fatalf("path[%d]: expected %s, got %s", i, expected[i], path[i]) + } + } +} + +func TestCheck_MultipleRoutes(t *testing.T) { + // alice vouches for bob AND carol + // both bob and carol vouch for dave + // expected: two 2-hop paths + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "bob.bsky.social": "did:plc:bob", + "carol.bsky.social": "did:plc:carol", + "dave.bsky.social": "did:plc:dave", + }) + + graph := newFakeGraph() + graph.addVouch("did:plc:bob", "did:plc:dave") // bob vouches for dave + graph.addVouch("did:plc:carol", "did:plc:dave") // carol vouches for dave + + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob", "did:plc:carol"}) + + result, err := checkWithDeps("dave.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 2 { + t.Fatalf("expected 2 paths, got %d: %v", len(result.paths), result.paths) + } + + // Both paths should be 3 elements (2-hop) + for i, path := range result.paths { + if len(path) != 3 { + t.Fatalf("path %d: expected len 3, got %d", i, len(path)) + } + if path[0] != "did:plc:alice" || path[2] != "did:plc:dave" { + t.Fatalf("path %d: unexpected endpoints: %v", i, path) + } + } +} + +func TestCheck_HandleResolutionFallback(t *testing.T) { + // When slingshot can't resolve a DID to a handle, fall back to showing the DID + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "charlie.bsky.social": "did:plc:charlie", + // bob is NOT in identity — simulates slingshot failure + }) + + graph := newFakeGraph() + graph.addVouch("did:plc:bob", "did:plc:charlie") + + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob"}) + + result, err := checkWithDeps("charlie.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 1 { + t.Fatalf("expected 1 path, got %d", len(result.paths)) + } + + // bob's DID should fall back to raw DID since slingshot can't resolve it + if result.handleMap["did:plc:bob"] != "did:plc:bob" { + t.Fatalf("expected DID fallback for bob, got %s", result.handleMap["did:plc:bob"]) + } +} + +func TestCheck_UnresolvableHandle(t *testing.T) { + identity := newFakeIdentity(map[string]string{}) + + graph := newFakeGraph() + deps := makeDeps(identity, graph, "did:plc:alice", nil) + + _, err := checkWithDeps("nobody.bsky.social", deps) + if err == nil { + t.Fatal("expected error for unresolvable handle") + } +} + +func TestCheck_FourHopNotFound(t *testing.T) { + // alice -> bob -> carol -> dave -> eve + // This is 4 hops — beyond the 3-hop limit, so no path should be found + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "bob.bsky.social": "did:plc:bob", + "carol.bsky.social": "did:plc:carol", + "dave.bsky.social": "did:plc:dave", + "eve.bsky.social": "did:plc:eve", + }) + + graph := newFakeGraph() + graph.addVouch("did:plc:dave", "did:plc:eve") // dave vouches for eve + graph.addVouch("did:plc:carol", "did:plc:dave") // carol vouches for dave + graph.addVouch("did:plc:bob", "did:plc:carol") // bob vouches for carol + + // alice only vouches for bob, so the chain is 4 hops: alice->bob->carol->dave->eve + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob"}) + + result, err := checkWithDeps("eve.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 0 { + t.Fatalf("expected no paths for 4-hop chain, got %d: %v", len(result.paths), result.paths) + } +} + +func TestCheck_CyclicVouches(t *testing.T) { + // alice -> bob -> carol -> alice (cycle) + // alice checks carol: should find alice -> bob -> carol (2-hop) + // The cycle back to alice should not cause infinite loops or duplicate paths + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "bob.bsky.social": "did:plc:bob", + "carol.bsky.social": "did:plc:carol", + }) + + graph := newFakeGraph() + graph.addVouch("did:plc:bob", "did:plc:carol") // bob vouches for carol + graph.addVouch("did:plc:carol", "did:plc:alice") // carol vouches for alice + graph.addVouch("did:plc:alice", "did:plc:bob") // alice vouches for bob + + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob"}) + + result, err := checkWithDeps("carol.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 1 { + t.Fatalf("expected 1 path, got %d: %v", len(result.paths), result.paths) + } + + expected := []string{"did:plc:alice", "did:plc:bob", "did:plc:carol"} + path := result.paths[0] + if len(path) != len(expected) { + t.Fatalf("expected path len %d, got %d", len(expected), len(path)) + } + for i := range expected { + if path[i] != expected[i] { + t.Fatalf("path[%d]: expected %s, got %s", i, expected[i], path[i]) + } + } +} + +func TestCheck_MutualVouches(t *testing.T) { + // alice and bob vouch for each other, bob and carol vouch for each other + // alice checks carol: should find alice -> bob -> carol (2-hop) + // and NOT produce spurious paths from the mutual edges + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + "bob.bsky.social": "did:plc:bob", + "carol.bsky.social": "did:plc:carol", + }) + + graph := newFakeGraph() + graph.addVouch("did:plc:alice", "did:plc:bob") // alice vouches for bob + graph.addVouch("did:plc:bob", "did:plc:alice") // bob vouches for alice + graph.addVouch("did:plc:bob", "did:plc:carol") // bob vouches for carol + graph.addVouch("did:plc:carol", "did:plc:bob") // carol vouches for bob + + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:bob"}) + + result, err := checkWithDeps("carol.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + if len(result.paths) != 1 { + t.Fatalf("expected 1 path, got %d: %v", len(result.paths), result.paths) + } + + expected := []string{"did:plc:alice", "did:plc:bob", "did:plc:carol"} + path := result.paths[0] + if len(path) != len(expected) { + t.Fatalf("expected path len %d, got %d", len(expected), len(path)) + } + for i := range expected { + if path[i] != expected[i] { + t.Fatalf("path[%d]: expected %s, got %s", i, expected[i], path[i]) + } + } +} + +func TestCheck_SelfVouch(t *testing.T) { + // Check what happens when checking yourself (you vouch for yourself) + identity := newFakeIdentity(map[string]string{ + "alice.bsky.social": "did:plc:alice", + }) + + graph := newFakeGraph() + deps := makeDeps(identity, graph, "did:plc:alice", []string{"did:plc:alice"}) + + result, err := checkWithDeps("alice.bsky.social", deps) + if err != nil { + t.Fatal(err) + } + + // Should be detected as direct vouch + if result.paths != nil { + t.Fatalf("expected nil paths (direct vouch), got %v", result.paths) + } +} diff --git a/cli/main.go b/cli/main.go index dbfaeb5..f5e0579 100644 --- a/cli/main.go +++ b/cli/main.go @@ -230,6 +230,22 @@ func create(ctx context.Context, handle string) error { return nil } +// checkDeps holds injectable dependencies for the check logic. +type checkDeps struct { + myDID string + resolveHandle func(handle string) (string, error) + resolveDidToHandle func(did string) (string, error) + fetchVouchers func(targetDID string) ([]string, error) + listMyVouches func() ([]string, error) +} + +// checkResult holds the output of a check operation. +type checkResult struct { + targetDID string + paths [][]string // each path is a list of DIDs + handleMap map[string]string // DID -> handle +} + func check(ctx context.Context, handle string) error { session, err := resumeSession(ctx) if err != nil { @@ -239,25 +255,63 @@ func check(ctx context.Context, handle string) error { client := session.APIClient() myDID := session.Data.AccountDID.String() - // Resolve target handle to DID - targetDID, err := slingshotResolveHandle(handle) + deps := checkDeps{ + myDID: myDID, + resolveHandle: slingshotResolveHandle, + resolveDidToHandle: slingshotResolveDidToHandle, + fetchVouchers: fetchVouchersFromMicrocosm, + listMyVouches: func() ([]string, error) { + return listVouchSubjects(ctx, client, myDID) + }, + } + + result, err := checkWithDeps(handle, deps) if err != nil { - return fmt.Errorf("resolving handle %q: %w", handle, err) + return err } - fmt.Printf("Checking vouch paths to %s (%s)...\n", handle, targetDID) + fmt.Printf("Checking vouch paths to %s (%s)...\n", handle, result.targetDID) + + if result.paths == nil { + fmt.Printf("\nyou -> %s\n", handle) + return nil + } - // Fetch my vouches (people I vouch for) - myVouches, err := listVouchSubjects(ctx, client, myDID) + if len(result.paths) == 0 { + fmt.Println("no vouch routes found") + return nil + } + + fmt.Printf("\nFound %d vouch route(s):\n", len(result.paths)) + for _, path := range result.paths { + parts := make([]string, len(path)) + for i, did := range path { + parts[i] = result.handleMap[did] + } + fmt.Println(strings.Join(parts, " -> ")) + } + + return nil +} + +// checkWithDeps contains the core check logic with injected dependencies. +// Returns a checkResult where paths == nil means direct vouch found, +// paths == empty means no routes, otherwise contains discovered paths. +func checkWithDeps(handle string, deps checkDeps) (*checkResult, error) { + targetDID, err := deps.resolveHandle(handle) + if err != nil { + return nil, fmt.Errorf("resolving handle %q: %w", handle, err) + } + + myVouches, err := deps.listMyVouches() if err != nil { - return fmt.Errorf("fetching your vouches: %w", err) + return nil, fmt.Errorf("fetching your vouches: %w", err) } // Direct vouch check (depth 1) for _, did := range myVouches { if did == targetDID { - fmt.Printf("\nyou -> %s\n", handle) - return nil + return &checkResult{targetDID: targetDID, paths: nil}, nil } } @@ -266,18 +320,18 @@ func check(ctx context.Context, handle string) error { reverseGraph := make(map[string]map[string]bool) // Level 1: who vouches for target - level1, err := fetchVouchersFromMicrocosm(targetDID) + level1, err := deps.fetchVouchers(targetDID) if err != nil { - return fmt.Errorf("querying microcosm: %w", err) + return nil, fmt.Errorf("querying microcosm: %w", err) } reverseGraph[targetDID] = toSet(level1) // Level 2: who vouches for each level-1 voucher level2DIDs := []string{} for _, did := range level1 { - vouchers, err := fetchVouchersFromMicrocosm(did) + vouchers, err := deps.fetchVouchers(did) if err != nil { - return fmt.Errorf("querying microcosm: %w", err) + return nil, fmt.Errorf("querying microcosm: %w", err) } reverseGraph[did] = toSet(vouchers) level2DIDs = append(level2DIDs, vouchers...) @@ -288,24 +342,21 @@ func check(ctx context.Context, handle string) error { if _, exists := reverseGraph[did]; exists { continue // already fetched } - vouchers, err := fetchVouchersFromMicrocosm(did) + vouchers, err := deps.fetchVouchers(did) if err != nil { - return fmt.Errorf("querying microcosm: %w", err) + return nil, fmt.Errorf("querying microcosm: %w", err) } reverseGraph[did] = toSet(vouchers) } // Find all paths: me -> (someone I vouch for) -> ... -> target - // A path me -> A -> B -> target means: - // I vouch for A, A vouches for B, B vouches for target - // In reverseGraph terms: B is in reverseGraph[target], A is in reverseGraph[B] myVouchSet := toSet(myVouches) var paths [][]string // Depth 2: me -> X -> target (X vouches for target, I vouch for X) for voucher := range reverseGraph[targetDID] { if myVouchSet[voucher] { - paths = append(paths, []string{myDID, voucher, targetDID}) + paths = append(paths, []string{deps.myDID, voucher, targetDID}) } } @@ -313,48 +364,40 @@ func check(ctx context.Context, handle string) error { for yDID := range reverseGraph[targetDID] { for xDID := range reverseGraph[yDID] { if myVouchSet[xDID] { - paths = append(paths, []string{myDID, xDID, yDID, targetDID}) + paths = append(paths, []string{deps.myDID, xDID, yDID, targetDID}) } } } - if len(paths) == 0 { - fmt.Println("no vouch routes found") - return nil - } - // Resolve all unique DIDs to handles for display - uniqueDIDs := make(map[string]bool) - for _, path := range paths { - for _, did := range path { - uniqueDIDs[did] = true - } - } - handleMap := make(map[string]string) - handleMap[targetDID] = handle // we already know this one - for did := range uniqueDIDs { - if _, exists := handleMap[did]; exists { - continue - } - resolved, err := slingshotResolveDidToHandle(did) - if err != nil { - handleMap[did] = did // fallback to DID - } else { - handleMap[did] = resolved + if len(paths) > 0 { + uniqueDIDs := make(map[string]bool) + for _, path := range paths { + for _, did := range path { + uniqueDIDs[did] = true + } } - } - fmt.Printf("\nFound %d vouch route(s):\n", len(paths)) - for _, path := range paths { - parts := make([]string, len(path)) - for i, did := range path { - parts[i] = handleMap[did] + handleMap[targetDID] = handle // we already know this one + for did := range uniqueDIDs { + if _, exists := handleMap[did]; exists { + continue + } + resolved, err := deps.resolveDidToHandle(did) + if err != nil { + handleMap[did] = did // fallback to DID + } else { + handleMap[did] = resolved + } } - fmt.Println(strings.Join(parts, " -> ")) } - return nil + return &checkResult{ + targetDID: targetDID, + paths: paths, + handleMap: handleMap, + }, nil } // listVouchSubjects returns the DIDs that the given repo has vouched for.