···1414 @templ generate
1515 @go test ./... -cover -coverprofile=cover.out
16161717-test-integration:
1818- @cd tests/integration && go test -v ./... -count=1
1717+integration-test:
1818+ @cd tests/integration && go test -v ./... -count=1
1919+2020+verbose-integration-test:
2121+ @cd tests/integration && INTEGRATION_LOGS=true go test -v ./... -count=1
19222023style:
2124 @nix develop --command tailwindcss -i static/css/app.css -o static/css/output.css --minify
+289
tests/integration/authz_test.go
···11+package integration
22+33+import (
44+ "fmt"
55+ "net/url"
66+ "testing"
77+88+ "github.com/stretchr/testify/assert"
99+ "github.com/stretchr/testify/require"
1010+)
1111+1212+// authzCase describes one entity's mutating-endpoint surface plus how to find
1313+// the test's record in /api/data after the cross-user attempts.
1414+type authzCase struct {
1515+ name string
1616+1717+ // HTTP surface.
1818+ createPath string // POST target
1919+ mutatePath string // template like "/api/beans/%s" — also used for DELETE
2020+2121+ // Forms.
2222+ createForm func(refs entityRefs) url.Values
2323+ attackForm url.Values // what Bob will PUT
2424+2525+ // extract returns (name, location-or-extra, found) for the given rkey from
2626+ // /api/data so the test can verify nothing changed. The "extra" string is
2727+ // entity-specific (location for roaster, origin for bean, etc.) and lets
2828+ // us assert two fields without writing one extractor per entity.
2929+ extract func(data listAllResponse, rkey string) (name, extra string, found bool)
3030+}
3131+3232+// entityRefs holds dependency rkeys created by the harness fixture so each
3333+// case's createForm closure can reference them (e.g. bean needs a roaster).
3434+type entityRefs struct {
3535+ roasterRKey string
3636+ brewerRKey string
3737+}
3838+3939+// TestHTTP_CrossUserMutationIsolation walks every mutating entity surface and
4040+// verifies that Bob cannot affect Alice's records by guessing her rkeys.
4141+//
4242+// For each entity:
4343+// 1. Alice creates a record (captures original name + secondary field).
4444+// 2. Bob attempts PUT and DELETE on Alice's rkey.
4545+// 3. Alice re-reads — name and secondary field must be unchanged and the
4646+// record must still exist.
4747+//
4848+// This is the catastrophic-class authz check, expanded from the roaster-only
4949+// version to cover beans, grinders, brewers, recipes, and brews.
5050+func TestHTTP_CrossUserMutationIsolation(t *testing.T) {
5151+ cases := []authzCase{
5252+ {
5353+ name: "roaster",
5454+ createPath: "/api/roasters",
5555+ mutatePath: "/api/roasters/%s",
5656+ createForm: func(_ entityRefs) url.Values {
5757+ return form("name", "Alice Roaster", "location", "Seattle")
5858+ },
5959+ attackForm: form("name", "PWNED", "location", "Hacker House"),
6060+ extract: func(data listAllResponse, rkey string) (string, string, bool) {
6161+ for _, r := range data.Roasters {
6262+ if r.RKey == rkey {
6363+ return r.Name, r.Location, true
6464+ }
6565+ }
6666+ return "", "", false
6767+ },
6868+ },
6969+ {
7070+ name: "bean",
7171+ createPath: "/api/beans",
7272+ mutatePath: "/api/beans/%s",
7373+ createForm: func(refs entityRefs) url.Values {
7474+ return form(
7575+ "name", "Alice Bean",
7676+ "origin", "Ethiopia",
7777+ "roaster_rkey", refs.roasterRKey,
7878+ "roast_level", "Light",
7979+ )
8080+ },
8181+ attackForm: form("name", "PWNED", "origin", "Hacker Origin"),
8282+ extract: func(data listAllResponse, rkey string) (string, string, bool) {
8383+ for _, b := range data.Beans {
8484+ if b.RKey == rkey {
8585+ return b.Name, b.Origin, true
8686+ }
8787+ }
8888+ return "", "", false
8989+ },
9090+ },
9191+ {
9292+ name: "grinder",
9393+ createPath: "/api/grinders",
9494+ mutatePath: "/api/grinders/%s",
9595+ createForm: func(_ entityRefs) url.Values {
9696+ return form("name", "Alice Grinder", "grinder_type", "Manual")
9797+ },
9898+ attackForm: form("name", "PWNED", "grinder_type", "Hacker"),
9999+ extract: func(data listAllResponse, rkey string) (string, string, bool) {
100100+ for _, g := range data.Grinders {
101101+ if g.RKey == rkey {
102102+ return g.Name, g.GrinderType, true
103103+ }
104104+ }
105105+ return "", "", false
106106+ },
107107+ },
108108+ {
109109+ name: "brewer",
110110+ createPath: "/api/brewers",
111111+ mutatePath: "/api/brewers/%s",
112112+ createForm: func(_ entityRefs) url.Values {
113113+ return form("name", "Alice Brewer", "brewer_type", "Pour Over")
114114+ },
115115+ attackForm: form("name", "PWNED", "brewer_type", "Hacker"),
116116+ extract: func(data listAllResponse, rkey string) (string, string, bool) {
117117+ for _, b := range data.Brewers {
118118+ if b.RKey == rkey {
119119+ return b.Name, b.BrewerType, true
120120+ }
121121+ }
122122+ return "", "", false
123123+ },
124124+ },
125125+ {
126126+ name: "recipe",
127127+ createPath: "/api/recipes",
128128+ mutatePath: "/api/recipes/%s",
129129+ createForm: func(refs entityRefs) url.Values {
130130+ return form(
131131+ "name", "Alice Recipe",
132132+ "brewer_rkey", refs.brewerRKey,
133133+ "brewer_type", "Pour Over",
134134+ "coffee_amount", "18",
135135+ "water_amount", "300",
136136+ "notes", "original notes",
137137+ )
138138+ },
139139+ attackForm: form(
140140+ "name", "PWNED",
141141+ "brewer_type", "Pour Over",
142142+ "notes", "hacker notes",
143143+ ),
144144+ extract: func(data listAllResponse, rkey string) (string, string, bool) {
145145+ for _, r := range data.Recipes {
146146+ if r.RKey == rkey {
147147+ return r.Name, r.Notes, true
148148+ }
149149+ }
150150+ return "", "", false
151151+ },
152152+ },
153153+ }
154154+155155+ for _, tc := range cases {
156156+ t.Run(tc.name, func(t *testing.T) {
157157+ h := StartHarness(t, nil)
158158+159159+ // Alice's per-test fixture: a roaster + brewer the create forms can reference.
160160+ refs := entityRefs{
161161+ roasterRKey: mustRKey(t, h.PostForm("/api/roasters", form("name", "Refs Roaster")), "roaster"),
162162+ brewerRKey: mustRKey(t, h.PostForm("/api/brewers", form("name", "Refs Brewer", "brewer_type", "Pour Over")), "brewer"),
163163+ }
164164+165165+ // Alice creates the entity under test.
166166+ createResp := h.PostForm(tc.createPath, tc.createForm(refs))
167167+ rkey := mustRKey(t, createResp, tc.name)
168168+169169+ // Capture the original (name, extra) for later comparison.
170170+ origData := fetchData(t, h)
171171+ origName, origExtra, ok := tc.extract(origData, rkey)
172172+ require.True(t, ok, "%s not found right after create", tc.name)
173173+ require.NotEmpty(t, origName)
174174+175175+ // Bob signs in.
176176+ bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2")
177177+ bobClient := h.NewClientForAccount(bob)
178178+179179+ // Bob attempts PUT and DELETE while masquerading as the harness client.
180180+ func() {
181181+ restore := withClient(h, bobClient)
182182+ defer restore()
183183+184184+ putResp := h.PutForm(fmt.Sprintf(tc.mutatePath, rkey), tc.attackForm)
185185+ putBody := ReadBody(t, putResp)
186186+ t.Logf("bob PUT %s: status=%d body=%s", tc.name, putResp.StatusCode, truncate(putBody, 200))
187187+188188+ delResp := h.Delete(fmt.Sprintf(tc.mutatePath, rkey))
189189+ delBody := ReadBody(t, delResp)
190190+ t.Logf("bob DELETE %s: status=%d body=%s", tc.name, delResp.StatusCode, truncate(delBody, 200))
191191+ }()
192192+193193+ // Back as Alice — verify the record is intact and unchanged.
194194+ //
195195+ // Reads go through the session cache; Alice's session was never
196196+ // invalidated by Bob's writes (those went to Bob's PDS, not
197197+ // Alice's), so cached state would be stale-but-correct here. To be
198198+ // extra safe and detect actual data mutation, we evict Alice's
199199+ // session cache to force a witness/PDS re-read.
200200+ h.InvalidateSessionCache(h.PrimaryAccount)
201201+202202+ data := fetchData(t, h)
203203+ gotName, gotExtra, found := tc.extract(data, rkey)
204204+ require.True(t, found, "Alice's %s must still exist after Bob's attempts", tc.name)
205205+ assert.Equal(t, origName, gotName, "Alice's %s name must be unchanged", tc.name)
206206+ assert.Equal(t, origExtra, gotExtra, "Alice's %s secondary field must be unchanged", tc.name)
207207+ })
208208+ }
209209+}
210210+211211+// TestHTTP_CrossUserBrewIsolation is the brew-specific version of the authz
212212+// matrix above. Brew uses different routes (/brews/{id}) and a much wider
213213+// form, so it's split out rather than wedged into the table.
214214+func TestHTTP_CrossUserBrewIsolation(t *testing.T) {
215215+ h := StartHarness(t, nil)
216216+217217+ // Alice creates a brew + its dependencies.
218218+ roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Alice Roaster")), "roaster")
219219+ beanRKey := mustRKey(t, h.PostForm("/api/beans", form(
220220+ "name", "Alice Bean",
221221+ "roaster_rkey", roasterRKey,
222222+ "roast_level", "Medium",
223223+ )), "bean")
224224+ brewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "Alice V60", "brewer_type", "Pour Over")), "brewer")
225225+226226+ createForm := url.Values{}
227227+ createForm.Set("bean_rkey", beanRKey)
228228+ createForm.Set("brewer_rkey", brewerRKey)
229229+ createForm.Set("method", "Pour Over")
230230+ createForm.Set("water_amount", "300")
231231+ createForm.Set("coffee_amount", "18")
232232+ createForm.Set("rating", "8")
233233+ createForm.Set("tasting_notes", "original notes")
234234+ createResp := h.PostForm("/brews", createForm)
235235+ require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, ReadBody(t, createResp)))
236236+237237+ data := fetchData(t, h)
238238+ require.Len(t, data.Brews, 1)
239239+ brewRKey := data.Brews[0].RKey
240240+241241+ // Bob signs in and attacks.
242242+ bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2")
243243+ bobClient := h.NewClientForAccount(bob)
244244+245245+ func() {
246246+ restore := withClient(h, bobClient)
247247+ defer restore()
248248+249249+ // Bob's PUT requires a valid bean_rkey from his own context. Use a fake
250250+ // (well-formed) rkey — handler should reject because it doesn't exist
251251+ // in Bob's PDS, and even if it doesn't, Alice's record must be safe.
252252+ attack := url.Values{}
253253+ attack.Set("bean_rkey", beanRKey) // Alice's rkey — handler treats it as Bob's
254254+ attack.Set("brewer_rkey", brewerRKey)
255255+ attack.Set("method", "PWNED METHOD")
256256+ attack.Set("water_amount", "1")
257257+ attack.Set("coffee_amount", "1")
258258+ attack.Set("rating", "1")
259259+ attack.Set("tasting_notes", "hacker notes")
260260+261261+ putResp := h.PutForm("/brews/"+brewRKey, attack)
262262+ putBody := ReadBody(t, putResp)
263263+ t.Logf("bob PUT brew: status=%d body=%s", putResp.StatusCode, truncate(putBody, 200))
264264+265265+ delResp := h.Delete("/brews/" + brewRKey)
266266+ delBody := ReadBody(t, delResp)
267267+ t.Logf("bob DELETE brew: status=%d body=%s", delResp.StatusCode, truncate(delBody, 200))
268268+ }()
269269+270270+ // Back as Alice — verify her brew is intact.
271271+ h.InvalidateSessionCache(h.PrimaryAccount)
272272+ data = fetchData(t, h)
273273+ require.Len(t, data.Brews, 1, "Alice's brew must still exist after Bob's attempts")
274274+ brew := data.Brews[0]
275275+ assert.Equal(t, brewRKey, brew.RKey)
276276+ assert.Equal(t, "Pour Over", brew.Method, "method must not be overwritten")
277277+ assert.Equal(t, "original notes", brew.TastingNotes, "tasting notes must not be overwritten")
278278+ assert.Equal(t, 8, brew.Rating, "rating must not be overwritten")
279279+ assert.Equal(t, 300, brew.WaterAmount, "water amount must not be overwritten")
280280+}
281281+282282+// truncate returns s shortened to max chars with an ellipsis suffix when
283283+// truncated. Used to keep test logs from drowning in HTML error pages.
284284+func truncate(s string, max int) string {
285285+ if len(s) <= max {
286286+ return s
287287+ }
288288+ return s[:max] + "…"
289289+}
+117
tests/integration/cache_test.go
···11+package integration
22+33+import (
44+ "encoding/json"
55+ "testing"
66+77+ "arabica/internal/atproto"
88+ "arabica/internal/models"
99+1010+ "github.com/stretchr/testify/assert"
1111+ "github.com/stretchr/testify/require"
1212+)
1313+1414+// TestHTTP_WitnessCacheFallback verifies that reads succeed even when both
1515+// cache layers (session cache + witness cache) are empty, by falling through
1616+// to a real PDS XRPC call.
1717+//
1818+// This is the riskiest architectural piece in the codebase: if write-through
1919+// ever drifts from PDS reads, or if the fallback path silently breaks, users
2020+// would see "missing" data right after creating it. This test exercises:
2121+//
2222+// 1. Create a roaster (write-through to witness cache happens here).
2323+// 2. Confirm a normal read returns it (witness-cache hit path).
2424+// 3. Evict the witness cache entry + invalidate the session cache.
2525+// 4. Read again — must still return the same data, this time via the
2626+// real-PDS fallback inside AtprotoStore.GetRoasterByRKey/ListRoasters.
2727+func TestHTTP_WitnessCacheFallback(t *testing.T) {
2828+ h := StartHarness(t, nil)
2929+3030+ // Step 1: create a roaster.
3131+ createResp := h.PostForm("/api/roasters", form(
3232+ "name", "Cache Fallback Roaster",
3333+ "location", "Portland",
3434+ "website", "https://example.com",
3535+ ))
3636+ createBody := ReadBody(t, createResp)
3737+ require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody))
3838+3939+ var created models.Roaster
4040+ require.NoError(t, json.Unmarshal([]byte(createBody), &created))
4141+ require.NotEmpty(t, created.RKey)
4242+4343+ // Step 2: read via /api/data — this should hit the witness cache (or
4444+ // session cache, populated by ListRoasters).
4545+ preData := fetchData(t, h)
4646+ prePresent := containsRoaster(preData.Roasters, created.RKey)
4747+ require.True(t, prePresent, "roaster should be readable immediately after create")
4848+4949+ // Step 3: evict the witness record and clear the session cache. After
5050+ // this, both fast paths in AtprotoStore.ListRoasters will miss and the
5151+ // store has to fall through to s.client.ListAllRecords (real PDS read).
5252+ h.EvictWitnessRecord(h.PrimaryAccount, atproto.NSIDRoaster, created.RKey)
5353+ h.InvalidateSessionCache(h.PrimaryAccount)
5454+5555+ // Sanity check: confirm the witness cache really is empty for that record.
5656+ wr, _ := h.FeedIndex.GetWitnessRecord(t.Context(), atproto.BuildATURI(h.PrimaryAccount.DID, atproto.NSIDRoaster, created.RKey))
5757+ require.Nil(t, wr, "witness record should have been evicted")
5858+5959+ // Step 4: read again — must still return the roaster, this time via the
6060+ // PDS fallback path.
6161+ postData := fetchData(t, h)
6262+ var found *models.Roaster
6363+ for i := range postData.Roasters {
6464+ if postData.Roasters[i].RKey == created.RKey {
6565+ found = &postData.Roasters[i]
6666+ break
6767+ }
6868+ }
6969+ require.NotNil(t, found, "roaster must still be readable via PDS fallback after both caches are empty")
7070+7171+ // Field-level: the fallback path goes through a different decode path
7272+ // (RecordToRoaster on a fresh PDS payload, not WitnessRecordToMap on
7373+ // cached JSON). Verify the round-trip preserves all the fields we set.
7474+ assert.Equal(t, "Cache Fallback Roaster", found.Name)
7575+ assert.Equal(t, "Portland", found.Location)
7676+ assert.Equal(t, "https://example.com", found.Website)
7777+}
7878+7979+// TestHTTP_WitnessCacheGetByRKeyFallback covers the per-record (not list)
8080+// fallback path: GetRoasterByRKey hits witness cache first, then falls back
8181+// to a single PDS GetRecord call. This path is used by HandleRoasterView and
8282+// other view handlers, so a regression here would surface as "404 not found"
8383+// on detail pages right after creation.
8484+func TestHTTP_WitnessCacheGetByRKeyFallback(t *testing.T) {
8585+ h := StartHarness(t, nil)
8686+8787+ createResp := h.PostForm("/api/roasters", form("name", "Single-Get Fallback"))
8888+ createBody := ReadBody(t, createResp)
8989+ require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody))
9090+9191+ var created models.Roaster
9292+ require.NoError(t, json.Unmarshal([]byte(createBody), &created))
9393+9494+ // Evict caches.
9595+ h.EvictWitnessRecord(h.PrimaryAccount, atproto.NSIDRoaster, created.RKey)
9696+ h.InvalidateSessionCache(h.PrimaryAccount)
9797+9898+ // The view page calls GetRoasterRecordByRKey via HandleRoasterView. With
9999+ // no owner= param this goes through the authenticated store path and
100100+ // should hit the PDS fallback.
101101+ resp := h.Get("/roasters/" + created.RKey)
102102+ body := ReadBody(t, resp)
103103+ require.Equal(t, 200, resp.StatusCode, statusErr(resp, body))
104104+ assert.Contains(t, body, "Single-Get Fallback",
105105+ "roaster name should appear on view page after PDS fallback read")
106106+}
107107+108108+// containsRoaster reports whether a roaster with the given rkey exists in the
109109+// slice. Small helper used by cache tests.
110110+func containsRoaster(roasters []models.Roaster, rkey string) bool {
111111+ for _, r := range roasters {
112112+ if r.RKey == rkey {
113113+ return true
114114+ }
115115+ }
116116+ return false
117117+}
···3030 zlog "github.com/rs/zerolog/log"
3131 "github.com/stretchr/testify/require"
3232 "tangled.org/pdewey.com/atp"
3333+ gormlogger "gorm.io/gorm/logger"
3334)
34353535-// silenceLogs redirects all the noisy log outputs that show up during
3636-// integration tests (cocoon's stdlib log + slog + GORM, arabica's zerolog) to
3737-// io.Discard. This keeps `go test -v` output focused on actual test results.
3838-//
3939-// Without this, even passing runs scroll dozens of lines of GORM
4040-// "record not found" warnings, arabica request debug logs, and cocoon's
4141-// per-request access logs.
4242-//
4343-// Set INTEGRATION_VERBOSE=1 to keep the logs visible — useful when a test is
4444-// failing and you want to see what arabica or cocoon were doing at the time.
3636+func init() {
3737+ // Cocoon constructs its gorm sessions with `&gorm.Config{}` (no Logger),
3838+ // so each session falls back to gormlogger.Default. Replace it with one
3939+ // that ignores ErrRecordNotFound — cocoon's preflight existence checks
4040+ // (handle/email/seq lookups on a fresh test DB) otherwise spam yellow
4141+ // "record not found" warnings on every test run.
4242+ gormlogger.Default = gormlogger.New(
4343+ stdlog.New(os.Stdout, "\r\n", stdlog.LstdFlags),
4444+ gormlogger.Config{
4545+ SlowThreshold: 200 * time.Millisecond,
4646+ LogLevel: gormlogger.Warn,
4747+ IgnoreRecordNotFoundError: true,
4848+ Colorful: true,
4949+ },
5050+ )
5151+}
5252+5353+// silenceLogs routes the noisy log outputs that show up during integration
5454+// tests (cocoon's stdlib log + slog, arabica's zerolog) to io.Discard so
5555+// passing runs aren't drowned in per-request access logs and handler debug
5656+// lines.
4557//
4646-// Note: without -v, `go test` already captures per-test output and only prints
4747-// it on failure, so this silencing is mainly relevant for `go test -v`.
5858+// Set INTEGRATION_LOGS=1 to keep them visible — arabica's zerolog gets routed
5959+// through a colored ConsoleWriter so its lines blend in with cocoon's
6060+// gorm/slog output.
4861func silenceLogs() {
4949- if v := os.Getenv("INTEGRATION_VERBOSE"); v == "1" || v == "true" {
6262+ if v := os.Getenv("INTEGRATION_LOGS"); v == "1" || v == "true" {
6363+ zlog.Logger = zerolog.New(zerolog.ConsoleWriter{
6464+ Out: os.Stdout,
6565+ TimeFormat: time.RFC3339,
6666+ }).With().Timestamp().Logger()
5067 return
5168 }
5252- // stdlib log (some GORM logger configurations write here)
5369 stdlog.SetOutput(io.Discard)
5454- // log/slog (cocoon's slogecho middleware, server lifecycle logs)
5570 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
5656- // zerolog (arabica's handler debug/info logs go through the global logger)
5771 zlog.Logger = zerolog.New(io.Discard)
5872}
5973···7084// PDS and exposes an httptest.Server. Auth is faked via custom headers so
7185// tests can act as any DID without an OAuth dance.
7286type Harness struct {
7373- T *testing.T
7474- PDS *testpds.TestPDS
7575- Server *httptest.Server
7676- Handler *handlers.Handler
7777- FeedIndex *firehose.FeedIndex
8787+ T *testing.T
8888+ PDS *testpds.TestPDS
8989+ Server *httptest.Server
9090+ Handler *handlers.Handler
9191+ FeedIndex *firehose.FeedIndex
9292+ SessionCache *atproto.SessionCache
78937994 // PrimaryAccount is the default account created on harness setup.
8095 PrimaryAccount TestAccount
···138153 feedIndex, err := firehose.NewFeedIndex(t.TempDir()+"/feed-index.db", 1*time.Hour)
139154 require.NoError(t, err)
140155156156+ sessionCache := atproto.NewSessionCache()
157157+141158 harness := &Harness{
142142- T: t,
143143- PDS: pds,
144144- FeedIndex: feedIndex,
145145- accounts: make(map[string]*atclient.APIClient),
159159+ T: t,
160160+ PDS: pds,
161161+ FeedIndex: feedIndex,
162162+ SessionCache: sessionCache,
163163+ accounts: make(map[string]*atclient.APIClient),
146164 }
147165148166 // Provider routes XRPC calls based on the DID in the request context. The
···165183 oauthMgr, err := atproto.NewOAuthManager("", "http://localhost/oauth/callback", nil)
166184 require.NoError(t, err)
167185168168- sessionCache := atproto.NewSessionCache()
169186 feedRegistry := feed.NewRegistry()
170187 feedService := feed.NewService(feedRegistry)
171188···307324 resp, err := h.Client.Do(req)
308325 require.NoError(h.T, err)
309326 return resp
327327+}
328328+329329+// SessionIDFor returns the test session ID assigned to an account by
330330+// authInjectingTransport. Tests use it when reaching into the session cache
331331+// directly (e.g. to evict an entry to force a witness/PDS read).
332332+func (h *Harness) SessionIDFor(acct TestAccount) string {
333333+ return "test-session-" + acct.DID
334334+}
335335+336336+// EvictWitnessRecord deletes a single record from the witness cache by
337337+// (collection, rkey). Tests call this to force a witness-cache miss without
338338+// going through the delete handler (which would also delete from the PDS).
339339+// Combined with InvalidateSessionCache, this exercises the PDS fallback path.
340340+func (h *Harness) EvictWitnessRecord(acct TestAccount, collection, rkey string) {
341341+ h.T.Helper()
342342+ require.NoError(h.T, h.FeedIndex.DeleteWitnessRecord(context.Background(), acct.DID, collection, rkey))
343343+}
344344+345345+// InvalidateSessionCache wipes the per-session in-memory cache for an account.
346346+// Tests use it together with EvictWitnessRecord to force the store to fall
347347+// through both cache layers down to a real PDS read.
348348+func (h *Harness) InvalidateSessionCache(acct TestAccount) {
349349+ h.SessionCache.Invalidate(h.SessionIDFor(acct))
310350}
311351312352// Delete sends a DELETE request as the primary account.
+327
tests/integration/social_test.go
···11+package integration
22+33+import (
44+ "context"
55+ "net/url"
66+ "strings"
77+ "testing"
88+99+ "arabica/internal/atproto"
1010+ "arabica/internal/firehose"
1111+ "arabica/internal/models"
1212+1313+ "github.com/stretchr/testify/assert"
1414+ "github.com/stretchr/testify/require"
1515+)
1616+1717+// subjectRefFor looks up the AT-URI and CID for a record from the witness
1818+// cache. Likes and comments need a (subject_uri, subject_cid) pair, but the
1919+// entity create handlers don't return CID directly — it's persisted into the
2020+// witness cache by the write-through, which is what view handlers also use to
2121+// build social-feature props.
2222+func subjectRefFor(t *testing.T, h *Harness, acct TestAccount, collection, rkey string) (uri, cid string) {
2323+ t.Helper()
2424+ uri = atproto.BuildATURI(acct.DID, collection, rkey)
2525+ wr, err := h.FeedIndex.GetWitnessRecord(context.Background(), uri)
2626+ require.NoError(t, err)
2727+ require.NotNil(t, wr, "witness record missing for %s", uri)
2828+ require.NotEmpty(t, wr.CID, "witness record has empty CID")
2929+ return uri, wr.CID
3030+}
3131+3232+// TestHTTP_LikeToggleFlow exercises the like toggle endpoint end-to-end:
3333+// like → verify count → unlike → verify count. The handler returns a rendered
3434+// LikeButton fragment, but the source of truth is the firehose index, so we
3535+// assert against GetLikeCount rather than parse HTML.
3636+func TestHTTP_LikeToggleFlow(t *testing.T) {
3737+ h := StartHarness(t, nil)
3838+3939+ rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Likeable Roaster")), "roaster")
4040+ subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey)
4141+4242+ // Initial state: no likes.
4343+ assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI))
4444+4545+ // Like.
4646+ likeResp := h.PostForm("/api/likes/toggle", form(
4747+ "subject_uri", subjectURI,
4848+ "subject_cid", subjectCID,
4949+ ))
5050+ likeBody := ReadBody(t, likeResp)
5151+ require.Equal(t, 200, likeResp.StatusCode, statusErr(likeResp, likeBody))
5252+ assert.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI),
5353+ "like count should be 1 after liking")
5454+5555+ // Toggle off.
5656+ unlikeResp := h.PostForm("/api/likes/toggle", form(
5757+ "subject_uri", subjectURI,
5858+ "subject_cid", subjectCID,
5959+ ))
6060+ unlikeBody := ReadBody(t, unlikeResp)
6161+ require.Equal(t, 200, unlikeResp.StatusCode, statusErr(unlikeResp, unlikeBody))
6262+ assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI),
6363+ "like count should be 0 after unliking")
6464+}
6565+6666+// TestHTTP_LikeCrossUser verifies that when Bob likes Alice's record, the
6767+// count reflects both users' likes independently. Each like is stored in the
6868+// liker's PDS but indexed against the subject URI, so this exercises the
6969+// "many likers, one subject" path.
7070+func TestHTTP_LikeCrossUser(t *testing.T) {
7171+ h := StartHarness(t, nil)
7272+7373+ rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Popular Roaster")), "roaster")
7474+ subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey)
7575+7676+ // Alice likes her own record.
7777+ resp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID))
7878+ require.Equal(t, 200, resp.StatusCode, statusErr(resp, ReadBody(t, resp)))
7979+ require.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI))
8080+8181+ // Bob signs in and likes the same record.
8282+ bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2")
8383+ bobClient := h.NewClientForAccount(bob)
8484+ func() {
8585+ restore := withClient(h, bobClient)
8686+ defer restore()
8787+ resp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID))
8888+ require.Equal(t, 200, resp.StatusCode, statusErr(resp, ReadBody(t, resp)))
8989+ }()
9090+9191+ assert.Equal(t, 2, h.FeedIndex.GetLikeCount(context.Background(), subjectURI),
9292+ "count should reflect likes from both Alice and Bob")
9393+}
9494+9595+// TestHTTP_LikeValidation covers the input rejection paths in the like
9696+// toggle handler: missing subject_uri or subject_cid must return 400.
9797+func TestHTTP_LikeValidation(t *testing.T) {
9898+ h := StartHarness(t, nil)
9999+100100+ cases := []struct {
101101+ name string
102102+ form url.Values
103103+ }{
104104+ {"missing_uri", form("subject_cid", "bafyfake")},
105105+ {"missing_cid", form("subject_uri", "at://did:plc:test/social.arabica.alpha.roaster/abc")},
106106+ {"both_missing", url.Values{}},
107107+ }
108108+109109+ for _, tc := range cases {
110110+ t.Run(tc.name, func(t *testing.T) {
111111+ resp := h.PostForm("/api/likes/toggle", tc.form)
112112+ body := ReadBody(t, resp)
113113+ assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body))
114114+ })
115115+ }
116116+}
117117+118118+// TestHTTP_CommentCreateAndList covers the basic comment lifecycle on a
119119+// brew (the most common comment subject): post a comment, list it via the
120120+// HTMX-only GET endpoint, then delete it.
121121+func TestHTTP_CommentCreateAndList(t *testing.T) {
122122+ h := StartHarness(t, nil)
123123+124124+ // Set up something to comment on.
125125+ roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "C Roaster")), "roaster")
126126+ beanRKey := mustRKey(t, h.PostForm("/api/beans", form(
127127+ "name", "C Bean", "roaster_rkey", roasterRKey, "roast_level", "Medium",
128128+ )), "bean")
129129+130130+ createBrew := url.Values{}
131131+ createBrew.Set("bean_rkey", beanRKey)
132132+ createBrew.Set("water_amount", "300")
133133+ createBrew.Set("coffee_amount", "18")
134134+ brewResp := h.PostForm("/brews", createBrew)
135135+ require.Equal(t, 200, brewResp.StatusCode, statusErr(brewResp, ReadBody(t, brewResp)))
136136+137137+ data := fetchData(t, h)
138138+ require.Len(t, data.Brews, 1)
139139+ brewRKey := data.Brews[0].RKey
140140+ subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDBrew, brewRKey)
141141+142142+ // Post a comment.
143143+ commentResp := h.PostForm("/api/comments", form(
144144+ "subject_uri", subjectURI,
145145+ "subject_cid", subjectCID,
146146+ "text", "great extraction",
147147+ ))
148148+ commentBody := ReadBody(t, commentResp)
149149+ require.Equal(t, 200, commentResp.StatusCode, statusErr(commentResp, commentBody))
150150+ assert.Contains(t, commentBody, "great extraction",
151151+ "create response should re-render the comment section including the new comment")
152152+153153+ // Verify via the threaded-comments source of truth.
154154+ indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)
155155+ require.Len(t, indexed, 1)
156156+ assert.Equal(t, "great extraction", indexed[0].Text)
157157+ assert.Equal(t, h.PrimaryAccount.DID, indexed[0].ActorDID)
158158+ commentRKey := indexed[0].RKey
159159+ require.NotEmpty(t, commentRKey)
160160+161161+ // Verify the HTMX list endpoint also returns it.
162162+ listResp := h.GetHTMX("/api/comments?subject_uri=" + url.QueryEscape(subjectURI) + "&subject_cid=" + url.QueryEscape(subjectCID))
163163+ listBody := ReadBody(t, listResp)
164164+ require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody))
165165+ assert.Contains(t, listBody, "great extraction")
166166+167167+ // Delete and verify gone from the index.
168168+ delResp := h.Delete("/api/comments/" + commentRKey)
169169+ require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp)))
170170+171171+ indexed = h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)
172172+ assert.Empty(t, indexed, "comment should be gone after delete")
173173+}
174174+175175+// TestHTTP_CommentReplyThreading exercises the parent_uri/parent_cid
176176+// strongRef path used for reply threading. After posting a top-level
177177+// comment and a reply, the threaded list should return both with the reply
178178+// nested under its parent.
179179+func TestHTTP_CommentReplyThreading(t *testing.T) {
180180+ h := StartHarness(t, nil)
181181+182182+ rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Threaded Roaster")), "roaster")
183183+ subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey)
184184+185185+ // Top-level comment.
186186+ parentResp := h.PostForm("/api/comments", form(
187187+ "subject_uri", subjectURI,
188188+ "subject_cid", subjectCID,
189189+ "text", "parent comment",
190190+ ))
191191+ require.Equal(t, 200, parentResp.StatusCode, statusErr(parentResp, ReadBody(t, parentResp)))
192192+193193+ indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)
194194+ require.Len(t, indexed, 1)
195195+ parent := indexed[0]
196196+ require.NotEmpty(t, parent.CID, "parent comment should have a CID we can strongRef")
197197+198198+ parentURI := atproto.BuildATURI(h.PrimaryAccount.DID, atproto.NSIDComment, parent.RKey)
199199+200200+ // Reply referencing the parent.
201201+ replyResp := h.PostForm("/api/comments", form(
202202+ "subject_uri", subjectURI,
203203+ "subject_cid", subjectCID,
204204+ "text", "child reply",
205205+ "parent_uri", parentURI,
206206+ "parent_cid", parent.CID,
207207+ ))
208208+ require.Equal(t, 200, replyResp.StatusCode, statusErr(replyResp, ReadBody(t, replyResp)))
209209+210210+ // Both comments should appear, with the reply at depth 1 and naming the parent.
211211+ indexed = h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)
212212+ require.Len(t, indexed, 2)
213213+214214+ var top, reply *firehose.IndexedComment
215215+ for i := range indexed {
216216+ switch indexed[i].Text {
217217+ case "parent comment":
218218+ top = &indexed[i]
219219+ case "child reply":
220220+ reply = &indexed[i]
221221+ }
222222+ }
223223+ require.NotNil(t, top, "parent comment missing from threaded list")
224224+ require.NotNil(t, reply, "child reply missing from threaded list")
225225+ assert.Equal(t, 0, top.Depth, "parent should be at depth 0")
226226+ assert.Equal(t, 1, reply.Depth, "reply should be at depth 1")
227227+ assert.Equal(t, parentURI, reply.ParentURI,
228228+ "reply should reference the parent URI")
229229+}
230230+231231+// TestHTTP_CommentValidation covers comment input rejection: missing subject,
232232+// missing text, oversized text, and orphan parent_uri without parent_cid.
233233+func TestHTTP_CommentValidation(t *testing.T) {
234234+ h := StartHarness(t, nil)
235235+236236+ rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Val Roaster")), "roaster")
237237+ subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey)
238238+239239+ cases := []struct {
240240+ name string
241241+ form url.Values
242242+ }{
243243+ {
244244+ name: "missing_subject_uri",
245245+ form: form("subject_cid", subjectCID, "text", "x"),
246246+ },
247247+ {
248248+ name: "missing_subject_cid",
249249+ form: form("subject_uri", subjectURI, "text", "x"),
250250+ },
251251+ {
252252+ name: "empty_text",
253253+ form: form("subject_uri", subjectURI, "subject_cid", subjectCID, "text", ""),
254254+ },
255255+ {
256256+ name: "text_too_long",
257257+ form: form("subject_uri", subjectURI, "subject_cid", subjectCID, "text", strings.Repeat("a", models.MaxCommentLength+1)),
258258+ },
259259+ {
260260+ name: "parent_uri_without_parent_cid",
261261+ form: form(
262262+ "subject_uri", subjectURI,
263263+ "subject_cid", subjectCID,
264264+ "text", "x",
265265+ "parent_uri", "at://did:plc:test/social.arabica.alpha.comment/abc",
266266+ ),
267267+ },
268268+ }
269269+270270+ for _, tc := range cases {
271271+ t.Run(tc.name, func(t *testing.T) {
272272+ resp := h.PostForm("/api/comments", tc.form)
273273+ body := ReadBody(t, resp)
274274+ assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body))
275275+ })
276276+ }
277277+278278+ // Sanity: nothing was indexed.
279279+ indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)
280280+ assert.Empty(t, indexed, "no comments should have been created by failing validation cases")
281281+}
282282+283283+// TestHTTP_LikeAndCommentTogether is a smoke test that walks the full social
284284+// loop: create record, like, comment, list, unlike, delete comment. Catches
285285+// any cross-feature interaction bugs that the focused tests miss.
286286+func TestHTTP_LikeAndCommentTogether(t *testing.T) {
287287+ h := StartHarness(t, nil)
288288+289289+ rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Combined Roaster")), "roaster")
290290+ subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey)
291291+292292+ // Like.
293293+ likeResp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID))
294294+ require.Equal(t, 200, likeResp.StatusCode)
295295+296296+ // Comment.
297297+ commentResp := h.PostForm("/api/comments", form(
298298+ "subject_uri", subjectURI,
299299+ "subject_cid", subjectCID,
300300+ "text", "first impressions: solid",
301301+ ))
302302+ require.Equal(t, 200, commentResp.StatusCode)
303303+304304+ // Both should be visible.
305305+ assert.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI))
306306+ indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)
307307+ require.Len(t, indexed, 1)
308308+ commentRKey := indexed[0].RKey
309309+310310+ // Render the roaster view page — it should show like + comment data
311311+ // pulled from the same feed index. This is the visible end-to-end check.
312312+ viewResp := h.Get("/roasters/" + rkey)
313313+ viewBody := ReadBody(t, viewResp)
314314+ require.Equal(t, 200, viewResp.StatusCode, statusErr(viewResp, viewBody))
315315+ assert.Contains(t, viewBody, "first impressions: solid",
316316+ "comment text should be embedded in the view page")
317317+318318+ // Unlike + delete comment.
319319+ unlikeResp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID))
320320+ require.Equal(t, 200, unlikeResp.StatusCode)
321321+ delResp := h.Delete("/api/comments/" + commentRKey)
322322+ require.Equal(t, 200, delResp.StatusCode)
323323+324324+ assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI))
325325+ assert.Empty(t, h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID))
326326+}
327327+