diff --git a/justfile b/justfile index 06d57ff..3594c0d 100644 --- a/justfile +++ b/justfile @@ -14,8 +14,11 @@ test: @templ generate @go test ./... -cover -coverprofile=cover.out -test-integration: - @cd tests/integration && go test -v ./... -count=1 +integration-test: + @cd tests/integration && go test -v ./... -count=1 + +verbose-integration-test: + @cd tests/integration && INTEGRATION_LOGS=true go test -v ./... -count=1 style: @nix develop --command tailwindcss -i static/css/app.css -o static/css/output.css --minify diff --git a/tests/integration/authz_test.go b/tests/integration/authz_test.go new file mode 100644 index 0000000..6d1313a --- /dev/null +++ b/tests/integration/authz_test.go @@ -0,0 +1,289 @@ +package integration + +import ( + "fmt" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authzCase describes one entity's mutating-endpoint surface plus how to find +// the test's record in /api/data after the cross-user attempts. +type authzCase struct { + name string + + // HTTP surface. + createPath string // POST target + mutatePath string // template like "/api/beans/%s" — also used for DELETE + + // Forms. + createForm func(refs entityRefs) url.Values + attackForm url.Values // what Bob will PUT + + // extract returns (name, location-or-extra, found) for the given rkey from + // /api/data so the test can verify nothing changed. The "extra" string is + // entity-specific (location for roaster, origin for bean, etc.) and lets + // us assert two fields without writing one extractor per entity. + extract func(data listAllResponse, rkey string) (name, extra string, found bool) +} + +// entityRefs holds dependency rkeys created by the harness fixture so each +// case's createForm closure can reference them (e.g. bean needs a roaster). +type entityRefs struct { + roasterRKey string + brewerRKey string +} + +// TestHTTP_CrossUserMutationIsolation walks every mutating entity surface and +// verifies that Bob cannot affect Alice's records by guessing her rkeys. +// +// For each entity: +// 1. Alice creates a record (captures original name + secondary field). +// 2. Bob attempts PUT and DELETE on Alice's rkey. +// 3. Alice re-reads — name and secondary field must be unchanged and the +// record must still exist. +// +// This is the catastrophic-class authz check, expanded from the roaster-only +// version to cover beans, grinders, brewers, recipes, and brews. +func TestHTTP_CrossUserMutationIsolation(t *testing.T) { + cases := []authzCase{ + { + name: "roaster", + createPath: "/api/roasters", + mutatePath: "/api/roasters/%s", + createForm: func(_ entityRefs) url.Values { + return form("name", "Alice Roaster", "location", "Seattle") + }, + attackForm: form("name", "PWNED", "location", "Hacker House"), + extract: func(data listAllResponse, rkey string) (string, string, bool) { + for _, r := range data.Roasters { + if r.RKey == rkey { + return r.Name, r.Location, true + } + } + return "", "", false + }, + }, + { + name: "bean", + createPath: "/api/beans", + mutatePath: "/api/beans/%s", + createForm: func(refs entityRefs) url.Values { + return form( + "name", "Alice Bean", + "origin", "Ethiopia", + "roaster_rkey", refs.roasterRKey, + "roast_level", "Light", + ) + }, + attackForm: form("name", "PWNED", "origin", "Hacker Origin"), + extract: func(data listAllResponse, rkey string) (string, string, bool) { + for _, b := range data.Beans { + if b.RKey == rkey { + return b.Name, b.Origin, true + } + } + return "", "", false + }, + }, + { + name: "grinder", + createPath: "/api/grinders", + mutatePath: "/api/grinders/%s", + createForm: func(_ entityRefs) url.Values { + return form("name", "Alice Grinder", "grinder_type", "Manual") + }, + attackForm: form("name", "PWNED", "grinder_type", "Hacker"), + extract: func(data listAllResponse, rkey string) (string, string, bool) { + for _, g := range data.Grinders { + if g.RKey == rkey { + return g.Name, g.GrinderType, true + } + } + return "", "", false + }, + }, + { + name: "brewer", + createPath: "/api/brewers", + mutatePath: "/api/brewers/%s", + createForm: func(_ entityRefs) url.Values { + return form("name", "Alice Brewer", "brewer_type", "Pour Over") + }, + attackForm: form("name", "PWNED", "brewer_type", "Hacker"), + extract: func(data listAllResponse, rkey string) (string, string, bool) { + for _, b := range data.Brewers { + if b.RKey == rkey { + return b.Name, b.BrewerType, true + } + } + return "", "", false + }, + }, + { + name: "recipe", + createPath: "/api/recipes", + mutatePath: "/api/recipes/%s", + createForm: func(refs entityRefs) url.Values { + return form( + "name", "Alice Recipe", + "brewer_rkey", refs.brewerRKey, + "brewer_type", "Pour Over", + "coffee_amount", "18", + "water_amount", "300", + "notes", "original notes", + ) + }, + attackForm: form( + "name", "PWNED", + "brewer_type", "Pour Over", + "notes", "hacker notes", + ), + extract: func(data listAllResponse, rkey string) (string, string, bool) { + for _, r := range data.Recipes { + if r.RKey == rkey { + return r.Name, r.Notes, true + } + } + return "", "", false + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := StartHarness(t, nil) + + // Alice's per-test fixture: a roaster + brewer the create forms can reference. + refs := entityRefs{ + roasterRKey: mustRKey(t, h.PostForm("/api/roasters", form("name", "Refs Roaster")), "roaster"), + brewerRKey: mustRKey(t, h.PostForm("/api/brewers", form("name", "Refs Brewer", "brewer_type", "Pour Over")), "brewer"), + } + + // Alice creates the entity under test. + createResp := h.PostForm(tc.createPath, tc.createForm(refs)) + rkey := mustRKey(t, createResp, tc.name) + + // Capture the original (name, extra) for later comparison. + origData := fetchData(t, h) + origName, origExtra, ok := tc.extract(origData, rkey) + require.True(t, ok, "%s not found right after create", tc.name) + require.NotEmpty(t, origName) + + // Bob signs in. + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") + bobClient := h.NewClientForAccount(bob) + + // Bob attempts PUT and DELETE while masquerading as the harness client. + func() { + restore := withClient(h, bobClient) + defer restore() + + putResp := h.PutForm(fmt.Sprintf(tc.mutatePath, rkey), tc.attackForm) + putBody := ReadBody(t, putResp) + t.Logf("bob PUT %s: status=%d body=%s", tc.name, putResp.StatusCode, truncate(putBody, 200)) + + delResp := h.Delete(fmt.Sprintf(tc.mutatePath, rkey)) + delBody := ReadBody(t, delResp) + t.Logf("bob DELETE %s: status=%d body=%s", tc.name, delResp.StatusCode, truncate(delBody, 200)) + }() + + // Back as Alice — verify the record is intact and unchanged. + // + // Reads go through the session cache; Alice's session was never + // invalidated by Bob's writes (those went to Bob's PDS, not + // Alice's), so cached state would be stale-but-correct here. To be + // extra safe and detect actual data mutation, we evict Alice's + // session cache to force a witness/PDS re-read. + h.InvalidateSessionCache(h.PrimaryAccount) + + data := fetchData(t, h) + gotName, gotExtra, found := tc.extract(data, rkey) + require.True(t, found, "Alice's %s must still exist after Bob's attempts", tc.name) + assert.Equal(t, origName, gotName, "Alice's %s name must be unchanged", tc.name) + assert.Equal(t, origExtra, gotExtra, "Alice's %s secondary field must be unchanged", tc.name) + }) + } +} + +// TestHTTP_CrossUserBrewIsolation is the brew-specific version of the authz +// matrix above. Brew uses different routes (/brews/{id}) and a much wider +// form, so it's split out rather than wedged into the table. +func TestHTTP_CrossUserBrewIsolation(t *testing.T) { + h := StartHarness(t, nil) + + // Alice creates a brew + its dependencies. + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Alice Roaster")), "roaster") + beanRKey := mustRKey(t, h.PostForm("/api/beans", form( + "name", "Alice Bean", + "roaster_rkey", roasterRKey, + "roast_level", "Medium", + )), "bean") + brewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "Alice V60", "brewer_type", "Pour Over")), "brewer") + + createForm := url.Values{} + createForm.Set("bean_rkey", beanRKey) + createForm.Set("brewer_rkey", brewerRKey) + createForm.Set("method", "Pour Over") + createForm.Set("water_amount", "300") + createForm.Set("coffee_amount", "18") + createForm.Set("rating", "8") + createForm.Set("tasting_notes", "original notes") + createResp := h.PostForm("/brews", createForm) + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, ReadBody(t, createResp))) + + data := fetchData(t, h) + require.Len(t, data.Brews, 1) + brewRKey := data.Brews[0].RKey + + // Bob signs in and attacks. + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") + bobClient := h.NewClientForAccount(bob) + + func() { + restore := withClient(h, bobClient) + defer restore() + + // Bob's PUT requires a valid bean_rkey from his own context. Use a fake + // (well-formed) rkey — handler should reject because it doesn't exist + // in Bob's PDS, and even if it doesn't, Alice's record must be safe. + attack := url.Values{} + attack.Set("bean_rkey", beanRKey) // Alice's rkey — handler treats it as Bob's + attack.Set("brewer_rkey", brewerRKey) + attack.Set("method", "PWNED METHOD") + attack.Set("water_amount", "1") + attack.Set("coffee_amount", "1") + attack.Set("rating", "1") + attack.Set("tasting_notes", "hacker notes") + + putResp := h.PutForm("/brews/"+brewRKey, attack) + putBody := ReadBody(t, putResp) + t.Logf("bob PUT brew: status=%d body=%s", putResp.StatusCode, truncate(putBody, 200)) + + delResp := h.Delete("/brews/" + brewRKey) + delBody := ReadBody(t, delResp) + t.Logf("bob DELETE brew: status=%d body=%s", delResp.StatusCode, truncate(delBody, 200)) + }() + + // Back as Alice — verify her brew is intact. + h.InvalidateSessionCache(h.PrimaryAccount) + data = fetchData(t, h) + require.Len(t, data.Brews, 1, "Alice's brew must still exist after Bob's attempts") + brew := data.Brews[0] + assert.Equal(t, brewRKey, brew.RKey) + assert.Equal(t, "Pour Over", brew.Method, "method must not be overwritten") + assert.Equal(t, "original notes", brew.TastingNotes, "tasting notes must not be overwritten") + assert.Equal(t, 8, brew.Rating, "rating must not be overwritten") + assert.Equal(t, 300, brew.WaterAmount, "water amount must not be overwritten") +} + +// truncate returns s shortened to max chars with an ellipsis suffix when +// truncated. Used to keep test logs from drowning in HTML error pages. +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "…" +} diff --git a/tests/integration/cache_test.go b/tests/integration/cache_test.go new file mode 100644 index 0000000..c5a02a7 --- /dev/null +++ b/tests/integration/cache_test.go @@ -0,0 +1,117 @@ +package integration + +import ( + "encoding/json" + "testing" + + "arabica/internal/atproto" + "arabica/internal/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHTTP_WitnessCacheFallback verifies that reads succeed even when both +// cache layers (session cache + witness cache) are empty, by falling through +// to a real PDS XRPC call. +// +// This is the riskiest architectural piece in the codebase: if write-through +// ever drifts from PDS reads, or if the fallback path silently breaks, users +// would see "missing" data right after creating it. This test exercises: +// +// 1. Create a roaster (write-through to witness cache happens here). +// 2. Confirm a normal read returns it (witness-cache hit path). +// 3. Evict the witness cache entry + invalidate the session cache. +// 4. Read again — must still return the same data, this time via the +// real-PDS fallback inside AtprotoStore.GetRoasterByRKey/ListRoasters. +func TestHTTP_WitnessCacheFallback(t *testing.T) { + h := StartHarness(t, nil) + + // Step 1: create a roaster. + createResp := h.PostForm("/api/roasters", form( + "name", "Cache Fallback Roaster", + "location", "Portland", + "website", "https://example.com", + )) + createBody := ReadBody(t, createResp) + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) + + var created models.Roaster + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) + require.NotEmpty(t, created.RKey) + + // Step 2: read via /api/data — this should hit the witness cache (or + // session cache, populated by ListRoasters). + preData := fetchData(t, h) + prePresent := containsRoaster(preData.Roasters, created.RKey) + require.True(t, prePresent, "roaster should be readable immediately after create") + + // Step 3: evict the witness record and clear the session cache. After + // this, both fast paths in AtprotoStore.ListRoasters will miss and the + // store has to fall through to s.client.ListAllRecords (real PDS read). + h.EvictWitnessRecord(h.PrimaryAccount, atproto.NSIDRoaster, created.RKey) + h.InvalidateSessionCache(h.PrimaryAccount) + + // Sanity check: confirm the witness cache really is empty for that record. + wr, _ := h.FeedIndex.GetWitnessRecord(t.Context(), atproto.BuildATURI(h.PrimaryAccount.DID, atproto.NSIDRoaster, created.RKey)) + require.Nil(t, wr, "witness record should have been evicted") + + // Step 4: read again — must still return the roaster, this time via the + // PDS fallback path. + postData := fetchData(t, h) + var found *models.Roaster + for i := range postData.Roasters { + if postData.Roasters[i].RKey == created.RKey { + found = &postData.Roasters[i] + break + } + } + require.NotNil(t, found, "roaster must still be readable via PDS fallback after both caches are empty") + + // Field-level: the fallback path goes through a different decode path + // (RecordToRoaster on a fresh PDS payload, not WitnessRecordToMap on + // cached JSON). Verify the round-trip preserves all the fields we set. + assert.Equal(t, "Cache Fallback Roaster", found.Name) + assert.Equal(t, "Portland", found.Location) + assert.Equal(t, "https://example.com", found.Website) +} + +// TestHTTP_WitnessCacheGetByRKeyFallback covers the per-record (not list) +// fallback path: GetRoasterByRKey hits witness cache first, then falls back +// to a single PDS GetRecord call. This path is used by HandleRoasterView and +// other view handlers, so a regression here would surface as "404 not found" +// on detail pages right after creation. +func TestHTTP_WitnessCacheGetByRKeyFallback(t *testing.T) { + h := StartHarness(t, nil) + + createResp := h.PostForm("/api/roasters", form("name", "Single-Get Fallback")) + createBody := ReadBody(t, createResp) + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) + + var created models.Roaster + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) + + // Evict caches. + h.EvictWitnessRecord(h.PrimaryAccount, atproto.NSIDRoaster, created.RKey) + h.InvalidateSessionCache(h.PrimaryAccount) + + // The view page calls GetRoasterRecordByRKey via HandleRoasterView. With + // no owner= param this goes through the authenticated store path and + // should hit the PDS fallback. + resp := h.Get("/roasters/" + created.RKey) + body := ReadBody(t, resp) + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) + assert.Contains(t, body, "Single-Get Fallback", + "roaster name should appear on view page after PDS fallback read") +} + +// containsRoaster reports whether a roaster with the given rkey exists in the +// slice. Small helper used by cache tests. +func containsRoaster(roasters []models.Roaster, rkey string) bool { + for _, r := range roasters { + if r.RKey == rkey { + return true + } + } + return false +} diff --git a/tests/integration/go.mod b/tests/integration/go.mod index 4bc5b11..2cf467b 100644 --- a/tests/integration/go.mod +++ b/tests/integration/go.mod @@ -13,6 +13,7 @@ require ( github.com/haileyok/cocoon v0.9.0 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 + gorm.io/gorm v1.31.1 tangled.org/pdewey.com/atp v0.0.0-20260407015143-f53954e5e783 ) @@ -161,7 +162,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect - gorm.io/gorm v1.31.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/tests/integration/harness.go b/tests/integration/harness.go index e3a0818..c9482ac 100644 --- a/tests/integration/harness.go +++ b/tests/integration/harness.go @@ -30,30 +30,44 @@ import ( zlog "github.com/rs/zerolog/log" "github.com/stretchr/testify/require" "tangled.org/pdewey.com/atp" + gormlogger "gorm.io/gorm/logger" ) -// silenceLogs redirects all the noisy log outputs that show up during -// integration tests (cocoon's stdlib log + slog + GORM, arabica's zerolog) to -// io.Discard. This keeps `go test -v` output focused on actual test results. -// -// Without this, even passing runs scroll dozens of lines of GORM -// "record not found" warnings, arabica request debug logs, and cocoon's -// per-request access logs. -// -// Set INTEGRATION_VERBOSE=1 to keep the logs visible — useful when a test is -// failing and you want to see what arabica or cocoon were doing at the time. +func init() { + // Cocoon constructs its gorm sessions with `&gorm.Config{}` (no Logger), + // so each session falls back to gormlogger.Default. Replace it with one + // that ignores ErrRecordNotFound — cocoon's preflight existence checks + // (handle/email/seq lookups on a fresh test DB) otherwise spam yellow + // "record not found" warnings on every test run. + gormlogger.Default = gormlogger.New( + stdlog.New(os.Stdout, "\r\n", stdlog.LstdFlags), + gormlogger.Config{ + SlowThreshold: 200 * time.Millisecond, + LogLevel: gormlogger.Warn, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ) +} + +// silenceLogs routes the noisy log outputs that show up during integration +// tests (cocoon's stdlib log + slog, arabica's zerolog) to io.Discard so +// passing runs aren't drowned in per-request access logs and handler debug +// lines. // -// Note: without -v, `go test` already captures per-test output and only prints -// it on failure, so this silencing is mainly relevant for `go test -v`. +// Set INTEGRATION_LOGS=1 to keep them visible — arabica's zerolog gets routed +// through a colored ConsoleWriter so its lines blend in with cocoon's +// gorm/slog output. func silenceLogs() { - if v := os.Getenv("INTEGRATION_VERBOSE"); v == "1" || v == "true" { + if v := os.Getenv("INTEGRATION_LOGS"); v == "1" || v == "true" { + zlog.Logger = zerolog.New(zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + }).With().Timestamp().Logger() return } - // stdlib log (some GORM logger configurations write here) stdlog.SetOutput(io.Discard) - // log/slog (cocoon's slogecho middleware, server lifecycle logs) slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) - // zerolog (arabica's handler debug/info logs go through the global logger) zlog.Logger = zerolog.New(io.Discard) } @@ -70,11 +84,12 @@ const ( // PDS and exposes an httptest.Server. Auth is faked via custom headers so // tests can act as any DID without an OAuth dance. type Harness struct { - T *testing.T - PDS *testpds.TestPDS - Server *httptest.Server - Handler *handlers.Handler - FeedIndex *firehose.FeedIndex + T *testing.T + PDS *testpds.TestPDS + Server *httptest.Server + Handler *handlers.Handler + FeedIndex *firehose.FeedIndex + SessionCache *atproto.SessionCache // PrimaryAccount is the default account created on harness setup. PrimaryAccount TestAccount @@ -138,11 +153,14 @@ func StartHarness(t *testing.T, opts *HarnessOptions) *Harness { feedIndex, err := firehose.NewFeedIndex(t.TempDir()+"/feed-index.db", 1*time.Hour) require.NoError(t, err) + sessionCache := atproto.NewSessionCache() + harness := &Harness{ - T: t, - PDS: pds, - FeedIndex: feedIndex, - accounts: make(map[string]*atclient.APIClient), + T: t, + PDS: pds, + FeedIndex: feedIndex, + SessionCache: sessionCache, + accounts: make(map[string]*atclient.APIClient), } // Provider routes XRPC calls based on the DID in the request context. The @@ -165,7 +183,6 @@ func StartHarness(t *testing.T, opts *HarnessOptions) *Harness { oauthMgr, err := atproto.NewOAuthManager("", "http://localhost/oauth/callback", nil) require.NoError(t, err) - sessionCache := atproto.NewSessionCache() feedRegistry := feed.NewRegistry() feedService := feed.NewService(feedRegistry) @@ -309,6 +326,29 @@ func (h *Harness) PutForm(path string, form url.Values) *http.Response { return resp } +// SessionIDFor returns the test session ID assigned to an account by +// authInjectingTransport. Tests use it when reaching into the session cache +// directly (e.g. to evict an entry to force a witness/PDS read). +func (h *Harness) SessionIDFor(acct TestAccount) string { + return "test-session-" + acct.DID +} + +// EvictWitnessRecord deletes a single record from the witness cache by +// (collection, rkey). Tests call this to force a witness-cache miss without +// going through the delete handler (which would also delete from the PDS). +// Combined with InvalidateSessionCache, this exercises the PDS fallback path. +func (h *Harness) EvictWitnessRecord(acct TestAccount, collection, rkey string) { + h.T.Helper() + require.NoError(h.T, h.FeedIndex.DeleteWitnessRecord(context.Background(), acct.DID, collection, rkey)) +} + +// InvalidateSessionCache wipes the per-session in-memory cache for an account. +// Tests use it together with EvictWitnessRecord to force the store to fall +// through both cache layers down to a real PDS read. +func (h *Harness) InvalidateSessionCache(acct TestAccount) { + h.SessionCache.Invalidate(h.SessionIDFor(acct)) +} + // Delete sends a DELETE request as the primary account. func (h *Harness) Delete(path string) *http.Response { h.T.Helper() diff --git a/tests/integration/social_test.go b/tests/integration/social_test.go new file mode 100644 index 0000000..1377c0e --- /dev/null +++ b/tests/integration/social_test.go @@ -0,0 +1,327 @@ +package integration + +import ( + "context" + "net/url" + "strings" + "testing" + + "arabica/internal/atproto" + "arabica/internal/firehose" + "arabica/internal/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// subjectRefFor looks up the AT-URI and CID for a record from the witness +// cache. Likes and comments need a (subject_uri, subject_cid) pair, but the +// entity create handlers don't return CID directly — it's persisted into the +// witness cache by the write-through, which is what view handlers also use to +// build social-feature props. +func subjectRefFor(t *testing.T, h *Harness, acct TestAccount, collection, rkey string) (uri, cid string) { + t.Helper() + uri = atproto.BuildATURI(acct.DID, collection, rkey) + wr, err := h.FeedIndex.GetWitnessRecord(context.Background(), uri) + require.NoError(t, err) + require.NotNil(t, wr, "witness record missing for %s", uri) + require.NotEmpty(t, wr.CID, "witness record has empty CID") + return uri, wr.CID +} + +// TestHTTP_LikeToggleFlow exercises the like toggle endpoint end-to-end: +// like → verify count → unlike → verify count. The handler returns a rendered +// LikeButton fragment, but the source of truth is the firehose index, so we +// assert against GetLikeCount rather than parse HTML. +func TestHTTP_LikeToggleFlow(t *testing.T) { + h := StartHarness(t, nil) + + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Likeable Roaster")), "roaster") + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) + + // Initial state: no likes. + assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) + + // Like. + likeResp := h.PostForm("/api/likes/toggle", form( + "subject_uri", subjectURI, + "subject_cid", subjectCID, + )) + likeBody := ReadBody(t, likeResp) + require.Equal(t, 200, likeResp.StatusCode, statusErr(likeResp, likeBody)) + assert.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI), + "like count should be 1 after liking") + + // Toggle off. + unlikeResp := h.PostForm("/api/likes/toggle", form( + "subject_uri", subjectURI, + "subject_cid", subjectCID, + )) + unlikeBody := ReadBody(t, unlikeResp) + require.Equal(t, 200, unlikeResp.StatusCode, statusErr(unlikeResp, unlikeBody)) + assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI), + "like count should be 0 after unliking") +} + +// TestHTTP_LikeCrossUser verifies that when Bob likes Alice's record, the +// count reflects both users' likes independently. Each like is stored in the +// liker's PDS but indexed against the subject URI, so this exercises the +// "many likers, one subject" path. +func TestHTTP_LikeCrossUser(t *testing.T) { + h := StartHarness(t, nil) + + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Popular Roaster")), "roaster") + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) + + // Alice likes her own record. + resp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) + require.Equal(t, 200, resp.StatusCode, statusErr(resp, ReadBody(t, resp))) + require.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) + + // Bob signs in and likes the same record. + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") + bobClient := h.NewClientForAccount(bob) + func() { + restore := withClient(h, bobClient) + defer restore() + resp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) + require.Equal(t, 200, resp.StatusCode, statusErr(resp, ReadBody(t, resp))) + }() + + assert.Equal(t, 2, h.FeedIndex.GetLikeCount(context.Background(), subjectURI), + "count should reflect likes from both Alice and Bob") +} + +// TestHTTP_LikeValidation covers the input rejection paths in the like +// toggle handler: missing subject_uri or subject_cid must return 400. +func TestHTTP_LikeValidation(t *testing.T) { + h := StartHarness(t, nil) + + cases := []struct { + name string + form url.Values + }{ + {"missing_uri", form("subject_cid", "bafyfake")}, + {"missing_cid", form("subject_uri", "at://did:plc:test/social.arabica.alpha.roaster/abc")}, + {"both_missing", url.Values{}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := h.PostForm("/api/likes/toggle", tc.form) + body := ReadBody(t, resp) + assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body)) + }) + } +} + +// TestHTTP_CommentCreateAndList covers the basic comment lifecycle on a +// brew (the most common comment subject): post a comment, list it via the +// HTMX-only GET endpoint, then delete it. +func TestHTTP_CommentCreateAndList(t *testing.T) { + h := StartHarness(t, nil) + + // Set up something to comment on. + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "C Roaster")), "roaster") + beanRKey := mustRKey(t, h.PostForm("/api/beans", form( + "name", "C Bean", "roaster_rkey", roasterRKey, "roast_level", "Medium", + )), "bean") + + createBrew := url.Values{} + createBrew.Set("bean_rkey", beanRKey) + createBrew.Set("water_amount", "300") + createBrew.Set("coffee_amount", "18") + brewResp := h.PostForm("/brews", createBrew) + require.Equal(t, 200, brewResp.StatusCode, statusErr(brewResp, ReadBody(t, brewResp))) + + data := fetchData(t, h) + require.Len(t, data.Brews, 1) + brewRKey := data.Brews[0].RKey + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDBrew, brewRKey) + + // Post a comment. + commentResp := h.PostForm("/api/comments", form( + "subject_uri", subjectURI, + "subject_cid", subjectCID, + "text", "great extraction", + )) + commentBody := ReadBody(t, commentResp) + require.Equal(t, 200, commentResp.StatusCode, statusErr(commentResp, commentBody)) + assert.Contains(t, commentBody, "great extraction", + "create response should re-render the comment section including the new comment") + + // Verify via the threaded-comments source of truth. + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) + require.Len(t, indexed, 1) + assert.Equal(t, "great extraction", indexed[0].Text) + assert.Equal(t, h.PrimaryAccount.DID, indexed[0].ActorDID) + commentRKey := indexed[0].RKey + require.NotEmpty(t, commentRKey) + + // Verify the HTMX list endpoint also returns it. + listResp := h.GetHTMX("/api/comments?subject_uri=" + url.QueryEscape(subjectURI) + "&subject_cid=" + url.QueryEscape(subjectCID)) + listBody := ReadBody(t, listResp) + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) + assert.Contains(t, listBody, "great extraction") + + // Delete and verify gone from the index. + delResp := h.Delete("/api/comments/" + commentRKey) + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp))) + + indexed = h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) + assert.Empty(t, indexed, "comment should be gone after delete") +} + +// TestHTTP_CommentReplyThreading exercises the parent_uri/parent_cid +// strongRef path used for reply threading. After posting a top-level +// comment and a reply, the threaded list should return both with the reply +// nested under its parent. +func TestHTTP_CommentReplyThreading(t *testing.T) { + h := StartHarness(t, nil) + + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Threaded Roaster")), "roaster") + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) + + // Top-level comment. + parentResp := h.PostForm("/api/comments", form( + "subject_uri", subjectURI, + "subject_cid", subjectCID, + "text", "parent comment", + )) + require.Equal(t, 200, parentResp.StatusCode, statusErr(parentResp, ReadBody(t, parentResp))) + + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) + require.Len(t, indexed, 1) + parent := indexed[0] + require.NotEmpty(t, parent.CID, "parent comment should have a CID we can strongRef") + + parentURI := atproto.BuildATURI(h.PrimaryAccount.DID, atproto.NSIDComment, parent.RKey) + + // Reply referencing the parent. + replyResp := h.PostForm("/api/comments", form( + "subject_uri", subjectURI, + "subject_cid", subjectCID, + "text", "child reply", + "parent_uri", parentURI, + "parent_cid", parent.CID, + )) + require.Equal(t, 200, replyResp.StatusCode, statusErr(replyResp, ReadBody(t, replyResp))) + + // Both comments should appear, with the reply at depth 1 and naming the parent. + indexed = h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) + require.Len(t, indexed, 2) + + var top, reply *firehose.IndexedComment + for i := range indexed { + switch indexed[i].Text { + case "parent comment": + top = &indexed[i] + case "child reply": + reply = &indexed[i] + } + } + require.NotNil(t, top, "parent comment missing from threaded list") + require.NotNil(t, reply, "child reply missing from threaded list") + assert.Equal(t, 0, top.Depth, "parent should be at depth 0") + assert.Equal(t, 1, reply.Depth, "reply should be at depth 1") + assert.Equal(t, parentURI, reply.ParentURI, + "reply should reference the parent URI") +} + +// TestHTTP_CommentValidation covers comment input rejection: missing subject, +// missing text, oversized text, and orphan parent_uri without parent_cid. +func TestHTTP_CommentValidation(t *testing.T) { + h := StartHarness(t, nil) + + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Val Roaster")), "roaster") + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) + + cases := []struct { + name string + form url.Values + }{ + { + name: "missing_subject_uri", + form: form("subject_cid", subjectCID, "text", "x"), + }, + { + name: "missing_subject_cid", + form: form("subject_uri", subjectURI, "text", "x"), + }, + { + name: "empty_text", + form: form("subject_uri", subjectURI, "subject_cid", subjectCID, "text", ""), + }, + { + name: "text_too_long", + form: form("subject_uri", subjectURI, "subject_cid", subjectCID, "text", strings.Repeat("a", models.MaxCommentLength+1)), + }, + { + name: "parent_uri_without_parent_cid", + form: form( + "subject_uri", subjectURI, + "subject_cid", subjectCID, + "text", "x", + "parent_uri", "at://did:plc:test/social.arabica.alpha.comment/abc", + ), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := h.PostForm("/api/comments", tc.form) + body := ReadBody(t, resp) + assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body)) + }) + } + + // Sanity: nothing was indexed. + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) + assert.Empty(t, indexed, "no comments should have been created by failing validation cases") +} + +// TestHTTP_LikeAndCommentTogether is a smoke test that walks the full social +// loop: create record, like, comment, list, unlike, delete comment. Catches +// any cross-feature interaction bugs that the focused tests miss. +func TestHTTP_LikeAndCommentTogether(t *testing.T) { + h := StartHarness(t, nil) + + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Combined Roaster")), "roaster") + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) + + // Like. + likeResp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) + require.Equal(t, 200, likeResp.StatusCode) + + // Comment. + commentResp := h.PostForm("/api/comments", form( + "subject_uri", subjectURI, + "subject_cid", subjectCID, + "text", "first impressions: solid", + )) + require.Equal(t, 200, commentResp.StatusCode) + + // Both should be visible. + assert.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) + require.Len(t, indexed, 1) + commentRKey := indexed[0].RKey + + // Render the roaster view page — it should show like + comment data + // pulled from the same feed index. This is the visible end-to-end check. + viewResp := h.Get("/roasters/" + rkey) + viewBody := ReadBody(t, viewResp) + require.Equal(t, 200, viewResp.StatusCode, statusErr(viewResp, viewBody)) + assert.Contains(t, viewBody, "first impressions: solid", + "comment text should be embedded in the view page") + + // Unlike + delete comment. + unlikeResp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) + require.Equal(t, 200, unlikeResp.StatusCode) + delResp := h.Delete("/api/comments/" + commentRKey) + require.Equal(t, 200, delResp.StatusCode) + + assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) + assert.Empty(t, h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)) +} +