Something went wrong. Try again.
An ATProto Powered Reputation Network
Something went wrong. Try again.
12 kB · 318 lines
Go
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319package appview
import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time"
"provenance/internal/domain" "provenance/internal/storage" "provenance/internal/trust" "provenance/internal/trust/algorithms")
const alice = "did:plc:alice"const bob = "did:plc:bob"const serviceKey = "test-service-key-with-at-least-32-characters"
type repository struct { records []domain.Vouch err error}
func (r *repository) Snapshot(context.Context, string) ([]domain.Vouch, error) { return r.records, r.err}
func (r *repository) Resolve(_ context.Context, handle string) (string, error) { if r.err != nil { return "", r.err } switch handle { case "alice.example.com": return alice, nil case "bob.example.com": return bob, nil default: return "", errors.New("account not found") }}
func TestPublicAccountReputation(t *testing.T) { ctx := context.Background() store, err := storage.Open(filepath.Join(t.TempDir(), "appview.db")) if err != nil { t.Fatal(err) } defer store.Close() repo := &repository{} service := New(store, repo, trust.Limits{States: 100, Edges: 100, Penalty: .1}) if err := service.Join(ctx, alice); err != nil { t.Fatal(err) } if err := store.Replace(ctx, alice, []domain.Vouch{{Author: alice, Subject: bob, Score: 1}, {Author: alice, Subject: bob, Tag: "work", Score: 2}}, time.Now()); err != nil { t.Fatal(err) } handler := Handler(service, serviceKey) for _, tt := range []struct { name, source, target, tag, path string status int score float64 }{ {"DIDs", alice, bob, "", "", http.StatusOK, 1}, {"handles", "alice.example.com", "bob.example.com", "", "true", http.StatusOK, 1}, {"mixed", alice, "bob.example.com", "work", "true", http.StatusOK, 2}, {"normalized", " @ALICE.EXAMPLE.COM ", bob, "", "", http.StatusOK, 1}, {"self", "alice.example.com", alice, "", "", http.StatusOK, 3}, {"missing source", "", bob, "", "", http.StatusBadRequest, 0}, {"invalid target", alice, "https://pds.example.com", "", "", http.StatusBadRequest, 0}, {"unresolved handle", alice, "missing.example.com", "", "", http.StatusBadRequest, 0}, } { t.Run(tt.name, func(t *testing.T) { params := url.Values{"source": {tt.source}, "target": {tt.target}, "tag": {tt.tag}, "include_path": {tt.path}} w := httptest.NewRecorder() handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/trust?"+params.Encode(), nil)) if w.Code != tt.status { t.Fatalf("response: %d %s", w.Code, w.Body) } if tt.status != http.StatusOK { return } var result trust.Result if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Score == nil || *result.Score != tt.score { t.Fatalf("result: %+v", result) } if strings.Contains(w.Body.String(), "trusted") || strings.Contains(w.Body.String(), "threshold") { t.Fatal("public query exposed private configuration") } if tt.path == "true" && (len(result.Path) != 2 || len(result.PathScores) != 2) { t.Fatal("requested path absent") } if tt.path != "true" && (result.Path != nil || result.PathScores != nil) { t.Fatal("unrequested path exposed") } }) } repo.err = errors.New("identity lookup unavailable") w := httptest.NewRecorder() handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/trust?source=alice.example.com&target="+bob, nil)) if w.Code == http.StatusOK { t.Fatal("identity lookup failure returned a score") }}
func TestAuthenticatedAPI(t *testing.T) { ctx := context.Background() store, err := storage.Open(filepath.Join(t.TempDir(), "appview.db")) if err != nil { t.Fatal(err) } defer store.Close() repo := &repository{records: []domain.Vouch{{Author: alice, Subject: bob, Score: 1}}} service := New(store, repo, trust.Limits{States: 100, Edges: 100, Penalty: .1}) if err := service.Join(ctx, alice); err != nil { t.Fatal(err) } if err := service.Sync(ctx, alice); err != nil { t.Fatal(err) } handler := Handler(service, serviceKey) call := func(method, path, body, key string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, path, strings.NewReader(body)) r.Header.Set(IdentityHeader, alice) if key != "" { r.Header.Set("Authorization", "Bearer "+key) } w := httptest.NewRecorder() handler.ServeHTTP(w, r) return w } for _, path := range []string{"/v1/me", "/v1/trusted?target=" + bob, "/v1/me/trust?target=" + bob} { w := call(http.MethodGet, path, "", "") if w.Code != http.StatusUnauthorized { t.Fatalf("anonymous %s returned %d", path, w.Code) } } w := call(http.MethodGet, "/v1/trusted?target="+bob, "", serviceKey) if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"trusted":true}` { t.Fatalf("trusted response: %d %s", w.Code, w.Body) } w = call(http.MethodGet, "/v1/me/trust?target="+bob, "", serviceKey) var trusted Evaluation if err := json.Unmarshal(w.Body.Bytes(), &trusted); err != nil { t.Fatal(err) } if len(trusted.PathScores) != 2 || trusted.PathScores[1].Assertion == nil || *trusted.PathScores[1].Assertion != 1 || trusted.PathScores[1].Score != 1 { t.Fatalf("trusted path scores absent: %s", w.Body) } w = call(http.MethodPut, "/v1/me/threshold", `{"threshold":1.1}`, serviceKey) if w.Code != http.StatusOK { t.Fatalf("threshold response: %s", w.Body) } w = call(http.MethodGet, "/v1/me/trust?target="+bob, "", serviceKey) var evaluation Evaluation if err := json.Unmarshal(w.Body.Bytes(), &evaluation); err != nil { t.Fatal(err) } if evaluation.Trusted || evaluation.Path != nil || evaluation.PathScores != nil { t.Fatalf("untrusted result exposed a path: %+v", evaluation) } w = call(http.MethodGet, "/v1/me/trust?target="+bob+"&include_path=true", "", serviceKey) if err := json.Unmarshal(w.Body.Bytes(), &evaluation); err != nil { t.Fatal(err) } if len(evaluation.Path) != 2 || len(evaluation.PathScores) != 2 { t.Fatalf("explicit path absent: %s", w.Body) } w = call(http.MethodGet, "/v1/trusted?target="+alice, "", serviceKey) if strings.TrimSpace(w.Body.String()) != `{"trusted":true}` { t.Fatal("self is not trusted") } w = call(http.MethodPut, "/v1/me/threshold", `{"threshold":3}`, serviceKey) if w.Code != http.StatusBadRequest { t.Fatal("accepted invalid threshold") } w = call(http.MethodGet, "/v1/trust?source="+alice+"&target="+bob, "", "") if strings.Contains(w.Body.String(), "threshold") || strings.Contains(w.Body.String(), "trusted") { t.Fatal("public query exposed private configuration") } if strings.Contains(w.Body.String(), `"path`) { t.Fatal("public query exposed an unrequested path") } w = call(http.MethodGet, "/v1/trust?source="+alice+"&target="+bob+"&include_path=true", "", "") var public trust.Result if err := json.Unmarshal(w.Body.Bytes(), &public); err != nil { t.Fatal(err) } if len(public.PathScores) != 2 { t.Fatalf("public path scores absent: %s", w.Body) } if err := store.Replace(ctx, alice, []domain.Vouch{{Author: alice, Subject: bob, Score: -3}}, time.Now()); err != nil { t.Fatal(err) } if err := service.SetThreshold(ctx, alice, -2); err != nil { t.Fatal(err) } result, err := service.Evaluate(ctx, alice, bob, "", domain.Default) if err != nil || result.Trusted || result.Score == nil || *result.Score != -3 { t.Fatalf("block result: %+v %v", result, err) }}
func TestFailedSyncPreservesIndex(t *testing.T) { ctx := context.Background() store, err := storage.Open(filepath.Join(t.TempDir(), "appview.db")) if err != nil { t.Fatal(err) } defer store.Close() repo := &repository{records: []domain.Vouch{{Author: alice, Subject: bob, Tag: "work", Score: 2}}} s := New(store, repo, trust.Limits{States: 100, Edges: 100, Penalty: .1}) if err := s.Join(ctx, alice); err != nil { t.Fatal(err) } if err := s.Sync(ctx, alice); err != nil { t.Fatal(err) } repo.err = errors.New("PDS unavailable") if err := s.Sync(ctx, alice); err == nil { t.Fatal("sync hid error") } result, err := s.Reputation(ctx, alice, bob, "work", domain.Tagged) if err != nil || result.Score == nil || *result.Score != 2 { t.Fatalf("previous index lost: %+v %v", result, err) } repo.err = nil repo.records = nil if err := s.Sync(ctx, alice); err != nil { t.Fatal(err) } result, err = s.Reputation(ctx, alice, bob, "work", domain.Tagged) if err != nil || result.Score != nil { t.Fatal("deleted record remains indexed") }}
type testAlgorithm struct { calculate func(context.Context, []domain.Vouch, string, string, trust.Limits) (trust.Result, error)}
func (a testAlgorithm) Describe() trust.Descriptor { return trust.Descriptor{Name: "Test"} }
func (a testAlgorithm) Calculate(ctx context.Context, input trust.Input) (trust.Result, error) { return a.calculate(ctx, input.Edges, input.Source, input.Target, input.Limits)}
func TestCustomTrustAlgorithm(t *testing.T) { ctx := context.Background() store, err := storage.Open(filepath.Join(t.TempDir(), "appview.db")) if err != nil { t.Fatal(err) } defer store.Close() repo := &repository{records: []domain.Vouch{ {Author: alice, Subject: bob, Score: 1}, {Author: alice, Subject: bob, Tag: "work", Score: 2}, }} limits := trust.Limits{States: 100, Edges: 100, Penalty: .1} const customScore = .75 calls := 0 failure := errors.New("custom algorithm failed") var algorithmError error algorithm := func(gotCtx context.Context, edges []domain.Vouch, source, target string, gotLimits trust.Limits) (trust.Result, error) { calls++ if gotCtx != ctx || source != alice || gotLimits != limits { t.Fatal("algorithm received incorrect query context") } if source == target { if len(edges) != 0 { t.Fatal("self query loaded graph edges") } return (algorithms.Optimistic{}).Calculate(gotCtx, trust.Input{Edges: edges, Source: source, Target: target, Limits: gotLimits}) } if target != bob || len(edges) != 1 || edges[0].Tag != "work" || edges[0].Score != 2 { t.Fatalf("algorithm received incorrect selected edges: %+v", edges) } score := customScore return trust.Result{Status: trust.CalculatedStatus, Score: &score}, algorithmError } service := NewWithAlgorithm(store, repo, limits, testAlgorithm{calculate: algorithm}) if err := service.Join(ctx, alice); err != nil { t.Fatal(err) } result, err := service.Reputation(ctx, alice, bob, "work", domain.Fallback) if err != nil || result.Score == nil || *result.Score != customScore { t.Fatalf("custom result: %+v %v", result, err) } if err := service.SetThreshold(ctx, alice, customScore); err != nil { t.Fatal(err) } evaluation, err := service.Evaluate(ctx, alice, bob, "work", domain.Fallback) if err != nil || !evaluation.Trusted { t.Fatalf("custom evaluation: %+v %v", evaluation, err) } algorithmError = failure service.now = func() time.Time { return time.Now().Add(trust.DefaultCacheTTL) } if _, err := service.Reputation(ctx, alice, bob, "work", domain.Fallback); !errors.Is(err, failure) { t.Fatalf("algorithm error lost: %v", err) } self, err := service.Reputation(ctx, alice, alice, "", domain.Default) if err != nil || self.Status != trust.SelfStatus || calls != 3 { t.Fatalf("self dispatch: %+v %v; calls = %d", self, err, calls) } defaultService := NewWithAlgorithm(store, repo, limits, nil) result, err = defaultService.Reputation(ctx, alice, bob, "work", domain.Fallback) if err != nil || result.Score == nil || *result.Score != 2 { t.Fatalf("default algorithm: %+v %v", result, err) }}