test(events): owner topic access matrix, token end, bearer refusal, stale index
- commit
- d66b108197d1e412dd67884ec50bc2df888739e3
- author
- brice · · 2026-10-19 06:49 UTC
- parent
- 063865b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +285 -1
M internal/adapters/kvstore/export_test.go +24 -1
| @@ -2,7 +2,13 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package kvstore | |
| 4 | 4 | ||
| 5 | - | import "context" | |
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | kv "bnema.dev/ermite-kv" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | ) | |
| 6 | 12 | ||
| 7 | 13 | // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of | |
| 8 | 14 | // DeleteEnvironment and its batch, and returns the function that removes it. | |
| @@ -34,3 +40,20 @@ func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int { | |||
| 34 | 40 | } | |
| 35 | 41 | return n | |
| 36 | 42 | } | |
| 43 | + | ||
| 44 | + | // DropIssueRecordForTest deletes the issue record and leaves its open index | |
| 45 | + | // entry: the state of an index that is stale. | |
| 46 | + | func (i *Issues) DropIssueRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error { | |
| 47 | + | var b kv.Batch | |
| 48 | + | b.Delete(issueKey(repo, n)) | |
| 49 | + | _, err := i.db.Apply(ctx, &b) | |
| 50 | + | return err | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // DropMRRecordForTest is DropIssueRecordForTest for a merge request. | |
| 54 | + | func (m *MergeRequests) DropMRRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error { | |
| 55 | + | var b kv.Batch | |
| 56 | + | b.Delete(mrKey(repo, n)) | |
| 57 | + | _, err := m.db.Apply(ctx, &b) | |
| 58 | + | return err | |
| 59 | + | } | |
| @@ -2,7 +2,13 @@ | |||||
| 2 | 2 | ||||
| 3 | package kvstore | 3 | package kvstore | ||
| 4 | 4 | ||||
| 5 | - | import "context" | 5 | + | import ( |
| 6 | + | "context" | |||
| 7 | + | ||||
| 8 | + | kv "bnema.dev/ermite-kv" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | ) | |||
| 6 | 12 | ||||
| 7 | // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of | 13 | // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of | ||
| 8 | // DeleteEnvironment and its batch, and returns the function that removes it. | 14 | // DeleteEnvironment and its batch, and returns the function that removes it. | ||
| @@ -34,3 +40,20 @@ func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int { | |||||
| 34 | } | 40 | } | ||
| 35 | return n | 41 | return n | ||
| 36 | } | 42 | } | ||
| 43 | + | ||||
| 44 | + | // DropIssueRecordForTest deletes the issue record and leaves its open index | |||
| 45 | + | // entry: the state of an index that is stale. | |||
| 46 | + | func (i *Issues) DropIssueRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error { | |||
| 47 | + | var b kv.Batch | |||
| 48 | + | b.Delete(issueKey(repo, n)) | |||
| 49 | + | _, err := i.db.Apply(ctx, &b) | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // DropMRRecordForTest is DropIssueRecordForTest for a merge request. | |||
| 54 | + | func (m *MergeRequests) DropMRRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error { | |||
| 55 | + | var b kv.Batch | |||
| 56 | + | b.Delete(mrKey(repo, n)) | |||
| 57 | + | _, err := m.db.Apply(ctx, &b) | |||
| 58 | + | return err | |||
| 59 | + | } | |||
M internal/adapters/kvstore/issues_test.go +28 -0
| @@ -425,3 +425,31 @@ func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) { | |||
| 425 | 425 | require.NoError(t, err) | |
| 426 | 426 | assert.Empty(t, open) | |
| 427 | 427 | } | |
| 428 | + | ||
| 429 | + | func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) { | |
| 430 | + | ctx := context.Background() | |
| 431 | + | s, mrs, n := repoWithMRs(t) | |
| 432 | + | is := s.Issues() | |
| 433 | + | keepI, err := is.Create(ctx, newIssue(t, n, "keep")) | |
| 434 | + | require.NoError(t, err) | |
| 435 | + | goneI, err := is.Create(ctx, newIssue(t, n, "gone")) | |
| 436 | + | require.NoError(t, err) | |
| 437 | + | keepM, err := mrs.Create(ctx, newMR(t, n, "keep")) | |
| 438 | + | require.NoError(t, err) | |
| 439 | + | goneM, err := mrs.Create(ctx, newMR(t, n, "gone")) | |
| 440 | + | require.NoError(t, err) | |
| 441 | + | ||
| 442 | + | require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number)) | |
| 443 | + | require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number)) | |
| 444 | + | require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there") | |
| 445 | + | require.Positive(t, countKeys(t, s, "mropen/proj/")) | |
| 446 | + | ||
| 447 | + | issues, err := is.ListOpenAll(ctx) | |
| 448 | + | require.NoError(t, err) | |
| 449 | + | require.Len(t, issues, 1) | |
| 450 | + | assert.Equal(t, keepI.Number, issues[0].Number) | |
| 451 | + | open, err := mrs.ListOpenAll(ctx) | |
| 452 | + | require.NoError(t, err) | |
| 453 | + | require.Len(t, open, 1) | |
| 454 | + | assert.Equal(t, keepM.Number, open[0].Number) | |
| 455 | + | } | |
| @@ -425,3 +425,31 @@ func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) { | |||||
| 425 | require.NoError(t, err) | 425 | require.NoError(t, err) | ||
| 426 | assert.Empty(t, open) | 426 | assert.Empty(t, open) | ||
| 427 | } | 427 | } | ||
| 428 | + | ||||
| 429 | + | func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) { | |||
| 430 | + | ctx := context.Background() | |||
| 431 | + | s, mrs, n := repoWithMRs(t) | |||
| 432 | + | is := s.Issues() | |||
| 433 | + | keepI, err := is.Create(ctx, newIssue(t, n, "keep")) | |||
| 434 | + | require.NoError(t, err) | |||
| 435 | + | goneI, err := is.Create(ctx, newIssue(t, n, "gone")) | |||
| 436 | + | require.NoError(t, err) | |||
| 437 | + | keepM, err := mrs.Create(ctx, newMR(t, n, "keep")) | |||
| 438 | + | require.NoError(t, err) | |||
| 439 | + | goneM, err := mrs.Create(ctx, newMR(t, n, "gone")) | |||
| 440 | + | require.NoError(t, err) | |||
| 441 | + | ||||
| 442 | + | require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number)) | |||
| 443 | + | require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number)) | |||
| 444 | + | require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there") | |||
| 445 | + | require.Positive(t, countKeys(t, s, "mropen/proj/")) | |||
| 446 | + | ||||
| 447 | + | issues, err := is.ListOpenAll(ctx) | |||
| 448 | + | require.NoError(t, err) | |||
| 449 | + | require.Len(t, issues, 1) | |||
| 450 | + | assert.Equal(t, keepI.Number, issues[0].Number) | |||
| 451 | + | open, err := mrs.ListOpenAll(ctx) | |||
| 452 | + | require.NoError(t, err) | |||
| 453 | + | require.Len(t, open, 1) | |||
| 454 | + | assert.Equal(t, keepM.Number, open[0].Number) | |||
| 455 | + | } | |||
M internal/adapters/web/inbox_test.go +114 -0
| @@ -3,10 +3,12 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bufio" | |
| 6 | 7 | "context" | |
| 7 | 8 | "io" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "net/http/httptest" | |
| 11 | + | "strings" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| 12 | 14 | ||
| @@ -193,3 +195,115 @@ func TestInboxLiveFragments(t *testing.T) { | |||
| 193 | 195 | func withOwner(ctx context.Context) context.Context { | |
| 194 | 196 | return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner()) | |
| 195 | 197 | } | |
| 198 | + | ||
| 199 | + | // A bearer token is not a web session: the inbox answers it as it answers | |
| 200 | + | // anyone else, with the unknown-page and unknown-topic responses. | |
| 201 | + | func TestInboxRefusesABearerToken(t *testing.T) { | |
| 202 | + | t.Parallel() | |
| 203 | + | f := &watchProbe{release: make(chan struct{})} | |
| 204 | + | svc := mocks.NewInboxService(t) // never asked | |
| 205 | + | srv := newTestServer(t, Config{ | |
| 206 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |
| 207 | + | Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 208 | + | }) | |
| 209 | + | bearer := map[string]string{"Authorization": "Bearer tok"} | |
| 210 | + | ||
| 211 | + | // The page. | |
| 212 | + | missing := inboxGet(srv, "/-/nothing-here", false) | |
| 213 | + | req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil) | |
| 214 | + | req.Header.Set("Authorization", "Bearer tok") | |
| 215 | + | rec := httptest.NewRecorder() | |
| 216 | + | srv.ServeHTTP(rec, req) | |
| 217 | + | assert.Equal(t, missing.Code, rec.Code) | |
| 218 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 219 | + | assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response") | |
| 220 | + | ||
| 221 | + | // The stream. | |
| 222 | + | ts := httptest.NewServer(srv) | |
| 223 | + | t.Cleanup(ts.Close) | |
| 224 | + | unknown, _ := open(t, ts, "topic=nope&view=inbox", nil) | |
| 225 | + | want, _ := io.ReadAll(unknown.Body) | |
| 226 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", bearer) | |
| 227 | + | body, _ := io.ReadAll(resp.Body) | |
| 228 | + | assert.Equal(t, unknown.StatusCode, resp.StatusCode) | |
| 229 | + | assert.Equal(t, string(want), string(body), "the unknown-topic response") | |
| 230 | + | assert.Zero(t, f.opened.Load(), "no watch is opened") | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // The owner's inbox stream ends once the session is invalid: the next event is | |
| 234 | + | // not sent. | |
| 235 | + | func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) { | |
| 236 | + | t.Parallel() | |
| 237 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} | |
| 238 | + | sess, ended := newRevocable(t, "tok") | |
| 239 | + | svc := mocks.NewInboxService(t) | |
| 240 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe() | |
| 241 | + | srv := newTestServer(t, Config{ | |
| 242 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |
| 243 | + | Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{}, | |
| 244 | + | }) | |
| 245 | + | ts := httptest.NewServer(srv) | |
| 246 | + | t.Cleanup(ts.Close) | |
| 247 | + | resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"}) | |
| 248 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 249 | + | defer time.AfterFunc(5*time.Second, cancel).Stop() | |
| 250 | + | rd := bufio.NewReader(resp.Body) | |
| 251 | + | line := func() string { | |
| 252 | + | s, err := rd.ReadString('\n') | |
| 253 | + | if err != nil { | |
| 254 | + | return "EOF" | |
| 255 | + | } | |
| 256 | + | return strings.TrimRight(s, "\n") | |
| 257 | + | } | |
| 258 | + | require.Equal(t, ": connected", line()) | |
| 259 | + | line() | |
| 260 | + | ev := func(id string) ports.WatchEvent { | |
| 261 | + | return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}} | |
| 262 | + | } | |
| 263 | + | f.push <- ev("b-1") | |
| 264 | + | var seen []string | |
| 265 | + | for l := line(); !strings.HasPrefix(l, "data:"); l = line() { | |
| 266 | + | seen = append(seen, l) | |
| 267 | + | require.NotEqual(t, "EOF", l) | |
| 268 | + | } | |
| 269 | + | assert.Contains(t, seen, "id: b-1") | |
| 270 | + | ||
| 271 | + | ended.Store(true) | |
| 272 | + | select { | |
| 273 | + | case f.push <- ev("b-2"): | |
| 274 | + | case <-time.After(5 * time.Second): | |
| 275 | + | t.Fatal("the event was not delivered to the web") | |
| 276 | + | } | |
| 277 | + | rest, _ := io.ReadAll(rd) | |
| 278 | + | assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended") | |
| 279 | + | require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed") | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // A repository event refreshes the inbox: issues turned off or a deletion | |
| 283 | + | // remove its items from the fragments. | |
| 284 | + | func TestInboxRefreshesOnRepoEvents(t *testing.T) { | |
| 285 | + | t.Parallel() | |
| 286 | + | for name, ev := range map[string]domain.Event{ | |
| 287 | + | "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")}, | |
| 288 | + | "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")}, | |
| 289 | + | } { | |
| 290 | + | t.Run(name, func(t *testing.T) { | |
| 291 | + | t.Parallel() | |
| 292 | + | after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}} | |
| 293 | + | svc := mocks.NewInboxService(t) | |
| 294 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil) | |
| 295 | + | srv := inboxServer(t, svc) | |
| 296 | + | ls := &liveStream{s: srv, view: "inbox"} | |
| 297 | + | frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev}) | |
| 298 | + | require.NoError(t, err) | |
| 299 | + | require.Len(t, frags, 4) | |
| 300 | + | all := "" | |
| 301 | + | for _, f := range frags { | |
| 302 | + | all += f.html | |
| 303 | + | } | |
| 304 | + | assert.Contains(t, all, `id="inbox-issues"`) | |
| 305 | + | assert.NotContains(t, all, "crash on start", "the issue is gone") | |
| 306 | + | assert.NotContains(t, all, "/beta", "the repository is gone from the filter") | |
| 307 | + | }) | |
| 308 | + | } | |
| 309 | + | } | |
| @@ -3,10 +3,12 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "bufio" | |||
| 6 | "context" | 7 | "context" | ||
| 7 | "io" | 8 | "io" | ||
| 8 | "net/http" | 9 | "net/http" | ||
| 9 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 11 | + | "strings" | |||
| 10 | "testing" | 12 | "testing" | ||
| 11 | "time" | 13 | "time" | ||
| 12 | 14 | ||||
| @@ -193,3 +195,115 @@ func TestInboxLiveFragments(t *testing.T) { | |||||
| 193 | func withOwner(ctx context.Context) context.Context { | 195 | func withOwner(ctx context.Context) context.Context { | ||
| 194 | return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner()) | 196 | return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner()) | ||
| 195 | } | 197 | } | ||
| 198 | + | ||||
| 199 | + | // A bearer token is not a web session: the inbox answers it as it answers | |||
| 200 | + | // anyone else, with the unknown-page and unknown-topic responses. | |||
| 201 | + | func TestInboxRefusesABearerToken(t *testing.T) { | |||
| 202 | + | t.Parallel() | |||
| 203 | + | f := &watchProbe{release: make(chan struct{})} | |||
| 204 | + | svc := mocks.NewInboxService(t) // never asked | |||
| 205 | + | srv := newTestServer(t, Config{ | |||
| 206 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |||
| 207 | + | Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |||
| 208 | + | }) | |||
| 209 | + | bearer := map[string]string{"Authorization": "Bearer tok"} | |||
| 210 | + | ||||
| 211 | + | // The page. | |||
| 212 | + | missing := inboxGet(srv, "/-/nothing-here", false) | |||
| 213 | + | req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil) | |||
| 214 | + | req.Header.Set("Authorization", "Bearer tok") | |||
| 215 | + | rec := httptest.NewRecorder() | |||
| 216 | + | srv.ServeHTTP(rec, req) | |||
| 217 | + | assert.Equal(t, missing.Code, rec.Code) | |||
| 218 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 219 | + | assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response") | |||
| 220 | + | ||||
| 221 | + | // The stream. | |||
| 222 | + | ts := httptest.NewServer(srv) | |||
| 223 | + | t.Cleanup(ts.Close) | |||
| 224 | + | unknown, _ := open(t, ts, "topic=nope&view=inbox", nil) | |||
| 225 | + | want, _ := io.ReadAll(unknown.Body) | |||
| 226 | + | resp, _ := open(t, ts, "topic=owner&view=inbox", bearer) | |||
| 227 | + | body, _ := io.ReadAll(resp.Body) | |||
| 228 | + | assert.Equal(t, unknown.StatusCode, resp.StatusCode) | |||
| 229 | + | assert.Equal(t, string(want), string(body), "the unknown-topic response") | |||
| 230 | + | assert.Zero(t, f.opened.Load(), "no watch is opened") | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // The owner's inbox stream ends once the session is invalid: the next event is | |||
| 234 | + | // not sent. | |||
| 235 | + | func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) { | |||
| 236 | + | t.Parallel() | |||
| 237 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} | |||
| 238 | + | sess, ended := newRevocable(t, "tok") | |||
| 239 | + | svc := mocks.NewInboxService(t) | |||
| 240 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe() | |||
| 241 | + | srv := newTestServer(t, Config{ | |||
| 242 | + | Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc, | |||
| 243 | + | Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{}, | |||
| 244 | + | }) | |||
| 245 | + | ts := httptest.NewServer(srv) | |||
| 246 | + | t.Cleanup(ts.Close) | |||
| 247 | + | resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"}) | |||
| 248 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 249 | + | defer time.AfterFunc(5*time.Second, cancel).Stop() | |||
| 250 | + | rd := bufio.NewReader(resp.Body) | |||
| 251 | + | line := func() string { | |||
| 252 | + | s, err := rd.ReadString('\n') | |||
| 253 | + | if err != nil { | |||
| 254 | + | return "EOF" | |||
| 255 | + | } | |||
| 256 | + | return strings.TrimRight(s, "\n") | |||
| 257 | + | } | |||
| 258 | + | require.Equal(t, ": connected", line()) | |||
| 259 | + | line() | |||
| 260 | + | ev := func(id string) ports.WatchEvent { | |||
| 261 | + | return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}} | |||
| 262 | + | } | |||
| 263 | + | f.push <- ev("b-1") | |||
| 264 | + | var seen []string | |||
| 265 | + | for l := line(); !strings.HasPrefix(l, "data:"); l = line() { | |||
| 266 | + | seen = append(seen, l) | |||
| 267 | + | require.NotEqual(t, "EOF", l) | |||
| 268 | + | } | |||
| 269 | + | assert.Contains(t, seen, "id: b-1") | |||
| 270 | + | ||||
| 271 | + | ended.Store(true) | |||
| 272 | + | select { | |||
| 273 | + | case f.push <- ev("b-2"): | |||
| 274 | + | case <-time.After(5 * time.Second): | |||
| 275 | + | t.Fatal("the event was not delivered to the web") | |||
| 276 | + | } | |||
| 277 | + | rest, _ := io.ReadAll(rd) | |||
| 278 | + | assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended") | |||
| 279 | + | require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed") | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // A repository event refreshes the inbox: issues turned off or a deletion | |||
| 283 | + | // remove its items from the fragments. | |||
| 284 | + | func TestInboxRefreshesOnRepoEvents(t *testing.T) { | |||
| 285 | + | t.Parallel() | |||
| 286 | + | for name, ev := range map[string]domain.Event{ | |||
| 287 | + | "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")}, | |||
| 288 | + | "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")}, | |||
| 289 | + | } { | |||
| 290 | + | t.Run(name, func(t *testing.T) { | |||
| 291 | + | t.Parallel() | |||
| 292 | + | after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}} | |||
| 293 | + | svc := mocks.NewInboxService(t) | |||
| 294 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil) | |||
| 295 | + | srv := inboxServer(t, svc) | |||
| 296 | + | ls := &liveStream{s: srv, view: "inbox"} | |||
| 297 | + | frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev}) | |||
| 298 | + | require.NoError(t, err) | |||
| 299 | + | require.Len(t, frags, 4) | |||
| 300 | + | all := "" | |||
| 301 | + | for _, f := range frags { | |||
| 302 | + | all += f.html | |||
| 303 | + | } | |||
| 304 | + | assert.Contains(t, all, `id="inbox-issues"`) | |||
| 305 | + | assert.NotContains(t, all, "crash on start", "the issue is gone") | |||
| 306 | + | assert.NotContains(t, all, "/beta", "the repository is gone from the filter") | |||
| 307 | + | }) | |||
| 308 | + | } | |||
| 309 | + | } | |||
M internal/usecase/events_owner_test.go +119 -0
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | "bnema.dev/ermite/internal/domain" | |
| 17 | 17 | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | func TestWatchOwnerTopicIsUnknownToEveryoneElse(t *testing.T) { | |
| @@ -79,3 +80,121 @@ func withIssues(m domain.IssuesMode) domain.RepoSettings { | |||
| 79 | 80 | st.Issues = m | |
| 80 | 81 | return st | |
| 81 | 82 | } | |
| 83 | + | ||
| 84 | + | func allScopesToken(mutate func(*domain.Token)) domain.Principal { | |
| 85 | + | tok := domain.Token{ID: "tok", Scopes: domain.AllScopes()} | |
| 86 | + | if mutate != nil { | |
| 87 | + | mutate(&tok) | |
| 88 | + | } | |
| 89 | + | return domain.TokenPrincipal(tok) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestWatchOwnerTopicAccessMatrix(t *testing.T) { | |
| 93 | + | t.Parallel() | |
| 94 | + | missingOne := domain.AllScopes()[1:] | |
| 95 | + | cases := []struct { | |
| 96 | + | name string | |
| 97 | + | p domain.Principal | |
| 98 | + | allowed bool | |
| 99 | + | }{ | |
| 100 | + | {"root", domain.Root(), true}, | |
| 101 | + | {"owner session", domain.Owner(), true}, | |
| 102 | + | {"unrestricted all-scope token", allScopesToken(nil), true}, | |
| 103 | + | {"token missing one scope", allScopesToken(func(tk *domain.Token) { tk.Scopes = missingOne }), false}, | |
| 104 | + | {"repo-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{name(t, "aaa")} }), false}, | |
| 105 | + | {"branch-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }), false}, | |
| 106 | + | {"anonymous", domain.Anonymous(), false}, | |
| 107 | + | } | |
| 108 | + | for _, tc := range cases { | |
| 109 | + | t.Run(tc.name, func(t *testing.T) { | |
| 110 | + | t.Parallel() | |
| 111 | + | synctest.Test(t, func(t *testing.T) { | |
| 112 | + | r := newEventRig(t, 16) | |
| 113 | + | a, b := name(t, "aaa"), name(t, "bbb") | |
| 114 | + | for _, n := range []domain.RepoName{a, b} { | |
| 115 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil).Maybe() | |
| 116 | + | } | |
| 117 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 118 | + | defer cancel() | |
| 119 | + | noop := func(ports.WatchEvent) error { return nil } | |
| 120 | + | if !tc.allowed { | |
| 121 | + | err := r.events.Watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}, noop) | |
| 122 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 123 | + | assert.EqualError(t, err, domain.ErrInvalid.Error()+`: unknown topic "owner" (want "home" or "repo:<name>")`) | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | w := r.watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}) | |
| 127 | + | synctest.Wait() | |
| 128 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 129 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 130 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 131 | + | assert.Equal(t, a, w.next(t).Event.(domain.IssueOpened).Issue.Repo) | |
| 132 | + | assert.Equal(t, b, w.next(t).Event.(domain.MergeRequestOpened).MergeRequest.Repo) | |
| 133 | + | }) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestWatchOwnerTopicFollowsRepoUpdatedAndDeleted(t *testing.T) { | |
| 139 | + | t.Parallel() | |
| 140 | + | synctest.Test(t, func(t *testing.T) { | |
| 141 | + | r := newEventRig(t, 16) | |
| 142 | + | a := r.repo("aaa", domain.VisibilityPrivate) | |
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 144 | + | defer cancel() | |
| 145 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 146 | + | synctest.Wait() | |
| 147 | + | r.feed <- domain.RepoUpdated{Repo: a, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now} | |
| 148 | + | r.feed <- domain.RepoDeleted{Repo: a, Visibility: domain.VisibilityPrivate, At: now} | |
| 149 | + | _, ok := owner.next(t).Event.(domain.RepoUpdated) | |
| 150 | + | require.True(t, ok) | |
| 151 | + | _, ok = owner.next(t).Event.(domain.RepoDeleted) | |
| 152 | + | require.True(t, ok) | |
| 153 | + | }) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestWatchOwnerTopicEndsWhenTheTokenIsRevoked(t *testing.T) { | |
| 157 | + | t.Parallel() | |
| 158 | + | synctest.Test(t, func(t *testing.T) { | |
| 159 | + | r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1}) | |
| 160 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 161 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 162 | + | defer cancel() | |
| 163 | + | w := r.watch(ctx, allScopesToken(nil), ports.WatchParams{Topics: []string{"owner"}}) | |
| 164 | + | synctest.Wait() | |
| 165 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 166 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 167 | + | w.next(t) | |
| 168 | + | ||
| 169 | + | r.revoked.Store("tok", true) | |
| 170 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 171 | + | synctest.Wait() | |
| 172 | + | select { | |
| 173 | + | case err := <-w.done: | |
| 174 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 175 | + | default: | |
| 176 | + | t.Fatal("the owner watch of a revoked token did not end") | |
| 177 | + | } | |
| 178 | + | assert.Empty(t, w.got, "nothing was sent after the revocation") | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestWatchOwnerTopicEndsWhenTheTokenExpires(t *testing.T) { | |
| 183 | + | t.Parallel() | |
| 184 | + | synctest.Test(t, func(t *testing.T) { | |
| 185 | + | r := newEventRig(t, 0) | |
| 186 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 187 | + | // The rig's clock stands at now: this token expired at now. | |
| 188 | + | p := allScopesToken(func(tk *domain.Token) { tk.ExpiresAt = now }) | |
| 189 | + | w := r.watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}}) | |
| 190 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |
| 191 | + | synctest.Wait() | |
| 192 | + | select { | |
| 193 | + | case err := <-w.done: | |
| 194 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 195 | + | default: | |
| 196 | + | t.Fatal("the owner watch of an expired token did not end") | |
| 197 | + | } | |
| 198 | + | assert.Empty(t, w.got) | |
| 199 | + | }) | |
| 200 | + | } | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | 15 | ||||
| 16 | "bnema.dev/ermite/internal/domain" | 16 | "bnema.dev/ermite/internal/domain" | ||
| 17 | "bnema.dev/ermite/internal/ports" | 17 | "bnema.dev/ermite/internal/ports" | ||
| 18 | + | "bnema.dev/ermite/internal/usecase" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | func TestWatchOwnerTopicIsUnknownToEveryoneElse(t *testing.T) { | 21 | func TestWatchOwnerTopicIsUnknownToEveryoneElse(t *testing.T) { | ||
| @@ -79,3 +80,121 @@ func withIssues(m domain.IssuesMode) domain.RepoSettings { | |||||
| 79 | st.Issues = m | 80 | st.Issues = m | ||
| 80 | return st | 81 | return st | ||
| 81 | } | 82 | } | ||
| 83 | + | ||||
| 84 | + | func allScopesToken(mutate func(*domain.Token)) domain.Principal { | |||
| 85 | + | tok := domain.Token{ID: "tok", Scopes: domain.AllScopes()} | |||
| 86 | + | if mutate != nil { | |||
| 87 | + | mutate(&tok) | |||
| 88 | + | } | |||
| 89 | + | return domain.TokenPrincipal(tok) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestWatchOwnerTopicAccessMatrix(t *testing.T) { | |||
| 93 | + | t.Parallel() | |||
| 94 | + | missingOne := domain.AllScopes()[1:] | |||
| 95 | + | cases := []struct { | |||
| 96 | + | name string | |||
| 97 | + | p domain.Principal | |||
| 98 | + | allowed bool | |||
| 99 | + | }{ | |||
| 100 | + | {"root", domain.Root(), true}, | |||
| 101 | + | {"owner session", domain.Owner(), true}, | |||
| 102 | + | {"unrestricted all-scope token", allScopesToken(nil), true}, | |||
| 103 | + | {"token missing one scope", allScopesToken(func(tk *domain.Token) { tk.Scopes = missingOne }), false}, | |||
| 104 | + | {"repo-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.Repos = []domain.RepoName{name(t, "aaa")} }), false}, | |||
| 105 | + | {"branch-limited all-scope token", allScopesToken(func(tk *domain.Token) { tk.BranchPrefixes = []string{"agent/"} }), false}, | |||
| 106 | + | {"anonymous", domain.Anonymous(), false}, | |||
| 107 | + | } | |||
| 108 | + | for _, tc := range cases { | |||
| 109 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 110 | + | t.Parallel() | |||
| 111 | + | synctest.Test(t, func(t *testing.T) { | |||
| 112 | + | r := newEventRig(t, 16) | |||
| 113 | + | a, b := name(t, "aaa"), name(t, "bbb") | |||
| 114 | + | for _, n := range []domain.RepoName{a, b} { | |||
| 115 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: withIssues(domain.IssuesOwner)}, nil).Maybe() | |||
| 116 | + | } | |||
| 117 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 118 | + | defer cancel() | |||
| 119 | + | noop := func(ports.WatchEvent) error { return nil } | |||
| 120 | + | if !tc.allowed { | |||
| 121 | + | err := r.events.Watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}, noop) | |||
| 122 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 123 | + | assert.EqualError(t, err, domain.ErrInvalid.Error()+`: unknown topic "owner" (want "home" or "repo:<name>")`) | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | w := r.watch(ctx, tc.p, ports.WatchParams{Topics: []string{"owner"}}) | |||
| 127 | + | synctest.Wait() | |||
| 128 | + | require.Equal(t, 1, r.events.Watchers()) | |||
| 129 | + | r.feed <- domain.IssueOpened{Issue: domain.Issue{Repo: a, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |||
| 130 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: b, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |||
| 131 | + | assert.Equal(t, a, w.next(t).Event.(domain.IssueOpened).Issue.Repo) | |||
| 132 | + | assert.Equal(t, b, w.next(t).Event.(domain.MergeRequestOpened).MergeRequest.Repo) | |||
| 133 | + | }) | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestWatchOwnerTopicFollowsRepoUpdatedAndDeleted(t *testing.T) { | |||
| 139 | + | t.Parallel() | |||
| 140 | + | synctest.Test(t, func(t *testing.T) { | |||
| 141 | + | r := newEventRig(t, 16) | |||
| 142 | + | a := r.repo("aaa", domain.VisibilityPrivate) | |||
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 144 | + | defer cancel() | |||
| 145 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |||
| 146 | + | synctest.Wait() | |||
| 147 | + | r.feed <- domain.RepoUpdated{Repo: a, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now} | |||
| 148 | + | r.feed <- domain.RepoDeleted{Repo: a, Visibility: domain.VisibilityPrivate, At: now} | |||
| 149 | + | _, ok := owner.next(t).Event.(domain.RepoUpdated) | |||
| 150 | + | require.True(t, ok) | |||
| 151 | + | _, ok = owner.next(t).Event.(domain.RepoDeleted) | |||
| 152 | + | require.True(t, ok) | |||
| 153 | + | }) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestWatchOwnerTopicEndsWhenTheTokenIsRevoked(t *testing.T) { | |||
| 157 | + | t.Parallel() | |||
| 158 | + | synctest.Test(t, func(t *testing.T) { | |||
| 159 | + | r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1}) | |||
| 160 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |||
| 161 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 162 | + | defer cancel() | |||
| 163 | + | w := r.watch(ctx, allScopesToken(nil), ports.WatchParams{Topics: []string{"owner"}}) | |||
| 164 | + | synctest.Wait() | |||
| 165 | + | require.Equal(t, 1, r.events.Watchers()) | |||
| 166 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |||
| 167 | + | w.next(t) | |||
| 168 | + | ||||
| 169 | + | r.revoked.Store("tok", true) | |||
| 170 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 2}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |||
| 171 | + | synctest.Wait() | |||
| 172 | + | select { | |||
| 173 | + | case err := <-w.done: | |||
| 174 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 175 | + | default: | |||
| 176 | + | t.Fatal("the owner watch of a revoked token did not end") | |||
| 177 | + | } | |||
| 178 | + | assert.Empty(t, w.got, "nothing was sent after the revocation") | |||
| 179 | + | }) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestWatchOwnerTopicEndsWhenTheTokenExpires(t *testing.T) { | |||
| 183 | + | t.Parallel() | |||
| 184 | + | synctest.Test(t, func(t *testing.T) { | |||
| 185 | + | r := newEventRig(t, 0) | |||
| 186 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |||
| 187 | + | // The rig's clock stands at now: this token expired at now. | |||
| 188 | + | p := allScopesToken(func(tk *domain.Token) { tk.ExpiresAt = now }) | |||
| 189 | + | w := r.watch(context.Background(), p, ports.WatchParams{Topics: []string{"owner"}}) | |||
| 190 | + | r.feed <- domain.MergeRequestOpened{MergeRequest: domain.MergeRequest{Repo: priv, Number: 1}, At: now, Visibility: domain.VisibilityPrivate, CreatedAt: now} | |||
| 191 | + | synctest.Wait() | |||
| 192 | + | select { | |||
| 193 | + | case err := <-w.done: | |||
| 194 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 195 | + | default: | |||
| 196 | + | t.Fatal("the owner watch of an expired token did not end") | |||
| 197 | + | } | |||
| 198 | + | assert.Empty(t, w.got) | |||
| 199 | + | }) | |||
| 200 | + | } | |||