package 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: ""}}, 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