Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: auth, cache, social tests

authored by

Patrick Dewey and committed by tangled.org d110d8f2 06b0b19b

+805 -29
+5 -2
justfile
··· 14 14 @templ generate 15 15 @go test ./... -cover -coverprofile=cover.out 16 16 17 - test-integration: 18 - @cd tests/integration && go test -v ./... -count=1 17 + integration-test: 18 + @cd tests/integration && go test -v ./... -count=1 19 + 20 + verbose-integration-test: 21 + @cd tests/integration && INTEGRATION_LOGS=true go test -v ./... -count=1 19 22 20 23 style: 21 24 @nix develop --command tailwindcss -i static/css/app.css -o static/css/output.css --minify
+289
tests/integration/authz_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "testing" 7 + 8 + "github.com/stretchr/testify/assert" 9 + "github.com/stretchr/testify/require" 10 + ) 11 + 12 + // authzCase describes one entity's mutating-endpoint surface plus how to find 13 + // the test's record in /api/data after the cross-user attempts. 14 + type authzCase struct { 15 + name string 16 + 17 + // HTTP surface. 18 + createPath string // POST target 19 + mutatePath string // template like "/api/beans/%s" — also used for DELETE 20 + 21 + // Forms. 22 + createForm func(refs entityRefs) url.Values 23 + attackForm url.Values // what Bob will PUT 24 + 25 + // extract returns (name, location-or-extra, found) for the given rkey from 26 + // /api/data so the test can verify nothing changed. The "extra" string is 27 + // entity-specific (location for roaster, origin for bean, etc.) and lets 28 + // us assert two fields without writing one extractor per entity. 29 + extract func(data listAllResponse, rkey string) (name, extra string, found bool) 30 + } 31 + 32 + // entityRefs holds dependency rkeys created by the harness fixture so each 33 + // case's createForm closure can reference them (e.g. bean needs a roaster). 34 + type entityRefs struct { 35 + roasterRKey string 36 + brewerRKey string 37 + } 38 + 39 + // TestHTTP_CrossUserMutationIsolation walks every mutating entity surface and 40 + // verifies that Bob cannot affect Alice's records by guessing her rkeys. 41 + // 42 + // For each entity: 43 + // 1. Alice creates a record (captures original name + secondary field). 44 + // 2. Bob attempts PUT and DELETE on Alice's rkey. 45 + // 3. Alice re-reads — name and secondary field must be unchanged and the 46 + // record must still exist. 47 + // 48 + // This is the catastrophic-class authz check, expanded from the roaster-only 49 + // version to cover beans, grinders, brewers, recipes, and brews. 50 + func TestHTTP_CrossUserMutationIsolation(t *testing.T) { 51 + cases := []authzCase{ 52 + { 53 + name: "roaster", 54 + createPath: "/api/roasters", 55 + mutatePath: "/api/roasters/%s", 56 + createForm: func(_ entityRefs) url.Values { 57 + return form("name", "Alice Roaster", "location", "Seattle") 58 + }, 59 + attackForm: form("name", "PWNED", "location", "Hacker House"), 60 + extract: func(data listAllResponse, rkey string) (string, string, bool) { 61 + for _, r := range data.Roasters { 62 + if r.RKey == rkey { 63 + return r.Name, r.Location, true 64 + } 65 + } 66 + return "", "", false 67 + }, 68 + }, 69 + { 70 + name: "bean", 71 + createPath: "/api/beans", 72 + mutatePath: "/api/beans/%s", 73 + createForm: func(refs entityRefs) url.Values { 74 + return form( 75 + "name", "Alice Bean", 76 + "origin", "Ethiopia", 77 + "roaster_rkey", refs.roasterRKey, 78 + "roast_level", "Light", 79 + ) 80 + }, 81 + attackForm: form("name", "PWNED", "origin", "Hacker Origin"), 82 + extract: func(data listAllResponse, rkey string) (string, string, bool) { 83 + for _, b := range data.Beans { 84 + if b.RKey == rkey { 85 + return b.Name, b.Origin, true 86 + } 87 + } 88 + return "", "", false 89 + }, 90 + }, 91 + { 92 + name: "grinder", 93 + createPath: "/api/grinders", 94 + mutatePath: "/api/grinders/%s", 95 + createForm: func(_ entityRefs) url.Values { 96 + return form("name", "Alice Grinder", "grinder_type", "Manual") 97 + }, 98 + attackForm: form("name", "PWNED", "grinder_type", "Hacker"), 99 + extract: func(data listAllResponse, rkey string) (string, string, bool) { 100 + for _, g := range data.Grinders { 101 + if g.RKey == rkey { 102 + return g.Name, g.GrinderType, true 103 + } 104 + } 105 + return "", "", false 106 + }, 107 + }, 108 + { 109 + name: "brewer", 110 + createPath: "/api/brewers", 111 + mutatePath: "/api/brewers/%s", 112 + createForm: func(_ entityRefs) url.Values { 113 + return form("name", "Alice Brewer", "brewer_type", "Pour Over") 114 + }, 115 + attackForm: form("name", "PWNED", "brewer_type", "Hacker"), 116 + extract: func(data listAllResponse, rkey string) (string, string, bool) { 117 + for _, b := range data.Brewers { 118 + if b.RKey == rkey { 119 + return b.Name, b.BrewerType, true 120 + } 121 + } 122 + return "", "", false 123 + }, 124 + }, 125 + { 126 + name: "recipe", 127 + createPath: "/api/recipes", 128 + mutatePath: "/api/recipes/%s", 129 + createForm: func(refs entityRefs) url.Values { 130 + return form( 131 + "name", "Alice Recipe", 132 + "brewer_rkey", refs.brewerRKey, 133 + "brewer_type", "Pour Over", 134 + "coffee_amount", "18", 135 + "water_amount", "300", 136 + "notes", "original notes", 137 + ) 138 + }, 139 + attackForm: form( 140 + "name", "PWNED", 141 + "brewer_type", "Pour Over", 142 + "notes", "hacker notes", 143 + ), 144 + extract: func(data listAllResponse, rkey string) (string, string, bool) { 145 + for _, r := range data.Recipes { 146 + if r.RKey == rkey { 147 + return r.Name, r.Notes, true 148 + } 149 + } 150 + return "", "", false 151 + }, 152 + }, 153 + } 154 + 155 + for _, tc := range cases { 156 + t.Run(tc.name, func(t *testing.T) { 157 + h := StartHarness(t, nil) 158 + 159 + // Alice's per-test fixture: a roaster + brewer the create forms can reference. 160 + refs := entityRefs{ 161 + roasterRKey: mustRKey(t, h.PostForm("/api/roasters", form("name", "Refs Roaster")), "roaster"), 162 + brewerRKey: mustRKey(t, h.PostForm("/api/brewers", form("name", "Refs Brewer", "brewer_type", "Pour Over")), "brewer"), 163 + } 164 + 165 + // Alice creates the entity under test. 166 + createResp := h.PostForm(tc.createPath, tc.createForm(refs)) 167 + rkey := mustRKey(t, createResp, tc.name) 168 + 169 + // Capture the original (name, extra) for later comparison. 170 + origData := fetchData(t, h) 171 + origName, origExtra, ok := tc.extract(origData, rkey) 172 + require.True(t, ok, "%s not found right after create", tc.name) 173 + require.NotEmpty(t, origName) 174 + 175 + // Bob signs in. 176 + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") 177 + bobClient := h.NewClientForAccount(bob) 178 + 179 + // Bob attempts PUT and DELETE while masquerading as the harness client. 180 + func() { 181 + restore := withClient(h, bobClient) 182 + defer restore() 183 + 184 + putResp := h.PutForm(fmt.Sprintf(tc.mutatePath, rkey), tc.attackForm) 185 + putBody := ReadBody(t, putResp) 186 + t.Logf("bob PUT %s: status=%d body=%s", tc.name, putResp.StatusCode, truncate(putBody, 200)) 187 + 188 + delResp := h.Delete(fmt.Sprintf(tc.mutatePath, rkey)) 189 + delBody := ReadBody(t, delResp) 190 + t.Logf("bob DELETE %s: status=%d body=%s", tc.name, delResp.StatusCode, truncate(delBody, 200)) 191 + }() 192 + 193 + // Back as Alice — verify the record is intact and unchanged. 194 + // 195 + // Reads go through the session cache; Alice's session was never 196 + // invalidated by Bob's writes (those went to Bob's PDS, not 197 + // Alice's), so cached state would be stale-but-correct here. To be 198 + // extra safe and detect actual data mutation, we evict Alice's 199 + // session cache to force a witness/PDS re-read. 200 + h.InvalidateSessionCache(h.PrimaryAccount) 201 + 202 + data := fetchData(t, h) 203 + gotName, gotExtra, found := tc.extract(data, rkey) 204 + require.True(t, found, "Alice's %s must still exist after Bob's attempts", tc.name) 205 + assert.Equal(t, origName, gotName, "Alice's %s name must be unchanged", tc.name) 206 + assert.Equal(t, origExtra, gotExtra, "Alice's %s secondary field must be unchanged", tc.name) 207 + }) 208 + } 209 + } 210 + 211 + // TestHTTP_CrossUserBrewIsolation is the brew-specific version of the authz 212 + // matrix above. Brew uses different routes (/brews/{id}) and a much wider 213 + // form, so it's split out rather than wedged into the table. 214 + func TestHTTP_CrossUserBrewIsolation(t *testing.T) { 215 + h := StartHarness(t, nil) 216 + 217 + // Alice creates a brew + its dependencies. 218 + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Alice Roaster")), "roaster") 219 + beanRKey := mustRKey(t, h.PostForm("/api/beans", form( 220 + "name", "Alice Bean", 221 + "roaster_rkey", roasterRKey, 222 + "roast_level", "Medium", 223 + )), "bean") 224 + brewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "Alice V60", "brewer_type", "Pour Over")), "brewer") 225 + 226 + createForm := url.Values{} 227 + createForm.Set("bean_rkey", beanRKey) 228 + createForm.Set("brewer_rkey", brewerRKey) 229 + createForm.Set("method", "Pour Over") 230 + createForm.Set("water_amount", "300") 231 + createForm.Set("coffee_amount", "18") 232 + createForm.Set("rating", "8") 233 + createForm.Set("tasting_notes", "original notes") 234 + createResp := h.PostForm("/brews", createForm) 235 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, ReadBody(t, createResp))) 236 + 237 + data := fetchData(t, h) 238 + require.Len(t, data.Brews, 1) 239 + brewRKey := data.Brews[0].RKey 240 + 241 + // Bob signs in and attacks. 242 + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") 243 + bobClient := h.NewClientForAccount(bob) 244 + 245 + func() { 246 + restore := withClient(h, bobClient) 247 + defer restore() 248 + 249 + // Bob's PUT requires a valid bean_rkey from his own context. Use a fake 250 + // (well-formed) rkey — handler should reject because it doesn't exist 251 + // in Bob's PDS, and even if it doesn't, Alice's record must be safe. 252 + attack := url.Values{} 253 + attack.Set("bean_rkey", beanRKey) // Alice's rkey — handler treats it as Bob's 254 + attack.Set("brewer_rkey", brewerRKey) 255 + attack.Set("method", "PWNED METHOD") 256 + attack.Set("water_amount", "1") 257 + attack.Set("coffee_amount", "1") 258 + attack.Set("rating", "1") 259 + attack.Set("tasting_notes", "hacker notes") 260 + 261 + putResp := h.PutForm("/brews/"+brewRKey, attack) 262 + putBody := ReadBody(t, putResp) 263 + t.Logf("bob PUT brew: status=%d body=%s", putResp.StatusCode, truncate(putBody, 200)) 264 + 265 + delResp := h.Delete("/brews/" + brewRKey) 266 + delBody := ReadBody(t, delResp) 267 + t.Logf("bob DELETE brew: status=%d body=%s", delResp.StatusCode, truncate(delBody, 200)) 268 + }() 269 + 270 + // Back as Alice — verify her brew is intact. 271 + h.InvalidateSessionCache(h.PrimaryAccount) 272 + data = fetchData(t, h) 273 + require.Len(t, data.Brews, 1, "Alice's brew must still exist after Bob's attempts") 274 + brew := data.Brews[0] 275 + assert.Equal(t, brewRKey, brew.RKey) 276 + assert.Equal(t, "Pour Over", brew.Method, "method must not be overwritten") 277 + assert.Equal(t, "original notes", brew.TastingNotes, "tasting notes must not be overwritten") 278 + assert.Equal(t, 8, brew.Rating, "rating must not be overwritten") 279 + assert.Equal(t, 300, brew.WaterAmount, "water amount must not be overwritten") 280 + } 281 + 282 + // truncate returns s shortened to max chars with an ellipsis suffix when 283 + // truncated. Used to keep test logs from drowning in HTML error pages. 284 + func truncate(s string, max int) string { 285 + if len(s) <= max { 286 + return s 287 + } 288 + return s[:max] + "…" 289 + }
+117
tests/integration/cache_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + 7 + "arabica/internal/atproto" 8 + "arabica/internal/models" 9 + 10 + "github.com/stretchr/testify/assert" 11 + "github.com/stretchr/testify/require" 12 + ) 13 + 14 + // TestHTTP_WitnessCacheFallback verifies that reads succeed even when both 15 + // cache layers (session cache + witness cache) are empty, by falling through 16 + // to a real PDS XRPC call. 17 + // 18 + // This is the riskiest architectural piece in the codebase: if write-through 19 + // ever drifts from PDS reads, or if the fallback path silently breaks, users 20 + // would see "missing" data right after creating it. This test exercises: 21 + // 22 + // 1. Create a roaster (write-through to witness cache happens here). 23 + // 2. Confirm a normal read returns it (witness-cache hit path). 24 + // 3. Evict the witness cache entry + invalidate the session cache. 25 + // 4. Read again — must still return the same data, this time via the 26 + // real-PDS fallback inside AtprotoStore.GetRoasterByRKey/ListRoasters. 27 + func TestHTTP_WitnessCacheFallback(t *testing.T) { 28 + h := StartHarness(t, nil) 29 + 30 + // Step 1: create a roaster. 31 + createResp := h.PostForm("/api/roasters", form( 32 + "name", "Cache Fallback Roaster", 33 + "location", "Portland", 34 + "website", "https://example.com", 35 + )) 36 + createBody := ReadBody(t, createResp) 37 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) 38 + 39 + var created models.Roaster 40 + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) 41 + require.NotEmpty(t, created.RKey) 42 + 43 + // Step 2: read via /api/data — this should hit the witness cache (or 44 + // session cache, populated by ListRoasters). 45 + preData := fetchData(t, h) 46 + prePresent := containsRoaster(preData.Roasters, created.RKey) 47 + require.True(t, prePresent, "roaster should be readable immediately after create") 48 + 49 + // Step 3: evict the witness record and clear the session cache. After 50 + // this, both fast paths in AtprotoStore.ListRoasters will miss and the 51 + // store has to fall through to s.client.ListAllRecords (real PDS read). 52 + h.EvictWitnessRecord(h.PrimaryAccount, atproto.NSIDRoaster, created.RKey) 53 + h.InvalidateSessionCache(h.PrimaryAccount) 54 + 55 + // Sanity check: confirm the witness cache really is empty for that record. 56 + wr, _ := h.FeedIndex.GetWitnessRecord(t.Context(), atproto.BuildATURI(h.PrimaryAccount.DID, atproto.NSIDRoaster, created.RKey)) 57 + require.Nil(t, wr, "witness record should have been evicted") 58 + 59 + // Step 4: read again — must still return the roaster, this time via the 60 + // PDS fallback path. 61 + postData := fetchData(t, h) 62 + var found *models.Roaster 63 + for i := range postData.Roasters { 64 + if postData.Roasters[i].RKey == created.RKey { 65 + found = &postData.Roasters[i] 66 + break 67 + } 68 + } 69 + require.NotNil(t, found, "roaster must still be readable via PDS fallback after both caches are empty") 70 + 71 + // Field-level: the fallback path goes through a different decode path 72 + // (RecordToRoaster on a fresh PDS payload, not WitnessRecordToMap on 73 + // cached JSON). Verify the round-trip preserves all the fields we set. 74 + assert.Equal(t, "Cache Fallback Roaster", found.Name) 75 + assert.Equal(t, "Portland", found.Location) 76 + assert.Equal(t, "https://example.com", found.Website) 77 + } 78 + 79 + // TestHTTP_WitnessCacheGetByRKeyFallback covers the per-record (not list) 80 + // fallback path: GetRoasterByRKey hits witness cache first, then falls back 81 + // to a single PDS GetRecord call. This path is used by HandleRoasterView and 82 + // other view handlers, so a regression here would surface as "404 not found" 83 + // on detail pages right after creation. 84 + func TestHTTP_WitnessCacheGetByRKeyFallback(t *testing.T) { 85 + h := StartHarness(t, nil) 86 + 87 + createResp := h.PostForm("/api/roasters", form("name", "Single-Get Fallback")) 88 + createBody := ReadBody(t, createResp) 89 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) 90 + 91 + var created models.Roaster 92 + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) 93 + 94 + // Evict caches. 95 + h.EvictWitnessRecord(h.PrimaryAccount, atproto.NSIDRoaster, created.RKey) 96 + h.InvalidateSessionCache(h.PrimaryAccount) 97 + 98 + // The view page calls GetRoasterRecordByRKey via HandleRoasterView. With 99 + // no owner= param this goes through the authenticated store path and 100 + // should hit the PDS fallback. 101 + resp := h.Get("/roasters/" + created.RKey) 102 + body := ReadBody(t, resp) 103 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) 104 + assert.Contains(t, body, "Single-Get Fallback", 105 + "roaster name should appear on view page after PDS fallback read") 106 + } 107 + 108 + // containsRoaster reports whether a roaster with the given rkey exists in the 109 + // slice. Small helper used by cache tests. 110 + func containsRoaster(roasters []models.Roaster, rkey string) bool { 111 + for _, r := range roasters { 112 + if r.RKey == rkey { 113 + return true 114 + } 115 + } 116 + return false 117 + }
+1 -1
tests/integration/go.mod
··· 13 13 github.com/haileyok/cocoon v0.9.0 14 14 github.com/rs/zerolog v1.34.0 15 15 github.com/stretchr/testify v1.11.1 16 + gorm.io/gorm v1.31.1 16 17 tangled.org/pdewey.com/atp v0.0.0-20260407015143-f53954e5e783 17 18 ) 18 19 ··· 161 162 gopkg.in/yaml.v3 v3.0.1 // indirect 162 163 gorm.io/driver/postgres v1.6.0 // indirect 163 164 gorm.io/driver/sqlite v1.6.0 // indirect 164 - gorm.io/gorm v1.31.1 // indirect 165 165 lukechampine.com/blake3 v1.4.1 // indirect 166 166 modernc.org/libc v1.70.0 // indirect 167 167 modernc.org/mathutil v1.7.1 // indirect
+66 -26
tests/integration/harness.go
··· 30 30 zlog "github.com/rs/zerolog/log" 31 31 "github.com/stretchr/testify/require" 32 32 "tangled.org/pdewey.com/atp" 33 + gormlogger "gorm.io/gorm/logger" 33 34 ) 34 35 35 - // silenceLogs redirects all the noisy log outputs that show up during 36 - // integration tests (cocoon's stdlib log + slog + GORM, arabica's zerolog) to 37 - // io.Discard. This keeps `go test -v` output focused on actual test results. 38 - // 39 - // Without this, even passing runs scroll dozens of lines of GORM 40 - // "record not found" warnings, arabica request debug logs, and cocoon's 41 - // per-request access logs. 42 - // 43 - // Set INTEGRATION_VERBOSE=1 to keep the logs visible — useful when a test is 44 - // failing and you want to see what arabica or cocoon were doing at the time. 36 + func init() { 37 + // Cocoon constructs its gorm sessions with `&gorm.Config{}` (no Logger), 38 + // so each session falls back to gormlogger.Default. Replace it with one 39 + // that ignores ErrRecordNotFound — cocoon's preflight existence checks 40 + // (handle/email/seq lookups on a fresh test DB) otherwise spam yellow 41 + // "record not found" warnings on every test run. 42 + gormlogger.Default = gormlogger.New( 43 + stdlog.New(os.Stdout, "\r\n", stdlog.LstdFlags), 44 + gormlogger.Config{ 45 + SlowThreshold: 200 * time.Millisecond, 46 + LogLevel: gormlogger.Warn, 47 + IgnoreRecordNotFoundError: true, 48 + Colorful: true, 49 + }, 50 + ) 51 + } 52 + 53 + // silenceLogs routes the noisy log outputs that show up during integration 54 + // tests (cocoon's stdlib log + slog, arabica's zerolog) to io.Discard so 55 + // passing runs aren't drowned in per-request access logs and handler debug 56 + // lines. 45 57 // 46 - // Note: without -v, `go test` already captures per-test output and only prints 47 - // it on failure, so this silencing is mainly relevant for `go test -v`. 58 + // Set INTEGRATION_LOGS=1 to keep them visible — arabica's zerolog gets routed 59 + // through a colored ConsoleWriter so its lines blend in with cocoon's 60 + // gorm/slog output. 48 61 func silenceLogs() { 49 - if v := os.Getenv("INTEGRATION_VERBOSE"); v == "1" || v == "true" { 62 + if v := os.Getenv("INTEGRATION_LOGS"); v == "1" || v == "true" { 63 + zlog.Logger = zerolog.New(zerolog.ConsoleWriter{ 64 + Out: os.Stdout, 65 + TimeFormat: time.RFC3339, 66 + }).With().Timestamp().Logger() 50 67 return 51 68 } 52 - // stdlib log (some GORM logger configurations write here) 53 69 stdlog.SetOutput(io.Discard) 54 - // log/slog (cocoon's slogecho middleware, server lifecycle logs) 55 70 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) 56 - // zerolog (arabica's handler debug/info logs go through the global logger) 57 71 zlog.Logger = zerolog.New(io.Discard) 58 72 } 59 73 ··· 70 84 // PDS and exposes an httptest.Server. Auth is faked via custom headers so 71 85 // tests can act as any DID without an OAuth dance. 72 86 type Harness struct { 73 - T *testing.T 74 - PDS *testpds.TestPDS 75 - Server *httptest.Server 76 - Handler *handlers.Handler 77 - FeedIndex *firehose.FeedIndex 87 + T *testing.T 88 + PDS *testpds.TestPDS 89 + Server *httptest.Server 90 + Handler *handlers.Handler 91 + FeedIndex *firehose.FeedIndex 92 + SessionCache *atproto.SessionCache 78 93 79 94 // PrimaryAccount is the default account created on harness setup. 80 95 PrimaryAccount TestAccount ··· 138 153 feedIndex, err := firehose.NewFeedIndex(t.TempDir()+"/feed-index.db", 1*time.Hour) 139 154 require.NoError(t, err) 140 155 156 + sessionCache := atproto.NewSessionCache() 157 + 141 158 harness := &Harness{ 142 - T: t, 143 - PDS: pds, 144 - FeedIndex: feedIndex, 145 - accounts: make(map[string]*atclient.APIClient), 159 + T: t, 160 + PDS: pds, 161 + FeedIndex: feedIndex, 162 + SessionCache: sessionCache, 163 + accounts: make(map[string]*atclient.APIClient), 146 164 } 147 165 148 166 // Provider routes XRPC calls based on the DID in the request context. The ··· 165 183 oauthMgr, err := atproto.NewOAuthManager("", "http://localhost/oauth/callback", nil) 166 184 require.NoError(t, err) 167 185 168 - sessionCache := atproto.NewSessionCache() 169 186 feedRegistry := feed.NewRegistry() 170 187 feedService := feed.NewService(feedRegistry) 171 188 ··· 307 324 resp, err := h.Client.Do(req) 308 325 require.NoError(h.T, err) 309 326 return resp 327 + } 328 + 329 + // SessionIDFor returns the test session ID assigned to an account by 330 + // authInjectingTransport. Tests use it when reaching into the session cache 331 + // directly (e.g. to evict an entry to force a witness/PDS read). 332 + func (h *Harness) SessionIDFor(acct TestAccount) string { 333 + return "test-session-" + acct.DID 334 + } 335 + 336 + // EvictWitnessRecord deletes a single record from the witness cache by 337 + // (collection, rkey). Tests call this to force a witness-cache miss without 338 + // going through the delete handler (which would also delete from the PDS). 339 + // Combined with InvalidateSessionCache, this exercises the PDS fallback path. 340 + func (h *Harness) EvictWitnessRecord(acct TestAccount, collection, rkey string) { 341 + h.T.Helper() 342 + require.NoError(h.T, h.FeedIndex.DeleteWitnessRecord(context.Background(), acct.DID, collection, rkey)) 343 + } 344 + 345 + // InvalidateSessionCache wipes the per-session in-memory cache for an account. 346 + // Tests use it together with EvictWitnessRecord to force the store to fall 347 + // through both cache layers down to a real PDS read. 348 + func (h *Harness) InvalidateSessionCache(acct TestAccount) { 349 + h.SessionCache.Invalidate(h.SessionIDFor(acct)) 310 350 } 311 351 312 352 // Delete sends a DELETE request as the primary account.
+327
tests/integration/social_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "context" 5 + "net/url" 6 + "strings" 7 + "testing" 8 + 9 + "arabica/internal/atproto" 10 + "arabica/internal/firehose" 11 + "arabica/internal/models" 12 + 13 + "github.com/stretchr/testify/assert" 14 + "github.com/stretchr/testify/require" 15 + ) 16 + 17 + // subjectRefFor looks up the AT-URI and CID for a record from the witness 18 + // cache. Likes and comments need a (subject_uri, subject_cid) pair, but the 19 + // entity create handlers don't return CID directly — it's persisted into the 20 + // witness cache by the write-through, which is what view handlers also use to 21 + // build social-feature props. 22 + func subjectRefFor(t *testing.T, h *Harness, acct TestAccount, collection, rkey string) (uri, cid string) { 23 + t.Helper() 24 + uri = atproto.BuildATURI(acct.DID, collection, rkey) 25 + wr, err := h.FeedIndex.GetWitnessRecord(context.Background(), uri) 26 + require.NoError(t, err) 27 + require.NotNil(t, wr, "witness record missing for %s", uri) 28 + require.NotEmpty(t, wr.CID, "witness record has empty CID") 29 + return uri, wr.CID 30 + } 31 + 32 + // TestHTTP_LikeToggleFlow exercises the like toggle endpoint end-to-end: 33 + // like → verify count → unlike → verify count. The handler returns a rendered 34 + // LikeButton fragment, but the source of truth is the firehose index, so we 35 + // assert against GetLikeCount rather than parse HTML. 36 + func TestHTTP_LikeToggleFlow(t *testing.T) { 37 + h := StartHarness(t, nil) 38 + 39 + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Likeable Roaster")), "roaster") 40 + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) 41 + 42 + // Initial state: no likes. 43 + assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) 44 + 45 + // Like. 46 + likeResp := h.PostForm("/api/likes/toggle", form( 47 + "subject_uri", subjectURI, 48 + "subject_cid", subjectCID, 49 + )) 50 + likeBody := ReadBody(t, likeResp) 51 + require.Equal(t, 200, likeResp.StatusCode, statusErr(likeResp, likeBody)) 52 + assert.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI), 53 + "like count should be 1 after liking") 54 + 55 + // Toggle off. 56 + unlikeResp := h.PostForm("/api/likes/toggle", form( 57 + "subject_uri", subjectURI, 58 + "subject_cid", subjectCID, 59 + )) 60 + unlikeBody := ReadBody(t, unlikeResp) 61 + require.Equal(t, 200, unlikeResp.StatusCode, statusErr(unlikeResp, unlikeBody)) 62 + assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI), 63 + "like count should be 0 after unliking") 64 + } 65 + 66 + // TestHTTP_LikeCrossUser verifies that when Bob likes Alice's record, the 67 + // count reflects both users' likes independently. Each like is stored in the 68 + // liker's PDS but indexed against the subject URI, so this exercises the 69 + // "many likers, one subject" path. 70 + func TestHTTP_LikeCrossUser(t *testing.T) { 71 + h := StartHarness(t, nil) 72 + 73 + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Popular Roaster")), "roaster") 74 + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) 75 + 76 + // Alice likes her own record. 77 + resp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) 78 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, ReadBody(t, resp))) 79 + require.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) 80 + 81 + // Bob signs in and likes the same record. 82 + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") 83 + bobClient := h.NewClientForAccount(bob) 84 + func() { 85 + restore := withClient(h, bobClient) 86 + defer restore() 87 + resp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) 88 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, ReadBody(t, resp))) 89 + }() 90 + 91 + assert.Equal(t, 2, h.FeedIndex.GetLikeCount(context.Background(), subjectURI), 92 + "count should reflect likes from both Alice and Bob") 93 + } 94 + 95 + // TestHTTP_LikeValidation covers the input rejection paths in the like 96 + // toggle handler: missing subject_uri or subject_cid must return 400. 97 + func TestHTTP_LikeValidation(t *testing.T) { 98 + h := StartHarness(t, nil) 99 + 100 + cases := []struct { 101 + name string 102 + form url.Values 103 + }{ 104 + {"missing_uri", form("subject_cid", "bafyfake")}, 105 + {"missing_cid", form("subject_uri", "at://did:plc:test/social.arabica.alpha.roaster/abc")}, 106 + {"both_missing", url.Values{}}, 107 + } 108 + 109 + for _, tc := range cases { 110 + t.Run(tc.name, func(t *testing.T) { 111 + resp := h.PostForm("/api/likes/toggle", tc.form) 112 + body := ReadBody(t, resp) 113 + assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body)) 114 + }) 115 + } 116 + } 117 + 118 + // TestHTTP_CommentCreateAndList covers the basic comment lifecycle on a 119 + // brew (the most common comment subject): post a comment, list it via the 120 + // HTMX-only GET endpoint, then delete it. 121 + func TestHTTP_CommentCreateAndList(t *testing.T) { 122 + h := StartHarness(t, nil) 123 + 124 + // Set up something to comment on. 125 + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "C Roaster")), "roaster") 126 + beanRKey := mustRKey(t, h.PostForm("/api/beans", form( 127 + "name", "C Bean", "roaster_rkey", roasterRKey, "roast_level", "Medium", 128 + )), "bean") 129 + 130 + createBrew := url.Values{} 131 + createBrew.Set("bean_rkey", beanRKey) 132 + createBrew.Set("water_amount", "300") 133 + createBrew.Set("coffee_amount", "18") 134 + brewResp := h.PostForm("/brews", createBrew) 135 + require.Equal(t, 200, brewResp.StatusCode, statusErr(brewResp, ReadBody(t, brewResp))) 136 + 137 + data := fetchData(t, h) 138 + require.Len(t, data.Brews, 1) 139 + brewRKey := data.Brews[0].RKey 140 + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDBrew, brewRKey) 141 + 142 + // Post a comment. 143 + commentResp := h.PostForm("/api/comments", form( 144 + "subject_uri", subjectURI, 145 + "subject_cid", subjectCID, 146 + "text", "great extraction", 147 + )) 148 + commentBody := ReadBody(t, commentResp) 149 + require.Equal(t, 200, commentResp.StatusCode, statusErr(commentResp, commentBody)) 150 + assert.Contains(t, commentBody, "great extraction", 151 + "create response should re-render the comment section including the new comment") 152 + 153 + // Verify via the threaded-comments source of truth. 154 + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) 155 + require.Len(t, indexed, 1) 156 + assert.Equal(t, "great extraction", indexed[0].Text) 157 + assert.Equal(t, h.PrimaryAccount.DID, indexed[0].ActorDID) 158 + commentRKey := indexed[0].RKey 159 + require.NotEmpty(t, commentRKey) 160 + 161 + // Verify the HTMX list endpoint also returns it. 162 + listResp := h.GetHTMX("/api/comments?subject_uri=" + url.QueryEscape(subjectURI) + "&subject_cid=" + url.QueryEscape(subjectCID)) 163 + listBody := ReadBody(t, listResp) 164 + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) 165 + assert.Contains(t, listBody, "great extraction") 166 + 167 + // Delete and verify gone from the index. 168 + delResp := h.Delete("/api/comments/" + commentRKey) 169 + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp))) 170 + 171 + indexed = h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) 172 + assert.Empty(t, indexed, "comment should be gone after delete") 173 + } 174 + 175 + // TestHTTP_CommentReplyThreading exercises the parent_uri/parent_cid 176 + // strongRef path used for reply threading. After posting a top-level 177 + // comment and a reply, the threaded list should return both with the reply 178 + // nested under its parent. 179 + func TestHTTP_CommentReplyThreading(t *testing.T) { 180 + h := StartHarness(t, nil) 181 + 182 + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Threaded Roaster")), "roaster") 183 + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) 184 + 185 + // Top-level comment. 186 + parentResp := h.PostForm("/api/comments", form( 187 + "subject_uri", subjectURI, 188 + "subject_cid", subjectCID, 189 + "text", "parent comment", 190 + )) 191 + require.Equal(t, 200, parentResp.StatusCode, statusErr(parentResp, ReadBody(t, parentResp))) 192 + 193 + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) 194 + require.Len(t, indexed, 1) 195 + parent := indexed[0] 196 + require.NotEmpty(t, parent.CID, "parent comment should have a CID we can strongRef") 197 + 198 + parentURI := atproto.BuildATURI(h.PrimaryAccount.DID, atproto.NSIDComment, parent.RKey) 199 + 200 + // Reply referencing the parent. 201 + replyResp := h.PostForm("/api/comments", form( 202 + "subject_uri", subjectURI, 203 + "subject_cid", subjectCID, 204 + "text", "child reply", 205 + "parent_uri", parentURI, 206 + "parent_cid", parent.CID, 207 + )) 208 + require.Equal(t, 200, replyResp.StatusCode, statusErr(replyResp, ReadBody(t, replyResp))) 209 + 210 + // Both comments should appear, with the reply at depth 1 and naming the parent. 211 + indexed = h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) 212 + require.Len(t, indexed, 2) 213 + 214 + var top, reply *firehose.IndexedComment 215 + for i := range indexed { 216 + switch indexed[i].Text { 217 + case "parent comment": 218 + top = &indexed[i] 219 + case "child reply": 220 + reply = &indexed[i] 221 + } 222 + } 223 + require.NotNil(t, top, "parent comment missing from threaded list") 224 + require.NotNil(t, reply, "child reply missing from threaded list") 225 + assert.Equal(t, 0, top.Depth, "parent should be at depth 0") 226 + assert.Equal(t, 1, reply.Depth, "reply should be at depth 1") 227 + assert.Equal(t, parentURI, reply.ParentURI, 228 + "reply should reference the parent URI") 229 + } 230 + 231 + // TestHTTP_CommentValidation covers comment input rejection: missing subject, 232 + // missing text, oversized text, and orphan parent_uri without parent_cid. 233 + func TestHTTP_CommentValidation(t *testing.T) { 234 + h := StartHarness(t, nil) 235 + 236 + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Val Roaster")), "roaster") 237 + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) 238 + 239 + cases := []struct { 240 + name string 241 + form url.Values 242 + }{ 243 + { 244 + name: "missing_subject_uri", 245 + form: form("subject_cid", subjectCID, "text", "x"), 246 + }, 247 + { 248 + name: "missing_subject_cid", 249 + form: form("subject_uri", subjectURI, "text", "x"), 250 + }, 251 + { 252 + name: "empty_text", 253 + form: form("subject_uri", subjectURI, "subject_cid", subjectCID, "text", ""), 254 + }, 255 + { 256 + name: "text_too_long", 257 + form: form("subject_uri", subjectURI, "subject_cid", subjectCID, "text", strings.Repeat("a", models.MaxCommentLength+1)), 258 + }, 259 + { 260 + name: "parent_uri_without_parent_cid", 261 + form: form( 262 + "subject_uri", subjectURI, 263 + "subject_cid", subjectCID, 264 + "text", "x", 265 + "parent_uri", "at://did:plc:test/social.arabica.alpha.comment/abc", 266 + ), 267 + }, 268 + } 269 + 270 + for _, tc := range cases { 271 + t.Run(tc.name, func(t *testing.T) { 272 + resp := h.PostForm("/api/comments", tc.form) 273 + body := ReadBody(t, resp) 274 + assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body)) 275 + }) 276 + } 277 + 278 + // Sanity: nothing was indexed. 279 + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) 280 + assert.Empty(t, indexed, "no comments should have been created by failing validation cases") 281 + } 282 + 283 + // TestHTTP_LikeAndCommentTogether is a smoke test that walks the full social 284 + // loop: create record, like, comment, list, unlike, delete comment. Catches 285 + // any cross-feature interaction bugs that the focused tests miss. 286 + func TestHTTP_LikeAndCommentTogether(t *testing.T) { 287 + h := StartHarness(t, nil) 288 + 289 + rkey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Combined Roaster")), "roaster") 290 + subjectURI, subjectCID := subjectRefFor(t, h, h.PrimaryAccount, atproto.NSIDRoaster, rkey) 291 + 292 + // Like. 293 + likeResp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) 294 + require.Equal(t, 200, likeResp.StatusCode) 295 + 296 + // Comment. 297 + commentResp := h.PostForm("/api/comments", form( 298 + "subject_uri", subjectURI, 299 + "subject_cid", subjectCID, 300 + "text", "first impressions: solid", 301 + )) 302 + require.Equal(t, 200, commentResp.StatusCode) 303 + 304 + // Both should be visible. 305 + assert.Equal(t, 1, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) 306 + indexed := h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID) 307 + require.Len(t, indexed, 1) 308 + commentRKey := indexed[0].RKey 309 + 310 + // Render the roaster view page — it should show like + comment data 311 + // pulled from the same feed index. This is the visible end-to-end check. 312 + viewResp := h.Get("/roasters/" + rkey) 313 + viewBody := ReadBody(t, viewResp) 314 + require.Equal(t, 200, viewResp.StatusCode, statusErr(viewResp, viewBody)) 315 + assert.Contains(t, viewBody, "first impressions: solid", 316 + "comment text should be embedded in the view page") 317 + 318 + // Unlike + delete comment. 319 + unlikeResp := h.PostForm("/api/likes/toggle", form("subject_uri", subjectURI, "subject_cid", subjectCID)) 320 + require.Equal(t, 200, unlikeResp.StatusCode) 321 + delResp := h.Delete("/api/comments/" + commentRKey) 322 + require.Equal(t, 200, delResp.StatusCode) 323 + 324 + assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) 325 + assert.Empty(t, h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)) 326 + } 327 +