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