Something went wrong. Try again.
An ATProto Powered Reputation Network
Something went wrong. Try again.
16 kB · 404 lines
Go
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405package ui
import ( "context" "encoding/base64" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time"
"github.com/bluesky-social/indigo/atproto/atclient"
"provenance/internal/appview" "provenance/internal/atproto" "provenance/internal/domain" "provenance/internal/httpx" "provenance/internal/reputation" "provenance/internal/secure" "provenance/internal/storage" "provenance/internal/trust" "provenance/internal/trust/algorithms")
const aliceDID = "did:plc:alice"const bobDID = "did:plc:bob"const origin = "https://app.example.com"
type fakePDS struct { records []domain.Vouch failSync bool snapshots int writeErr error}
func (f *fakePDS) Snapshot(context.Context, string) ([]domain.Vouch, error) { f.snapshots++ if f.failSync { return nil, errors.New("PDS unavailable") } return f.records, nil}
func TestLoginBootstrapsOnce(t *testing.T) { ctx := context.Background() store, err := storage.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } defer store.Close() pds := &fakePDS{} service := appview.New(store, pds, trust.Limits{States: 100, Edges: 100, Penalty: .1}) if err := service.Join(ctx, aliceDID); err != nil { t.Fatal(err) } if pds.snapshots != 1 { t.Fatalf("first opt-in performed %d snapshots, want one", pds.snapshots) } if err := service.Join(ctx, aliceDID); err != nil { t.Fatal(err) } if pds.snapshots != 1 { t.Fatalf("repeat login performed %d snapshots", pds.snapshots) }}func (f *fakePDS) Metadata() any { return map[string]string{"client_id": origin + "/oauth-client-metadata.json"}}func (f *fakePDS) Begin(context.Context, string, string) (string, error) { return "https://pds.example.com/authorize", nil}func (f *fakePDS) Finish(context.Context, url.Values, string) (atproto.Session, error) { return atproto.Session{DID: aliceDID, ID: "oauth"}, nil}func (f *fakePDS) PutVouch(_ context.Context, session atproto.Session, v domain.Vouch) error { if f.writeErr != nil { return f.writeErr } v.Author = session.DID f.records = []domain.Vouch{v} return nil}func (f *fakePDS) DeleteVouch(context.Context, atproto.Session, string, string) error { f.records = nil return nil}func (f *fakePDS) Logout(context.Context, atproto.Session) error { return nil }func (f *fakePDS) Resolve(_ context.Context, value string) (string, error) { if value == "bob.example.com" { return bobDID, nil } if err := atproto.ValidDID(value); err != nil { return "", err } return value, nil}func (f *fakePDS) Search(context.Context, string) ([]atproto.Actor, error) { return []atproto.Actor{{DID: bobDID, Handle: "bob.example.com", DisplayName: "<script>alert(1)</script>"}}, nil}
func TestUIAndAppview(t *testing.T) { ctx := context.Background() dir := t.TempDir() appStore, err := storage.Open(filepath.Join(dir, "app.db")) if err != nil { t.Fatal(err) } defer appStore.Close() uiStore, err := storage.Open(filepath.Join(dir, "ui.db")) if err != nil { t.Fatal(err) } defer uiStore.Close() pds := &fakePDS{} key := "test-service-key-with-at-least-32-characters" index := appview.New(appStore, pds, trust.Limits{States: 100, Edges: 100, Penalty: .1}) appServer := httptest.NewServer(appview.Handler(index, key)) defer appServer.Close() appClient, err := appview.NewClient(appServer.URL, key) if err != nil { t.Fatal(err) } vault, err := secure.New(uiStore, base64.StdEncoding.EncodeToString(make([]byte, secure.KeyBytes))) if err != nil { t.Fatal(err) } service := New(pds, pds, appClient, vault) token, err := service.Finish(ctx, url.Values{}, "browser") if err != nil { t.Fatal(err) } b, err := service.Session(ctx, token) if err != nil { t.Fatal(err) } handler := Handler(service, origin) call := func(method, path, body, csrf, requestOrigin string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, path, strings.NewReader(body)) r.AddCookie(&http.Cookie{Name: "__Host-" + sessionCookie, Value: token}) r.Header.Set("Origin", requestOrigin) r.Header.Set(csrfHeader, csrf) w := httptest.NewRecorder() handler.ServeHTTP(w, r) return w } w := call(http.MethodGet, "/", "", "", "") if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `id="vouch-form"`) || !strings.Contains(w.Body.String(), "/app.js") { t.Fatalf("authenticated page: %d %s", w.Code, w.Body) } w = call(http.MethodGet, "/check", "", "", "") if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `id="query-form"`) || strings.Contains(w.Body.String(), `id="vouch-form"`) { t.Fatalf("reputation view: %d %s", w.Code, w.Body) } if !strings.Contains(w.Body.String(), `id="include-path"`) || !strings.Contains(w.Body.String(), `href="/reputation"`) { t.Fatal("trust tab lost path controls or reputation navigation") } w = call(http.MethodGet, "/reputation", "", "", "") if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `data-calculation="reputation"`) || strings.Contains(w.Body.String(), `id="include-path"`) || !strings.Contains(w.Body.String(), `href="/check"`) { t.Fatalf("aggregate view: %d %s", w.Code, w.Body) } unauthenticated := httptest.NewRecorder() handler.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/aggregate?target="+bobDID, nil)) if unauthenticated.Code != http.StatusUnauthorized { t.Fatalf("aggregate requires a session: %d", unauthenticated.Code) } w = call(http.MethodGet, "/api/me", "", "", "") if w.Code != http.StatusOK || strings.Contains(w.Body.String(), "oauth") { t.Fatalf("me response: %d %s", w.Code, w.Body) } for _, test := range []struct{ csrf, origin string }{{"", origin}, {b.CSRF, "https://evil.example.com"}} { w = call(http.MethodPut, "/api/vouch", `{"subject":"bob.example.com","tag":"work","score":2}`, test.csrf, test.origin) if w.Code != http.StatusForbidden || len(pds.records) != 0 { t.Fatal("CSRF request modified PDS") } } longComment, err := json.Marshal(strings.Repeat("界", domain.MaxCommentLength+1)) if err != nil { t.Fatal(err) } w = call(http.MethodPut, "/api/vouch", `{"subject":"bob.example.com","tag":"work","score":2,"comment":`+string(longComment)+`}`, b.CSRF, origin) if w.Code != http.StatusBadRequest || len(pds.records) != 0 { t.Fatalf("invalid comment publication: %d %s", w.Code, w.Body) } w = call(http.MethodPut, "/api/vouch", `{"subject":" @BOB.EXAMPLE.COM ","tag":"work","score":2,"comment":"A reference <script> only."}`, b.CSRF, origin) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"indexed":false`) { t.Fatalf("publish response: %d %s", w.Code, w.Body) } pds.writeErr = &atclient.APIError{StatusCode: http.StatusInternalServerError, Message: "private upstream details"} w = call(http.MethodPut, "/api/vouch", `{"subject":"bob.example.com","tag":"work","score":0}`, b.CSRF, origin) if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), `"pds_write_failed"`) || strings.Contains(w.Body.String(), "private upstream details") || pds.records[0].Score != 2 { t.Fatalf("failed write: %d %s", w.Code, w.Body) } pds.writeErr = nil if pds.snapshots != 1 { t.Fatal("vouch publication triggered a PDS snapshot") } vouch := pds.records[0] stamp := time.Now().UnixMicro() if err := index.Apply(ctx, domain.Change{Kind: domain.RecordChange, DID: aliceDID, TimeUS: stamp, RecordKey: domain.RecordKey(vouch.Subject, vouch.Tag), Vouch: &vouch}); err != nil { t.Fatal(err) } w = call(http.MethodGet, "/api/reputation?target=bob.example.com&tag=work&mode=fallback", "", "", "") var result appview.Evaluation if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { t.Fatal(err) } if !result.Trusted || result.Score == nil || *result.Score != 2 { t.Fatalf("trust query: %s", w.Body) } if len(result.PathScores) != 2 || result.PathScores[0].Comment != "" || result.PathScores[1].Comment != "A reference <script> only." { t.Fatalf("path comments: %s", w.Body) } w = call(http.MethodGet, "/api/aggregate?target=bob.example.com&tag=work&mode=fallback", "", "", "") var aggregate reputation.Result if err := json.Unmarshal(w.Body.Bytes(), &aggregate); err != nil || w.Code != http.StatusOK || aggregate.AbsoluteScore != 2 || aggregate.MeanScore != 2 || aggregate.Score != 2 { t.Fatalf("aggregate query: %d %s %v", w.Code, w.Body, err) } w = call(http.MethodGet, "/api/aggregate?target=bob.example.com&mode=default", "", "", "") if err := json.Unmarshal(w.Body.Bytes(), &aggregate); err != nil || w.Code != http.StatusOK || aggregate.AbsoluteScore != 0 || aggregate.MeanScore != 0 { t.Fatalf("empty aggregate: %d %s %v", w.Code, w.Body, err) } for _, query := range []string{"target=bob.example.com&include_path=true", "target=bob.example.com&threshold=-2", "target=bob.example.com&target=" + aliceDID, "target=bob.example.com&mode=tag", "target=%zz"} { w = call(http.MethodGet, "/api/aggregate?"+query, "", "", "") if w.Code != http.StatusBadRequest { t.Fatalf("invalid aggregate query: %s: %d %s", query, w.Code, w.Body) } } const carolDID = "did:plc:carol" const daveDID = "did:plc:dave" for _, did := range []string{carolDID, daveDID} { if err := appStore.Join(ctx, did); err != nil { t.Fatal(err) } } // Include a negative assertion and a zero assertion to distinguish the sum from the mean. authorVouches := []domain.Vouch{ {Author: aliceDID, Subject: carolDID, Tag: "work", Score: 1}, {Author: aliceDID, Subject: daveDID, Tag: "work", Score: 2}, {Author: carolDID, Subject: bobDID, Tag: "work", Score: -2}, {Author: daveDID, Subject: bobDID, Tag: "work", Score: 0}, } for _, v := range authorVouches { stamp++ if err := index.Apply(ctx, domain.Change{Kind: domain.RecordChange, DID: v.Author, TimeUS: stamp, RecordKey: domain.RecordKey(v.Subject, v.Tag), Vouch: &v}); err != nil { t.Fatal(err) } } w = call(http.MethodGet, "/api/aggregate?target="+bobDID+"&tag=work&mode=tag", "", "", "") if err := json.Unmarshal(w.Body.Bytes(), &aggregate); err != nil || w.Code != http.StatusOK || aggregate.AbsoluteScore != 0 || aggregate.MeanScore != 0 { t.Fatalf("signed aggregate: %d %s %v", w.Code, w.Body, err) } w = call(http.MethodGet, "/api/tags?prefix=w", "", "", "") if strings.TrimSpace(w.Body.String()) != `["work"]` { t.Fatalf("tags: %s", w.Body) } w = call(http.MethodPut, "/api/threshold", `{"threshold":1.5}`, b.CSRF, origin) if w.Code != http.StatusOK { t.Fatalf("threshold: %s", w.Body) } w = call(http.MethodGet, "/api/aggregate?target="+bobDID+"&tag=work&mode=tag", "", "", "") if err := json.Unmarshal(w.Body.Bytes(), &aggregate); err != nil || w.Code != http.StatusOK || aggregate.AbsoluteScore != 2 || aggregate.MeanScore != 1 { t.Fatalf("private threshold and zero assertion: %d %s %v", w.Code, w.Body, err) } for _, v := range authorVouches { stamp++ if err := index.Apply(ctx, domain.Change{Kind: domain.RecordChange, DID: v.Author, TimeUS: stamp, RecordKey: domain.RecordKey(v.Subject, v.Tag)}); err != nil { t.Fatal(err) } } w = call(http.MethodGet, "/api/vouches", "", "", "") if !strings.Contains(w.Body.String(), bobDID) { t.Fatal("published vouch absent") } pds.failSync = true w = call(http.MethodPut, "/api/vouch", `{"subject":"bob.example.com","tag":"work","score":0}`, b.CSRF, origin) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"saved":true,"indexed":false`) { t.Fatalf("partial success response: %s", w.Body) } pds.failSync = false w = call(http.MethodDelete, "/api/vouch", `{"subject":"bob.example.com","tag":"work"}`, b.CSRF, origin) if w.Code != http.StatusOK { t.Fatalf("delete: %s", w.Body) } if pds.snapshots != 1 { t.Fatal("vouch update or removal triggered a PDS snapshot") } if err := index.Apply(ctx, domain.Change{Kind: domain.RecordChange, DID: aliceDID, TimeUS: stamp + 1, RecordKey: domain.RecordKey(bobDID, "work")}); err != nil { t.Fatal(err) } w = call(http.MethodGet, "/api/tags?prefix=w", "", "", "") if strings.TrimSpace(w.Body.String()) != `[]` { t.Fatal("deleted tag remains") } w = call(http.MethodPost, "/api/logout", "", b.CSRF, origin) if w.Code != http.StatusOK { t.Fatal("logout failed") } w = call(http.MethodGet, "/api/me", "", "", "") if w.Code != http.StatusUnauthorized { t.Fatal("logged-out session accepted") }}
func TestLoginRequiresOrigin(t *testing.T) { pds := &fakePDS{} handler := httpx.Headers(Handler(New(pds, pds, nil, nil), origin)) r := httptest.NewRequest(http.MethodPost, "/oauth/login", strings.NewReader("identifier=alice.example.com")) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") for _, badOrigin := range []string{"", "null", "https://evil.example.com"} { r.Header.Set("Origin", badOrigin) w := httptest.NewRecorder() handler.ServeHTTP(w, r) if w.Code != http.StatusForbidden { t.Fatalf("login accepted invalid origin %q", badOrigin) } } r.Header.Set("Origin", origin) w := httptest.NewRecorder() handler.ServeHTTP(w, r) if w.Code != http.StatusSeeOther || len(w.Result().Cookies()) != 1 { t.Fatal("login did not create a browser binding") } cookie := w.Result().Cookies()[0] if cookie.Name != "__Host-"+flowCookie || !cookie.Secure || !cookie.HttpOnly || cookie.Path != "/" || cookie.Domain != "" { t.Fatalf("unsafe OAuth cookie: %+v", cookie) }}
func TestLoginAccountSuggestions(t *testing.T) { pds := &fakePDS{} handler := Handler(New(pds, pds, nil, nil), origin) w := httptest.NewRecorder() handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/accounts?q=bob", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "bob.example.com") { t.Fatalf("login suggestions unavailable: %d %s", w.Code, w.Body) }}
func TestUIAlgorithmOptions(t *testing.T) { const intermediary = "did:plc:carol" const key = "test-service-key-with-at-least-32-characters" for _, algorithm := range []trust.Algorithm{algorithms.Optimistic{}, algorithms.ShortestPath{}} { t.Run(algorithm.Describe().Name, func(t *testing.T) { ctx := context.Background() appStore, err := storage.Open(filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatal(err) } defer appStore.Close() uiStore, err := storage.Open(filepath.Join(t.TempDir(), "ui.db")) if err != nil { t.Fatal(err) } defer uiStore.Close() pds := &fakePDS{} index := appview.NewWithAlgorithm(appStore, pds, trust.Limits{States: 100, Edges: 100, Penalty: .1}, algorithm) server := httptest.NewServer(appview.Handler(index, key)) defer server.Close() client, err := appview.NewClient(server.URL, key) if err != nil { t.Fatal(err) } vault, err := secure.New(uiStore, base64.StdEncoding.EncodeToString(make([]byte, secure.KeyBytes))) if err != nil { t.Fatal(err) } service := New(pds, pds, client, vault) token, err := service.Finish(ctx, url.Values{}, "browser") if err != nil { t.Fatal(err) } if err := index.Join(ctx, intermediary); err != nil { t.Fatal(err) } for _, v := range []domain.Vouch{{Author: aliceDID, Subject: intermediary, Score: 2}, {Author: intermediary, Subject: bobDID, Score: 2}} { if err := appStore.Replace(ctx, v.Author, []domain.Vouch{v}, time.Now()); err != nil { t.Fatal(err) } } handler := Handler(service, origin) r := httptest.NewRequest(http.MethodGet, "/api/reputation?target=bob.example.com&ignored="+intermediary, nil) r.AddCookie(&http.Cookie{Name: "__Host-" + sessionCookie, Value: token}) w := httptest.NewRecorder() handler.ServeHTTP(w, r) if len(algorithm.Describe().Accounts) == 0 { if w.Code != http.StatusBadRequest { t.Fatalf("unsupported UI option: %d %s", w.Code, w.Body) } return } var result appview.Evaluation if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { t.Fatal(err) } if w.Code != http.StatusOK || result.Status != trust.UnknownStatus || result.Score != nil || result.Trusted { t.Fatalf("UI ignored option lost: %d %s", w.Code, w.Body) } }) }}