Commit d0e26d3df9
Verified · cmc
Layout: unified · split
CHANGELOG.org +5
| @@ -102,6 +102,11 @@ for the eighteen commands whose CLI path differs from the registry's | |||
| 102 | inside the migration's own transaction, before commit, so a | 102 | inside the migration's own transaction, before commit, so a |
| 103 | violation rolls the migration back instead of leaving the bad | 103 | violation rolls the migration back instead of leaving the bad |
| 104 | schema and =user_version= already persisted (#261). | 104 | schema and =user_version= already persisted (#261). |
| 105 | - The web pin and watch buttons dispatch through =repo pin=/=unpin= | ||
| 106 | and =repo watch=/=mute=/=unwatch= instead of writing the store | ||
| 107 | directly, so a refusal reaches the viewer as a message instead of | ||
| 108 | being dropped. The watch button now cycles three states — default, | ||
| 109 | watching, muted — instead of two (#261). | ||
| 105 | 110 | ||
| 106 | * v1.36.0 — 2026-09-23 | 111 | * v1.36.0 — 2026-09-23 |
| 107 | 112 | ||
internal/httpd/account_test.go +109
| @@ -175,3 +175,112 @@ func TestAccountPageMasksAShortDeviceToken(t *testing.T) { | |||
| 175 | t.Fatalf("the device table has no id column:\n%s", body) | 175 | t.Fatalf("the device table has no id column:\n%s", body) |
| 176 | } | 176 | } |
| 177 | } | 177 | } |
| 178 | |||
| 179 | // assertAudited fails the test unless an audit row with the given action | ||
| 180 | // prefix exists — proof a handler dispatched through the control | ||
| 181 | // registry rather than writing the store directly, since only Dispatch | ||
| 182 | // itself calls Store.Audit. | ||
| 183 | func assertAudited(t *testing.T, st *store.Store, prefix string) { | ||
| 184 | t.Helper() | ||
| 185 | entries, err := st.AuditEntries(store.AuditFilter{ActionPrefix: prefix, Limit: 10}) | ||
| 186 | if err != nil { | ||
| 187 | t.Fatal(err) | ||
| 188 | } | ||
| 189 | if len(entries) == 0 { | ||
| 190 | t.Fatalf("no audit row with action prefix %q", prefix) | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | // Pinning writes through the repo pin command, not the store directly, | ||
| 195 | // so it carries the same audit trail and write budget as every other | ||
| 196 | // mutating command (#261). | ||
| 197 | func TestPinToggleDispatchesRepoPin(t *testing.T) { | ||
| 198 | st, err := store.Open(":memory:") | ||
| 199 | if err != nil { | ||
| 200 | t.Fatal(err) | ||
| 201 | } | ||
| 202 | defer st.Close() | ||
| 203 | if err := st.MigrateUp(); err != nil { | ||
| 204 | t.Fatal(err) | ||
| 205 | } | ||
| 206 | uid, err := st.CreateUser("alice", false) | ||
| 207 | if err != nil { | ||
| 208 | t.Fatal(err) | ||
| 209 | } | ||
| 210 | u := store.User{ID: uid, Username: "alice"} | ||
| 211 | if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil { | ||
| 212 | t.Fatal(err) | ||
| 213 | } | ||
| 214 | |||
| 215 | s := New(config.Default(), st) | ||
| 216 | req := httptest.NewRequest("POST", "/alice/app/pin", nil) | ||
| 217 | req.SetPathValue("owner", "alice") | ||
| 218 | req.SetPathValue("repo", "app") | ||
| 219 | rr := httptest.NewRecorder() | ||
| 220 | s.pinToggle(rr, req, u) | ||
| 221 | |||
| 222 | repo, err := st.RepoByPath("alice/app") | ||
| 223 | if err != nil { | ||
| 224 | t.Fatal(err) | ||
| 225 | } | ||
| 226 | if !st.IsPinned(uid, repo.ID) { | ||
| 227 | t.Fatal("pin did not take effect") | ||
| 228 | } | ||
| 229 | assertAudited(t, st, "cmd repo pin") | ||
| 230 | |||
| 231 | rr2 := httptest.NewRecorder() | ||
| 232 | s.pinToggle(rr2, req, u) | ||
| 233 | if st.IsPinned(uid, repo.ID) { | ||
| 234 | t.Fatal("second toggle should have unpinned") | ||
| 235 | } | ||
| 236 | assertAudited(t, st, "cmd repo unpin") | ||
| 237 | } | ||
| 238 | |||
| 239 | // The watch button cycles default, watching, muted — the three states | ||
| 240 | // repo watch/repo mute/repo unwatch already support — rather than the | ||
| 241 | // two the store-writing version offered (#261, #271). | ||
| 242 | func TestWatchToggleCyclesThroughMuted(t *testing.T) { | ||
| 243 | st, err := store.Open(":memory:") | ||
| 244 | if err != nil { | ||
| 245 | t.Fatal(err) | ||
| 246 | } | ||
| 247 | defer st.Close() | ||
| 248 | if err := st.MigrateUp(); err != nil { | ||
| 249 | t.Fatal(err) | ||
| 250 | } | ||
| 251 | uid, err := st.CreateUser("alice", false) | ||
| 252 | if err != nil { | ||
| 253 | t.Fatal(err) | ||
| 254 | } | ||
| 255 | u := store.User{ID: uid, Username: "alice"} | ||
| 256 | if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil { | ||
| 257 | t.Fatal(err) | ||
| 258 | } | ||
| 259 | repo, err := st.RepoByPath("alice/app") | ||
| 260 | if err != nil { | ||
| 261 | t.Fatal(err) | ||
| 262 | } | ||
| 263 | |||
| 264 | s := New(config.Default(), st) | ||
| 265 | req := httptest.NewRequest("POST", "/alice/app/watch", nil) | ||
| 266 | req.SetPathValue("owner", "alice") | ||
| 267 | req.SetPathValue("repo", "app") | ||
| 268 | |||
| 269 | click := func() string { | ||
| 270 | rr := httptest.NewRecorder() | ||
| 271 | s.watchToggle(rr, req, u) | ||
| 272 | return st.RepoWatchState(repo.ID, uid) | ||
| 273 | } | ||
| 274 | if got := click(); got != "watching" { | ||
| 275 | t.Fatalf("first click: got %q, want watching", got) | ||
| 276 | } | ||
| 277 | assertAudited(t, st, "cmd repo watch") | ||
| 278 | if got := click(); got != "muted" { | ||
| 279 | t.Fatalf("second click: got %q, want muted", got) | ||
| 280 | } | ||
| 281 | assertAudited(t, st, "cmd repo mute") | ||
| 282 | if got := click(); got != "" { | ||
| 283 | t.Fatalf("third click: got %q, want default (unwatched)", got) | ||
| 284 | } | ||
| 285 | assertAudited(t, st, "cmd repo unwatch") | ||
| 286 | } | ||
internal/httpd/accounts.go +8 −4
| @@ -240,16 +240,20 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) | |||
| 240 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) | 240 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) |
| 241 | } | 241 | } |
| 242 | 242 | ||
| 243 | // pinToggle pins or unpins the repo for the logged-in viewer. | 243 | // pinToggle pins or unpins the repo for the logged-in viewer, through |
| 244 | // repo pin/repo unpin — the same commands the CLI runs — rather than | ||
| 245 | // writing the store directly (#261). | ||
| 244 | func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) { | 246 | func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) { |
| 245 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | 247 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 246 | if !ok { | 248 | if !ok { |
| 247 | return | 249 | return |
| 248 | } | 250 | } |
| 251 | verb := "pin" | ||
| 249 | if s.st.IsPinned(u.ID, repo.ID) { | 252 | if s.st.IsPinned(u.ID, repo.ID) { |
| 250 | s.st.UnpinRepo(u.ID, repo.ID) | 253 | verb = "unpin" |
| 251 | } else { | 254 | } |
| 252 | s.st.PinRepo(u.ID, repo.ID) | 255 | if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok { |
| 256 | s.setFlash(w, msg) | ||
| 253 | } | 257 | } |
| 254 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | 258 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 255 | } | 259 | } |
internal/httpd/notifyweb.go +7 −6
| @@ -56,17 +56,18 @@ func (s *Server) notificationsRead(w http.ResponseWriter, r *http.Request, u sto | |||
| 56 | http.Redirect(w, r, "/notifications", http.StatusSeeOther) | 56 | http.Redirect(w, r, "/notifications", http.StatusSeeOther) |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | // watchToggle turns watching a repository on and off from its header, | 59 | // watchToggle cycles the viewer's watch state on a repository: default, |
| 60 | // the way the pin button does. | 60 | // watching, muted, back to default — through repo watch/repo mute/repo |
| 61 | // unwatch, the same commands the CLI runs (#261, #271). | ||
| 61 | func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) { | 62 | func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) { |
| 62 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | 63 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 63 | if !ok { | 64 | if !ok { |
| 64 | return | 65 | return |
| 65 | } | 66 | } |
| 66 | if s.st.RepoWatchState(repo.ID, u.ID) == "watching" { | 67 | next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"} |
| 67 | s.st.ClearRepoWatch(repo.ID, u.ID) | 68 | verb := next[s.st.RepoWatchState(repo.ID, u.ID)] |
| 68 | } else { | 69 | if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok { |
| 69 | s.st.SetRepoWatch(repo.ID, u.ID, "watching") | 70 | s.setFlash(w, msg) |
| 70 | } | 71 | } |
| 71 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | 72 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 72 | } | 73 | } |