detached 4738f20

refactor: split use cases into internal/usecase and the CLI and composition root into internal/app

- internal/app (use cases) becomes internal/usecase
- the CLI, server wiring and their tests move from cmd/ermite to internal/app,
  which exposes Run and ExitCode; cmd/ermite/main.go only calls them
- the version stamp is set with -X bnema.dev/ermite/internal/app.stamped
- internal/boundary tests the allowed imports between layers
- README describes the layers and import rules
commit
4738f20080bcfa10342dd67c9aefd2781616b1e2
author
brice · · 2026-10-12 05:02 UTC
parent
f2f0e1b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
111 files · +1809 -1476
M .air.toml +1 -1
@@ -10,7 +10,7 @@ tmp_dir = "tmp"
10 entrypoint = ["./tmp/ermite", "serve"]10 entrypoint = ["./tmp/ermite", "serve"]
11 # The web assets are embedded (go:embed static), so they need a rebuild too.11 # The web assets are embedded (go:embed static), so they need a rebuild too.
12 include_ext = ["go", "templ", "css", "js", "svg", "json", "woff2"]12 include_ext = ["go", "templ", "css", "js", "svg", "json", "woff2"]
13- exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "cmd/ermite/testdata"]13+ exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "internal/app/testdata"]
14 # *_templ.go is the output of templ generate: a .templ change triggers the14 # *_templ.go is the output of templ generate: a .templ change triggers the
15 # rebuild, the generated Go file does not trigger a second one.15 # rebuild, the generated Go file does not trigger a second one.
16 exclude_regex = ["_test\\.go", "_templ\\.go"]16 exclude_regex = ["_test\\.go", "_templ\\.go"]
M .hexcheck.yaml +14 -5
@@ -4,10 +4,10 @@ components:
4 role: core4 role: core
5 paths:5 paths:
6 - internal/domain/**6 - internal/domain/**
7- app:7+ usecase:
8 role: usecase8 role: usecase
9 paths:9 paths:
10- - internal/app/**10+ - internal/usecase/**
11 ports:11 ports:
12 role: ports12 role: ports
13 paths:13 paths:
@@ -22,9 +22,17 @@ components:
22 paths:22 paths:
23 - internal/clientlimit/**23 - internal/clientlimit/**
24 entrypoints:24 entrypoints:
25+ # cmd/ermite only calls app.Run.
25 role: entrypoint26 role: entrypoint
26 paths:27 paths:
27 - cmd/**28 - cmd/**
29+ app:
30+ # Composition root and CLI: builds the adapters and wires them to the use
31+ # cases. hexcheck has no dedicated bootstrap role; entrypoint is the
32+ # closest.
33+ role: entrypoint
34+ paths:
35+ - internal/app/**
28 config:36 config:
29 role: entrypoint37 role: entrypoint
30 paths:38 paths:
@@ -40,9 +48,10 @@ components:
40 role: ignore48 role: ignore
41 paths:49 paths:
42 - internal/readertest/**50 - internal/readertest/**
43-# Cross-layer contracts live in ports: adapters and app import ports and domain,51+# Cross-layer contracts live in ports: adapters and use cases import ports and
44-# never each other. hexcheck has no rule for an adapter importing app (only the52+# domain, never each other. hexcheck has no rule for an adapter importing a use
45-# reverse, no-infra-imports-in-usecase), so that direction is kept by review.53+# case or internal/app; internal/boundary/boundary_test.go enforces the full
54+# import matrix.
46 rules:55 rules:
47 no-adapter-imports-in-core: error56 no-adapter-imports-in-core: error
48 no-infra-imports-in-usecase: error57 no-infra-imports-in-usecase: error
M Dockerfile +1 -1
@@ -24,7 +24,7 @@ COPY . .
24 ARG VERSION=24 ARG VERSION=
25 RUN --mount=type=cache,target=/go/pkg/mod \25 RUN --mount=type=cache,target=/go/pkg/mod \
26 --mount=type=cache,target=/root/.cache/go-build \26 --mount=type=cache,target=/root/.cache/go-build \
27- go build -trimpath -buildvcs=false -ldflags "-s -w -X main.stamped=${VERSION}" \27+ go build -trimpath -buildvcs=false -ldflags "-s -w -X bnema.dev/ermite/internal/app.stamped=${VERSION}" \
28 -o /out/ermite ./cmd/ermite28 -o /out/ermite ./cmd/ermite
29 29
30 FROM alpine:3.2430 FROM alpine:3.24
M README.md +11 -0
@@ -98,6 +98,17 @@ go build ./cmd/ermite
98 go test ./...98 go test ./...
99 ```99 ```
100 100
101+## Architecture
102+
103+`cmd/ermite` (entry point, calls `app.Run`) → `internal/app` (CLI and composition root: builds the adapters and wires them to the use cases) → `internal/usecase` (use cases), `internal/ports` (interfaces and parameter types that cross layers), `internal/domain` (pure values and rules) and `internal/adapters` (Git, KV store, HTTP, API, web, client implementations of the ports).
104+
105+- `internal/usecase` imports only `internal/domain` and `internal/ports`.
106+- Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter.
107+- `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module.
108+- `internal/config` (environment parsing) is used by `internal/app` only.
109+
110+`internal/boundary/boundary_test.go` enforces these import rules on production files; `hexcheck` (`.hexcheck.yaml`) checks the layering of types and behaviour. The release version is stamped with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`.
111+
101 ## Develop112 ## Develop
102 113
103 Tools are pinned in `go.mod`:114 Tools are pinned in `go.mod`:
D cmd/ermite/events_test.go +0 -404
@@ -1,404 +0,0 @@
1-// SPDX-License-Identifier: AGPL-3.0-or-later
2-
3-package main
4-
5-import (
6- "context"
7- "errors"
8- "fmt"
9- "net/http"
10- "os/exec"
11- "syscall"
12- "testing"
13- "time"
14-
15- "connectrpc.com/connect"
16- "github.com/stretchr/testify/assert"
17- "github.com/stretchr/testify/require"
18- "google.golang.org/protobuf/types/known/fieldmaskpb"
19-
20- pb "bnema.dev/ermite-api/gen/ermite"
21- "bnema.dev/ermite/internal/adapters/rpc"
22- "bnema.dev/ermite/internal/app"
23- "bnema.dev/ermite/internal/domain"
24- "bnema.dev/ermite/internal/ports"
25-)
26-
27-// watchWait is how long a test waits for an event that must arrive. Only
28-// TestAC5PushEventWithinASecond holds the server to a second.
29-const watchWait = 10 * time.Second
30-
31-// AC5: a push shows up in Watch within a second, with the ref updates.
32-func TestAC5PushEventWithinASecond(t *testing.T) {
33- h := newHarness(t, harnessOpts{})
34- h.seed("pub", domain.VisibilityPublic)
35- w := h.watch(h.api(""), "", "home")
36-
37- wt := h.workTree("main", "a.txt")
38- start := time.Now()
39- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
40- ev := w.next(t, time.Second)
41- assert.Less(t, time.Since(start), 2*time.Second)
42-
43- pushed := ev.GetPushed()
44- require.NotNil(t, pushed, "got %v", ev)
45- assert.Equal(t, "pub", pushed.GetRepo())
46- require.Len(t, pushed.GetRefUpdates(), 1)
47- up := pushed.GetRefUpdates()[0]
48- assert.Equal(t, "refs/heads/main", up.GetRef())
49- assert.Empty(t, up.GetOldId(), "a created ref has no old id")
50- assert.Equal(t, h.bareGit("pub", "rev-parse", "main"), up.GetNewId())
51- assert.NotEmpty(t, ev.GetId())
52- assert.NotNil(t, ev.GetTime())
53-
54- // A second push to the same ref carries both ids; a deletion has no new id.
55- old := up.GetNewId()
56- h.writeFile(wt, "b.txt", []byte("b\n"))
57- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
58- up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
59- assert.Equal(t, old, up2.GetOldId())
60- assert.NotEqual(t, old, up2.GetNewId())
61-
62- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp")
63- w.next(t, watchWait)
64- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp")
65- del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
66- assert.Equal(t, "refs/heads/tmp", del.GetRef())
67- assert.NotEmpty(t, del.GetOldId())
68- assert.Empty(t, del.GetNewId())
69-}
70-
71-// AC5: creating, updating and deleting repositories through the API publish
72-// RepoCreated, RepoUpdated and RepoDeleted; so does a create-on-push.
73-func TestAC5RepoLifecycleEvents(t *testing.T) {
74- h := newHarness(t, harnessOpts{})
75- root := h.api(adminToken)
76- w := h.watch(root, "")
77-
78- _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC}))
79- require.NoError(t, err)
80- created := w.next(t, watchWait).GetRepoCreated().GetRepo()
81- require.NotNil(t, created)
82- assert.Equal(t, "life", created.GetName())
83- assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility())
84- assert.Equal(t, "main", created.GetDefaultBranch())
85-
86- _, err = root.repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
87- Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
88- }))
89- require.NoError(t, err)
90- updated := w.next(t, watchWait).GetRepoUpdated().GetRepo()
91- require.NotNil(t, updated)
92- assert.Equal(t, "new", updated.GetDescription())
93-
94- _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"}))
95- require.NoError(t, err)
96- assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo())
97-
98- // Create on first push publishes RepoCreated once, then the push.
99- wt := h.workTree("main", "a.txt")
100- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main")
101- assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
102- assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo())
103- // Nothing else came in between: the next event is the sentinel.
104- h.sentinel(t, w, "s-life")
105-}
106-
107-// AC5: resume from an id replays what came after, then goes live.
108-func TestAC5Resume(t *testing.T) {
109- h := newHarness(t, harnessOpts{})
110- h.seed("pub", domain.VisibilityPublic)
111- api := h.api("")
112- w := h.watch(api, "")
113-
114- wt := h.workTree("main", "a.txt")
115- var ids []string
116- for i := range 4 {
117- if i > 0 {
118- h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
119- }
120- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
121- ids = append(ids, w.next(t, watchWait).GetId())
122- }
123- w.cancel()
124-
125- // The client lost its connection after ids[1]: it gets 2 and 3, then live.
126- r := h.watch(api, ids[1])
127- for _, want := range ids[2:] {
128- ev := r.next(t, watchWait)
129- assert.Equal(t, want, ev.GetId())
130- assert.NotNil(t, ev.GetPushed())
131- }
132- // Nothing was replayed twice: the next event is the live one, below.
133- h.writeFile(wt, "live.txt", []byte("x\n"))
134- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
135- live := r.next(t, watchWait)
136- assert.NotNil(t, live.GetPushed())
137- assert.NotContains(t, ids, live.GetId())
138-
139- // An id nobody issued is a gap, followed by the oldest buffered event: the
140- // creation of the repository, which came before the first push.
141- g := h.watch(api, "not-an-id")
142- assert.NotNil(t, g.next(t, watchWait).GetGap())
143- assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
144- assert.Equal(t, ids[0], g.next(t, watchWait).GetId())
145-}
146-
147-// AC5: a gap when the id fell out of a small ring.
148-func TestAC5GapAfterOverflow(t *testing.T) {
149- h := newHarness(t, harnessOpts{eventBuffer: 4})
150- h.seed("pub", domain.VisibilityPublic)
151- api := h.api(adminToken)
152- w := h.watch(api, "")
153- wt := h.workTree("main", "a.txt")
154-
155- var ids []string
156- for i := range 8 {
157- if i > 0 {
158- h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
159- }
160- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
161- ids = append(ids, w.next(t, watchWait).GetId())
162- }
163-
164- // ids[2] is out of the ring (the last four are ids[4:]).
165- r := h.watch(api, ids[2])
166- gap := r.next(t, watchWait)
167- assert.NotNil(t, gap.GetGap())
168- assert.Empty(t, gap.GetId())
169- for _, want := range ids[4:] {
170- assert.Equal(t, want, r.next(t, watchWait).GetId())
171- }
172-
173- // The last buffered id still resumes without a gap.
174- ok := h.watch(api, ids[4])
175- assert.Equal(t, ids[5], ok.next(t, watchWait).GetId())
176-}
177-
178-// AC5: private events never reach anonymous callers, nor tokens that may not
179-// read the repository; repo topics follow the same rule.
180-func TestAC5NoPrivateEventsToAnonymous(t *testing.T) {
181- h := newHarness(t, harnessOpts{})
182- h.seed("pub", domain.VisibilityPublic)
183- h.seed("priv", domain.VisibilityPrivate)
184- other := h.token(scopes(domain.ScopeRepoRead), "pub")
185- reader := h.token(scopes(domain.ScopeRepoRead), "priv")
186-
187- anon := h.watch(h.api(""), "")
188- scoped := h.watch(h.api(other), "")
189- rd := h.watch(h.api(reader), "")
190- root := h.watch(h.api(adminToken), "")
191-
192- // A private repository's topic is not found for those who cannot read it.
193- assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(""), "repo:priv")))
194- assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(other), "repo:priv")))
195- assert.Equal(t, codeName(watchErr(h.ctx, h.api(""), "repo:nothing")), codeName(watchErr(h.ctx, h.api(""), "repo:priv")))
196-
197- wt := h.workTree("main", "a.txt")
198- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
199- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
200- // Metadata events too.
201- _, err := h.api(adminToken).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
202- Repo: "priv", Description: "secret plans", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
203- }))
204- require.NoError(t, err)
205-
206- // The reader of priv sees its push and its update, and pub's push.
207- assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo())
208- assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public")
209- assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription())
210- // Root sees everything in order.
211- assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo())
212- assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo())
213- assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName())
214-
215- // Deleting reaches those who could read the repository before, not others.
216- _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"}))
217- require.NoError(t, err)
218- assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo())
219- assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo())
220-
221- for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
222- ev := w.next(t, watchWait)
223- assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name)
224- }
225- // Nothing about priv came before the sentinel, for either of them.
226- _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic})
227- require.NoError(t, err)
228- for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
229- assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name)
230- }
231-
232- // Resuming does not replay private events to anonymous either: after a
233- // gap, the creation and the push of pub, the sentinels the watchers above
234- // saw, and nothing about priv.
235- r := h.watch(h.api(""), "bogus")
236- assert.NotNil(t, r.next(t, watchWait).GetGap())
237- assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
238- assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo())
239- assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
240- h.sentinel(t, r, "s-resumed")
241-}
242-
243-// AC5: a repo topic only carries that repository; a revoked token cannot
244-// start a new watch.
245-func TestAC5RepoTopic(t *testing.T) {
246- h := newHarness(t, harnessOpts{})
247- h.seed("a", domain.VisibilityPublic)
248- h.seed("b", domain.VisibilityPublic)
249- w := h.watch(h.api(""), "", "repo:a")
250-
251- wt := h.workTree("main", "x.txt")
252- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main")
253- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
254- assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo())
255- // The push to b was not delivered: the next event is the next push to a.
256- h.writeFile(wt, "y.txt", []byte("y\n"))
257- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
258- assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId())
259-}
260-
261-// The stream ends cleanly when the client cancels, and Watch counts go back to zero.
262-func TestWatchEndsOnCancel(t *testing.T) {
263- h := newHarness(t, harnessOpts{})
264- w := h.watch(h.api(""), "")
265- require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond)
266- w.cancel()
267- select {
268- case <-w.done:
269- case <-time.After(5 * watchWait):
270- t.Fatal("the stream did not end")
271- }
272- require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond)
273-}
274-
275-// Shutdown ends the streams cleanly: the client sees the stream end without
276-// an error and the server exits 0 well before the shutdown timeout.
277-func TestServeShutdownEndsWatchStreams(t *testing.T) {
278- requireGit(t)
279- data := t.TempDir()
280- addr := freeAddr(t)
281- cmd := exec.Command(binary(t), "serve")
282- cmd.Env = append(baseEnv(),
283- "ERMITE_ADDR="+addr, "ERMITE_DATA_DIR="+data, "ERMITE_PUBLIC_URL=http://"+addr,
284- "ERMITE_ADMIN_TOKEN="+adminToken, "ERMITE_LOG_LEVEL=debug", "ERMITE_LOG_FORMAT=json",
285- )
286- var logs syncBuffer
287- cmd.Stdout, cmd.Stderr = &logs, &logs
288- require.NoError(t, cmd.Start())
289- exited := make(chan error, 1)
290- go func() { exited <- cmd.Wait() }()
291- t.Cleanup(func() { _ = cmd.Process.Kill() })
292-
293- base := "http://" + addr
294- require.Eventually(t, func() bool {
295- resp, err := http.Get(base + "/-/health")
296- if err != nil {
297- return false
298- }
299- _ = resp.Body.Close()
300- return resp.StatusCode == 200
301- }, 15*time.Second, 50*time.Millisecond, "%s", logs.String())
302-
303- c := newClients(base+"/-/api", adminToken)
304- ctx, cancel := context.WithCancel(context.Background())
305- defer cancel()
306- st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{}))
307- require.NoError(t, err)
308- ended := make(chan error, 1)
309- go func() {
310- for st.Receive() {
311- }
312- ended <- st.Err()
313- }()
314-
315- // The server version comes from the real binary.
316- v, err := c.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
317- require.NoError(t, err)
318- assert.NotEmpty(t, v.Msg.GetVersion())
319-
320- start := time.Now()
321- require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
322- select {
323- case err := <-ended:
324- assert.NoError(t, err, "the stream ends cleanly")
325- case <-time.After(15 * watchWait):
326- t.Fatalf("the stream did not end\n%s", logs.String())
327- }
328- select {
329- case err := <-exited:
330- require.NoError(t, err, "%s", logs.String())
331- case <-time.After(15 * watchWait):
332- t.Fatalf("the server did not exit\n%s", logs.String())
333- }
334- assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout")
335- assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String())
336- assert.False(t, errors.Is(err, context.Canceled))
337-}
338-
339-// A token that is revoked while its Watch is open ends the stream with
340-// unauthenticated, and the private events that follow never reach it.
341-func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
342- h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond})
343- h.seed("priv", domain.VisibilityPrivate)
344- created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{
345- Label: "watcher", Scopes: scopes(domain.ScopeRepoRead),
346- })
347- require.NoError(t, err)
348- w := h.watch(h.api(created.Secret), "")
349- wt := h.workTree("main", "a.txt")
350- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
351- assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv")
352-
353- require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
354- h.writeFile(wt, "b.txt", []byte("b\n"))
355- h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
356-
357- select {
358- case <-w.done:
359- case <-time.After(5 * watchWait):
360- t.Fatal("the stream of a revoked token did not end")
361- }
362- assert.Equal(t, "unauthenticated", codeName(w.err))
363- for ev := range w.events {
364- t.Fatalf("an event reached the revoked token: %v", ev)
365- }
366-}
367-
368-// Watch refuses too many topics, and too many streams from one client.
369-func TestWatchLimits(t *testing.T) {
370- h := newHarness(t, harnessOpts{})
371- api := h.api(adminToken)
372-
373- topics := make([]string, app.MaxWatchTopics+1)
374- for i := range topics {
375- topics[i] = "home"
376- }
377- assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...)))
378-
379- for range app.MaxWatchersPerClient {
380- h.watch(api, "")
381- }
382- require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
383- assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api)))
384- // A stream of another client address is not affected (the harness trusts
385- // the loopback proxy only when configured: use the in-process caller).
386- inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL)
387- ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root()))
388- defer cancel()
389- st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{}))
390- require.NoError(t, err)
391- _ = st.Close()
392-}
393-
394-// sentinel proves that nothing reached the stream since the last event it
395-// returned: it creates a public repository, which every watcher of "home" may
396-// see, and requires its RepoCreated to be the next event. Events arrive in the
397-// order they were published.
398-func (h *harness) sentinel(t *testing.T, s *stream, name string) {
399- t.Helper()
400- _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
401- require.NoError(t, err)
402- ev := s.next(t, watchWait)
403- assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev)
404-}
M cmd/ermite/main.go +4 -23
@@ -6,32 +6,13 @@ package main
6 import (6 import (
7 "fmt"7 "fmt"
8 "os"8 "os"
9- "runtime/debug"9+
10+ "bnema.dev/ermite/internal/app"
10 )11 )
11 12
12 func main() {13 func main() {
13- if err := newRootCmd().Execute(); err != nil {14+ if err := app.Run(os.Args[1:]); err != nil {
14 fmt.Fprintln(os.Stderr, "ermite:", err)15 fmt.Fprintln(os.Stderr, "ermite:", err)
15- os.Exit(1)16+ os.Exit(app.ExitCode(err))
16 }17 }
17 }18 }
18-
19-// stamped is the release version set at link time by the image build
20-// (-ldflags "-X main.stamped=v0.1.0"). Empty for a plain go build, which then
21-// reports the module version recorded by the Go toolchain.
22-var stamped string
23-
24-// buildVersion is the version stamped into the binary: the link-time version
25-// when set, else the module version of the build.
26-func buildVersion() string {
27- if stamped != "" {
28- return stamped
29- }
30- info, ok := debug.ReadBuildInfo()
31- if !ok || info.Main.Version == "" {
32- return "(devel)"
33- }
34- return info.Main.Version
35-}
36-
37-func version() string { return "ermite " + buildVersion() }
M internal/adapters/rpc/api.go +1 -1
@@ -2,7 +2,7 @@
2 2
3 // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with3 // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
4 // ConnectRPC: handlers that map the messages of the contract to the use cases4 // ConnectRPC: handlers that map the messages of the contract to the use cases
5-// of app and back, the interceptor that authenticates callers, and the error5+// of usecase and back, the interceptor that authenticates callers, and the error
6 // mapping. The same handlers serve remote clients over HTTP and the web in6 // mapping. The same handlers serve remote clients over HTTP and the web in
7 // process (see InProcessClient).7 // process (see InProcessClient).
8 package rpc8 package rpc
M internal/adapters/web/ASSETS.md +1 -1
@@ -18,7 +18,7 @@ content-hashed names. Nothing is fetched at runtime.
18 18
19 The three htmx files are the exact files the approved mockup was built with.19 The three htmx files are the exact files the approved mockup was built with.
20 To upgrade, replace the file, update the checksum here, and run the web tests20 To upgrade, replace the file, update the checksum here, and run the web tests
21-(`go test ./cmd/ermite -run TestWeb`).21+(`go test ./internal/app -run TestWeb`).
22 22
23 `*_templ.go` files are generated by `go tool templ generate` (templ is pinned23 `*_templ.go` files are generated by `go tool templ generate` (templ is pinned
24 in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale.24 in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale.
M internal/adapters/web/web.go +2 -2
@@ -4,10 +4,10 @@
4 // showcase, rendered with templ from the data of the ermite API.4 // showcase, rendered with templ from the data of the ermite API.
5 //5 //
6 // The package is a client of the API like any other. It talks to the6 // The package is a client of the API like any other. It talks to the
7-// generated ermite-api Connect clients, which cmd binds in process to the7+// generated ermite-api Connect clients, which internal/app binds in process to the
8 // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use8 // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use
9 // cases or the domain. Who the visitor is comes with the request context, set9 // cases or the domain. Who the visitor is comes with the request context, set
10-// by the middleware cmd puts in front of the handler: anonymous in P5. The10+// by the middleware internal/app puts in front of the handler: anonymous in P5. The
11 // API then shows public repositories only; a private repository is11 // API then shows public repositories only; a private repository is
12 // indistinguishable from a missing one, here as everywhere.12 // indistinguishable from a missing one, here as everywhere.
13 //13 //
R cmd/ermite/api_helpers_test.go → internal/app/api_helpers_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
R cmd/ermite/api_test.go → internal/app/api_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
A internal/app/app.go +51 -0
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package app is the composition root and the command line of ermite: it
4+// parses the CLI, reads the environment, builds the adapters and wires them to
5+// the use cases. cmd/ermite is only its entry point.
6+package app
7+
8+import (
9+ "runtime/debug"
10+)
11+
12+// Run executes the ermite command line with args, the command-line arguments
13+// without the program name. It returns the error of the command, which the
14+// caller prints once.
15+func Run(args []string) error {
16+ root := newRootCmd()
17+ if args == nil {
18+ args = []string{}
19+ }
20+ root.SetArgs(args)
21+ return root.Execute()
22+}
23+
24+// ExitCode is the process exit status for the error Run returned.
25+func ExitCode(err error) int {
26+ if err == nil {
27+ return 0
28+ }
29+ return 1
30+}
31+
32+// stamped is the release version set at link time by the image build
33+// (-ldflags "-X bnema.dev/ermite/internal/app.stamped=v0.1.0"). Empty for a
34+// plain go build, which then reports the module version recorded by the Go
35+// toolchain.
36+var stamped string
37+
38+// buildVersion is the version stamped into the binary: the link-time version
39+// when set, else the module version of the build.
40+func buildVersion() string {
41+ if stamped != "" {
42+ return stamped
43+ }
44+ info, ok := debug.ReadBuildInfo()
45+ if !ok || info.Main.Version == "" {
46+ return "(devel)"
47+ }
48+ return info.Main.Version
49+}
50+
51+func version() string { return "ermite " + buildVersion() }
R cmd/ermite/cli.go → internal/app/cli.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
R cmd/ermite/cli_binary_test.go → internal/app/cli_binary_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
R cmd/ermite/cli_e2e_test.go → internal/app/cli_e2e_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bufio"6 "bufio"
R cmd/ermite/cli_fake_test.go → internal/app/cli_fake_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
R cmd/ermite/cli_mr.go → internal/app/cli_mr.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
R cmd/ermite/cli_mr_e2e_test.go → internal/app/cli_mr_e2e_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "os"6 "os"
R cmd/ermite/cli_mr_test.go → internal/app/cli_mr_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "strings"6 "strings"
R cmd/ermite/cli_repo.go → internal/app/cli_repo.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bufio"6 "bufio"
R cmd/ermite/cli_repo_test.go → internal/app/cli_repo_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "errors"6 "errors"
R cmd/ermite/cli_signing.go → internal/app/cli_signing.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
R cmd/ermite/cli_token.go → internal/app/cli_token.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
R cmd/ermite/cli_token_test.go → internal/app/cli_token_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "testing"6 "testing"
R cmd/ermite/cli_version.go → internal/app/cli_version.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
R cmd/ermite/cli_version_test.go → internal/app/cli_version_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "errors"6 "errors"
R cmd/ermite/cli_watch.go → internal/app/cli_watch.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
R cmd/ermite/cli_watch_test.go → internal/app/cli_watch_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bufio"6 "bufio"
R cmd/ermite/cli_web.go → internal/app/cli_web.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "errors"6 "errors"
R cmd/ermite/clock.go → internal/app/clock.go +1 -1 renamed, 94% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import "time"5 import "time"
6 6
R cmd/ermite/datadir.go → internal/app/datadir.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
R cmd/ermite/e2e_helpers_test.go → internal/app/e2e_helpers_test.go +2 -2 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
@@ -69,7 +69,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
69 if o.trustedProxies != "" {69 if o.trustedProxies != "" {
70 env[config.EnvTrustedProxies] = o.trustedProxies70 env[config.EnvTrustedProxies] = o.trustedProxies
71 }71 }
72- // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite72+ // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./internal/app
73 if r := os.Getenv(config.EnvGitReader); r != "" {73 if r := os.Getenv(config.EnvGitReader); r != "" {
74 env[config.EnvGitReader] = r74 env[config.EnvGitReader] = r
75 }75 }
R cmd/ermite/e2e_test.go → internal/app/e2e_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
M internal/app/events_test.go +346 -825
@@ -1,883 +1,404 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package app
4 4
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9- "sync"9+ "net/http"
10- "sync/atomic"10+ "os/exec"
11+ "syscall"
11 "testing"12 "testing"
12- "testing/synctest"
13 "time"13 "time"
14 14
15+ "connectrpc.com/connect"
15 "github.com/stretchr/testify/assert"16 "github.com/stretchr/testify/assert"
16- "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
18+ "google.golang.org/protobuf/types/known/fieldmaskpb"
18 19
19- "bnema.dev/ermite/internal/app"20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite/internal/adapters/rpc"
20 "bnema.dev/ermite/internal/domain"22 "bnema.dev/ermite/internal/domain"
21 "bnema.dev/ermite/internal/ports"23 "bnema.dev/ermite/internal/ports"
22- "bnema.dev/ermite/internal/ports/mocks"24+ "bnema.dev/ermite/internal/usecase"
23 )25 )
24 26
25-// eventRig is an Events use case fed by a channel the test writes to.27+// watchWait is how long a test waits for an event that must arrive. Only
26-type eventRig struct {28+// TestAC5PushEventWithinASecond holds the server to a second.
27- t *testing.T29+const watchWait = 10 * time.Second
28- meta *mocks.RepoMetaStore30+
29- git *mocks.GitRepos31+// AC5: a push shows up in Watch within a second, with the ref updates.
30- feed chan domain.Event32+func TestAC5PushEventWithinASecond(t *testing.T) {
31- auth *mocks.Revalidator33+ h := newHarness(t, harnessOpts{})
32- revoked sync.Map // token IDs revoked by the test34+ h.seed("pub", domain.VisibilityPublic)
33- revalidations atomic.Int32 // calls to auth.Revalidate35+ w := h.watch(h.api(""), "", "home")
34- events *app.Events36+
37+ wt := h.workTree("main", "a.txt")
38+ start := time.Now()
39+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
40+ ev := w.next(t, time.Second)
41+ assert.Less(t, time.Since(start), 2*time.Second)
42+
43+ pushed := ev.GetPushed()
44+ require.NotNil(t, pushed, "got %v", ev)
45+ assert.Equal(t, "pub", pushed.GetRepo())
46+ require.Len(t, pushed.GetRefUpdates(), 1)
47+ up := pushed.GetRefUpdates()[0]
48+ assert.Equal(t, "refs/heads/main", up.GetRef())
49+ assert.Empty(t, up.GetOldId(), "a created ref has no old id")
50+ assert.Equal(t, h.bareGit("pub", "rev-parse", "main"), up.GetNewId())
51+ assert.NotEmpty(t, ev.GetId())
52+ assert.NotNil(t, ev.GetTime())
53+
54+ // A second push to the same ref carries both ids; a deletion has no new id.
55+ old := up.GetNewId()
56+ h.writeFile(wt, "b.txt", []byte("b\n"))
57+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
58+ up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
59+ assert.Equal(t, old, up2.GetOldId())
60+ assert.NotEqual(t, old, up2.GetNewId())
61+
62+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp")
63+ w.next(t, watchWait)
64+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp")
65+ del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0]
66+ assert.Equal(t, "refs/heads/tmp", del.GetRef())
67+ assert.NotEmpty(t, del.GetOldId())
68+ assert.Empty(t, del.GetNewId())
35 }69 }
36 70
37-func newEventRig(t *testing.T, size int) *eventRig {71+// AC5: creating, updating and deleting repositories through the API publish
38- t.Helper()72+// RepoCreated, RepoUpdated and RepoDeleted; so does a create-on-push.
39- return newEventRigConfig(t, app.EventsConfig{Size: size})73+func TestAC5RepoLifecycleEvents(t *testing.T) {
74+ h := newHarness(t, harnessOpts{})
75+ root := h.api(adminToken)
76+ w := h.watch(root, "")
77+
78+ _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC}))
79+ require.NoError(t, err)
80+ created := w.next(t, watchWait).GetRepoCreated().GetRepo()
81+ require.NotNil(t, created)
82+ assert.Equal(t, "life", created.GetName())
83+ assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility())
84+ assert.Equal(t, "main", created.GetDefaultBranch())
85+
86+ _, err = root.repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
87+ Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
88+ }))
89+ require.NoError(t, err)
90+ updated := w.next(t, watchWait).GetRepoUpdated().GetRepo()
91+ require.NotNil(t, updated)
92+ assert.Equal(t, "new", updated.GetDescription())
93+
94+ _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"}))
95+ require.NoError(t, err)
96+ assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo())
97+
98+ // Create on first push publishes RepoCreated once, then the push.
99+ wt := h.workTree("main", "a.txt")
100+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main")
101+ assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
102+ assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo())
103+ // Nothing else came in between: the next event is the sentinel.
104+ h.sentinel(t, w, "s-life")
40 }105 }
41 106
42-func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {107+// AC5: resume from an id replays what came after, then goes live.
43- t.Helper()108+func TestAC5Resume(t *testing.T) {
44- r := &eventRig{109+ h := newHarness(t, harnessOpts{})
45- t: t,110+ h.seed("pub", domain.VisibilityPublic)
46- meta: mocks.NewRepoMetaStore(t),111+ api := h.api("")
47- git: mocks.NewGitRepos(t),112+ w := h.watch(api, "")
48- feed: make(chan domain.Event, 4096),113+
49- auth: mocks.NewRevalidator(t),114+ wt := h.workTree("main", "a.txt")
50- }115+ var ids []string
51- // Tokens are valid until the test revokes them.116+ for i := range 4 {
52- r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error {117+ if i > 0 {
53- r.revalidations.Add(1)118+ h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
54- if _, gone := r.revoked.Load(p.TokenID()); gone {
55- return domain.ErrUnauthenticated
56 }119 }
57- return nil120+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
58- }).Maybe()121+ ids = append(ids, w.next(t, watchWait).GetId())
59- cfg.Auth = r.auth
60- bus := mocks.NewEventBus(t)
61- bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
62- go func() { <-ctx.Done(); close(r.feed) }()
63- return r.feed
64- })
65- clock := mocks.NewClock(t)
66- clock.EXPECT().Now().Return(now).Maybe()
67- r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe()
68- repos := app.NewRepos(r.meta, r.git, bus, clock)
69- r.events = app.NewEvents(context.Background(), repos, bus, clock, cfg)
70- t.Cleanup(func() { r.events.Close(context.Background()) })
71- return r
72-}
73-
74-// repo declares a repository the rig knows.
75-func (r *eventRig) repo(s string, vis domain.Visibility) domain.RepoName {
76- n := name(r.t, s)
77- r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil).Maybe()
78- return n
79-}
80-
81-// pushEvent builds the PushEvent the Pushes use case would publish now: it
82-// carries the repository as the store answers at this moment.
83-func (r *eventRig) pushEvent(n domain.RepoName, oid, ref string) domain.PushEvent {
84- repo, err := r.meta.Get(context.Background(), n)
85- require.NoError(r.t, err)
86- return domain.PushEvent{
87- Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: ref}}, At: now,
88- Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
89 }122 }
90-}123+ w.cancel()
91-124+
92-func (r *eventRig) push(n domain.RepoName, i int) {125+ // The client lost its connection after ids[1]: it gets 2 and 3, then live.
93- r.feed <- r.pushEvent(n, fmt.Sprintf("%040x", i+1), "refs/heads/main")126+ r := h.watch(api, ids[1])
94-}127+ for _, want := range ids[2:] {
95-128+ ev := r.next(t, watchWait)
96-// watcher is a running Watch.129+ assert.Equal(t, want, ev.GetId())
97-type watcher struct {130+ assert.NotNil(t, ev.GetPushed())
98- got chan ports.WatchEvent
99- done chan error
100-}
101-
102-func (r *eventRig) watch(ctx context.Context, p domain.Principal, params ports.WatchParams) *watcher {
103- w := &watcher{got: make(chan ports.WatchEvent, 4096), done: make(chan error, 1)}
104- go func() {
105- w.done <- r.events.Watch(ctx, p, params, func(e ports.WatchEvent) error {
106- w.got <- e
107- return nil
108- })
109- }()
110- return w
111-}
112-
113-func (w *watcher) next(t *testing.T) ports.WatchEvent {
114- t.Helper()
115- synctest.Wait() // everything the event can reach has run
116- select {
117- case e := <-w.got:
118- return e
119- case err := <-w.done:
120- t.Fatalf("watch ended: %v", err)
121- default:
122- t.Fatal("no event")
123 }131 }
124- return ports.WatchEvent{}132+ // Nothing was replayed twice: the next event is the live one, below.
133+ h.writeFile(wt, "live.txt", []byte("x\n"))
134+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
135+ live := r.next(t, watchWait)
136+ assert.NotNil(t, live.GetPushed())
137+ assert.NotContains(t, ids, live.GetId())
138+
139+ // An id nobody issued is a gap, followed by the oldest buffered event: the
140+ // creation of the repository, which came before the first push.
141+ g := h.watch(api, "not-an-id")
142+ assert.NotNil(t, g.next(t, watchWait).GetGap())
143+ assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
144+ assert.Equal(t, ids[0], g.next(t, watchWait).GetId())
125 }145 }
126 146
127-// sentinelOID marks the push events that only prove the ones before them were147+// AC5: a gap when the id fell out of a small ring.
128-// handled.148+func TestAC5GapAfterOverflow(t *testing.T) {
129-var sentinelOID = fmt.Sprintf("%040x", 0xfeed)149+ h := newHarness(t, harnessOpts{eventBuffer: 4})
130-150+ h.seed("pub", domain.VisibilityPublic)
131-// quiet proves that the watcher got nothing since its last read: it pushes an151+ api := h.api(adminToken)
132-// event on n, which the watcher may see, and requires it to be the next one.152+ w := h.watch(api, "")
133-// Events reach a watcher in order, so anything sent before would show up first.153+ wt := h.workTree("main", "a.txt")
134-func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) {154+
135- t.Helper()155+ var ids []string
136- r.feed <- r.pushEvent(n, sentinelOID, "refs/heads/sentinel")156+ for i := range 8 {
137- e := w.next(t)157+ if i > 0 {
138- push, ok := e.Event.(domain.PushEvent)158+ h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n"))
139- if !ok || len(push.Updates) != 1 || push.Updates[0].New != sentinelOID {159+ }
140- t.Fatalf("expected nothing before the sentinel, got %+v", e)160+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
161+ ids = append(ids, w.next(t, watchWait).GetId())
141 }162 }
142-}
143 163
144-func TestWatchLiveAndFiltered(t *testing.T) {164+ // ids[2] is out of the ring (the last four are ids[4:]).
145- t.Parallel()165+ r := h.watch(api, ids[2])
146- synctest.Test(t, func(t *testing.T) {166+ gap := r.next(t, watchWait)
147- r := newEventRig(t, 0)167+ assert.NotNil(t, gap.GetGap())
148- pub := r.repo("pub", domain.VisibilityPublic)168+ assert.Empty(t, gap.GetId())
149- priv := r.repo("priv", domain.VisibilityPrivate)169+ for _, want := range ids[4:] {
150- ctx, cancel := context.WithCancel(context.Background())170+ assert.Equal(t, want, r.next(t, watchWait).GetId())
151- defer cancel()171+ }
152-
153- anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
154- root := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"home"}})
155- scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), ports.WatchParams{})
156- onlyPriv := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
157- synctest.Wait()
158- require.Equal(t, 4, r.events.Watchers())
159-
160- r.push(priv, 1)
161- r.push(pub, 2)
162-
163- // Anonymous and a token limited to another repository never see priv.
164- for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
165- e := w.next(t)
166- assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
167- w.quiet(t, r, pub)
168- }
169- first, second := root.next(t), root.next(t)
170- assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
171- assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)
172- assert.NotEmpty(t, first.ID)
173- assert.NotEqual(t, first.ID, second.ID)
174-
175- assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
176- onlyPriv.quiet(t, r, priv)
177- })
178-}
179 172
180-func TestWatchRechecksAccessPerEvent(t *testing.T) {173+ // The last buffered id still resumes without a gap.
181- t.Parallel()174+ ok := h.watch(api, ids[4])
182- synctest.Test(t, func(t *testing.T) {175+ assert.Equal(t, ids[5], ok.next(t, watchWait).GetId())
183- r := newEventRig(t, 0)
184- n := name(t, "flip")
185- vis := domain.VisibilityPublic
186- var mu sync.Mutex
187- r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
188- mu.Lock()
189- defer mu.Unlock()
190- return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil
191- })
192- ctx, cancel := context.WithCancel(context.Background())
193- defer cancel()
194- w := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
195- synctest.Wait()
196- require.Equal(t, 1, r.events.Watchers())
197-
198- r.push(n, 1)
199- w.next(t)
200-
201- mu.Lock()
202- vis = domain.VisibilityPrivate
203- mu.Unlock()
204- r.push(n, 2)
205- w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
206- })
207 }176 }
208 177
209-func TestWatchResumeAndGap(t *testing.T) {178+// AC5: private events never reach anonymous callers, nor tokens that may not
210- t.Parallel()179+// read the repository; repo topics follow the same rule.
211- const size = 8180+func TestAC5NoPrivateEventsToAnonymous(t *testing.T) {
212- // Each subtest gets its own bubble and a fresh run of 20 events recorded181+ h := newHarness(t, harnessOpts{})
213- // through a first watcher; the ring then holds the last 8.182+ h.seed("pub", domain.VisibilityPublic)
214- setup := func(t *testing.T) (r *eventRig, n domain.RepoName, ctx context.Context, ids []string) {183+ h.seed("priv", domain.VisibilityPrivate)
215- r = newEventRig(t, size)184+ other := h.token(scopes(domain.ScopeRepoRead), "pub")
216- n = r.repo("pub", domain.VisibilityPublic)185+ reader := h.token(scopes(domain.ScopeRepoRead), "priv")
217- ctx, cancel := context.WithCancel(context.Background())186+
218- t.Cleanup(cancel)187+ anon := h.watch(h.api(""), "")
219-188+ scoped := h.watch(h.api(other), "")
220- first := r.watch(ctx, domain.Root(), ports.WatchParams{})189+ rd := h.watch(h.api(reader), "")
221- synctest.Wait()190+ root := h.watch(h.api(adminToken), "")
222- require.Equal(t, 1, r.events.Watchers())191+
223- // One at a time: this watcher must not fall behind the small ring.192+ // A private repository's topic is not found for those who cannot read it.
224- for i := range 20 {193+ assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(""), "repo:priv")))
225- r.push(n, i)194+ assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(other), "repo:priv")))
226- ids = append(ids, first.next(t).ID)195+ assert.Equal(t, codeName(watchErr(h.ctx, h.api(""), "repo:nothing")), codeName(watchErr(h.ctx, h.api(""), "repo:priv")))
227- }196+
228- require.Len(t, ids, 20)197+ wt := h.workTree("main", "a.txt")
229- return r, n, ctx, ids198+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
199+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
200+ // Metadata events too.
201+ _, err := h.api(adminToken).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
202+ Repo: "priv", Description: "secret plans", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
203+ }))
204+ require.NoError(t, err)
205+
206+ // The reader of priv sees its push and its update, and pub's push.
207+ assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo())
208+ assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public")
209+ assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription())
210+ // Root sees everything in order.
211+ assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo())
212+ assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo())
213+ assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName())
214+
215+ // Deleting reaches those who could read the repository before, not others.
216+ _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"}))
217+ require.NoError(t, err)
218+ assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo())
219+ assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo())
220+
221+ for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
222+ ev := w.next(t, watchWait)
223+ assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name)
224+ }
225+ // Nothing about priv came before the sentinel, for either of them.
226+ _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic})
227+ require.NoError(t, err)
228+ for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} {
229+ assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name)
230 }230 }
231 231
232- t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {232+ // Resuming does not replay private events to anonymous either: after a
233- t.Parallel()233+ // gap, the creation and the push of pub, the sentinels the watchers above
234- synctest.Test(t, func(t *testing.T) {234+ // saw, and nothing about priv.
235- r, _, ctx, ids := setup(t)235+ r := h.watch(h.api(""), "bogus")
236- // 20 events were taken, the buffer holds the last 8: ids[12] is the236+ assert.NotNil(t, r.next(t, watchWait).GetGap())
237- // oldest still present; ids[0] is long gone.237+ assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
238- w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[0]})238+ assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo())
239- gap := w.next(t)239+ assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName())
240- assert.True(t, gap.Gap)240+ h.sentinel(t, r, "s-resumed")
241- assert.Empty(t, gap.ID)
242- oldest := w.next(t)
243- assert.False(t, oldest.Gap)
244- assert.Equal(t, ids[12], oldest.ID)
245- })
246- })
247-
248- t.Run("an unknown id gives a gap", func(t *testing.T) {
249- t.Parallel()
250- synctest.Test(t, func(t *testing.T) {
251- r, _, ctx, _ := setup(t)
252- for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {
253- w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: id})
254- assert.True(t, w.next(t).Gap, id)
255- }
256- })
257- })
258-
259- t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) {
260- t.Parallel()
261- synctest.Test(t, func(t *testing.T) {
262- r, _, ctx, _ := setup(t)
263- w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: "otherboot-999"})
264- assert.True(t, w.next(t).Gap)
265- })
266- })
267-
268- t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
269- t.Parallel()
270- synctest.Test(t, func(t *testing.T) {
271- r, n, ctx, ids := setup(t)
272- w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[16]})
273- for _, want := range ids[17:] {
274- e := w.next(t)
275- assert.False(t, e.Gap)
276- assert.Equal(t, want, e.ID)
277- }
278- // Nothing else was replayed: the next event is the one pushed now.
279- r.push(n, 98)
280- live := w.next(t)
281- assert.False(t, live.Gap)
282- assert.NotContains(t, ids, live.ID)
283- })
284- })
285-
286- t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) {
287- t.Parallel()
288- synctest.Test(t, func(t *testing.T) {
289- r, n, ctx, ids := setup(t)
290- w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[len(ids)-1]})
291- r.push(n, 99)
292- e := w.next(t)
293- assert.False(t, e.Gap)
294- assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")
295- })
296- })
297 }241 }
298 242
299-func TestWatchTopicErrors(t *testing.T) {243+// AC5: a repo topic only carries that repository; a revoked token cannot
300- t.Parallel()244+// start a new watch.
301- r := newEventRig(t, 0)245+func TestAC5RepoTopic(t *testing.T) {
302- r.repo("pub", domain.VisibilityPublic)246+ h := newHarness(t, harnessOpts{})
303- r.repo("priv", domain.VisibilityPrivate)247+ h.seed("a", domain.VisibilityPublic)
304- r.meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound).Maybe()248+ h.seed("b", domain.VisibilityPublic)
305- noop := func(ports.WatchEvent) error { return nil }249+ w := h.watch(h.api(""), "", "repo:a")
306- ctx := context.Background()250+
307-251+ wt := h.workTree("main", "x.txt")
308- for topic, want := range map[string]error{252+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main")
309- "nope": domain.ErrInvalid,253+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
310- "repo:Bad": domain.ErrInvalidName,254+ assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo())
311- "repo:": domain.ErrInvalidName,255+ // The push to b was not delivered: the next event is the next push to a.
312- "repo:gone": domain.ErrNotFound,256+ h.writeFile(wt, "y.txt", []byte("y\n"))
313- "repo:priv": domain.ErrNotFound, // hidden from anonymous257+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main")
314- "repo:pub:foo": domain.ErrInvalidName,258+ assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId())
315- } {
316- err := r.events.Watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{topic}}, noop)
317- assert.ErrorIs(t, err, want, topic)
318- }
319 }259 }
320 260
321-func TestWatchEndsCleanly(t *testing.T) {261+// The stream ends cleanly when the client cancels, and Watch counts go back to zero.
322- t.Parallel()262+func TestWatchEndsOnCancel(t *testing.T) {
323- t.Run("on context cancellation", func(t *testing.T) {263+ h := newHarness(t, harnessOpts{})
324- t.Parallel()264+ w := h.watch(h.api(""), "")
325- synctest.Test(t, func(t *testing.T) {265+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond)
326- r := newEventRig(t, 0)266+ w.cancel()
327- ctx, cancel := context.WithCancel(context.Background())267+ select {
328- w := r.watch(ctx, domain.Root(), ports.WatchParams{})268+ case <-w.done:
329- synctest.Wait()269+ case <-time.After(5 * watchWait):
330- require.Equal(t, 1, r.events.Watchers())270+ t.Fatal("the stream did not end")
331- cancel()271+ }
332- synctest.Wait()272+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond)
333- select {
334- case err := <-w.done:
335- assert.NoError(t, err)
336- default:
337- t.Fatal("watch did not end")
338- }
339- assert.Zero(t, r.events.Watchers())
340- })
341- })
342-
343- t.Run("on Close", func(t *testing.T) {
344- t.Parallel()
345- synctest.Test(t, func(t *testing.T) {
346- r := newEventRig(t, 0)
347- w := r.watch(context.Background(), domain.Root(), ports.WatchParams{})
348- synctest.Wait()
349- require.Equal(t, 1, r.events.Watchers())
350- r.events.Close(context.Background())
351- r.events.Close(context.Background()) // twice is fine
352- synctest.Wait()
353- select {
354- case err := <-w.done:
355- assert.NoError(t, err)
356- default:
357- t.Fatal("watch did not end")
358- }
359- })
360- })
361-
362- t.Run("a failing send ends the watch with its error", func(t *testing.T) {
363- t.Parallel()
364- synctest.Test(t, func(t *testing.T) {
365- r := newEventRig(t, 0)
366- n := r.repo("pub", domain.VisibilityPublic)
367- boom := errors.New("client went away")
368- done := make(chan error, 1)
369- go func() {
370- done <- r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{}, func(ports.WatchEvent) error { return boom })
371- }()
372- synctest.Wait()
373- require.Equal(t, 1, r.events.Watchers())
374- r.push(n, 1)
375- assert.ErrorIs(t, <-done, boom)
376- })
377- })
378 }273 }
379 274
380-func TestWatchSlowWatcherGetsAGap(t *testing.T) {275+// Shutdown ends the streams cleanly: the client sees the stream end without
381- t.Parallel()276+// an error and the server exits 0 well before the shutdown timeout.
382- synctest.Test(t, func(t *testing.T) {277+func TestServeShutdownEndsWatchStreams(t *testing.T) {
383- const size = 4278+ requireGit(t)
384- r := newEventRig(t, size)279+ data := t.TempDir()
385- n := r.repo("pub", domain.VisibilityPublic)280+ addr := freeAddr(t)
386-281+ cmd := exec.Command(binary(t), "serve")
387- release := make(chan struct{})282+ cmd.Env = append(baseEnv(),
388- var mu sync.Mutex283+ "ERMITE_ADDR="+addr, "ERMITE_DATA_DIR="+data, "ERMITE_PUBLIC_URL=http://"+addr,
389- var got []ports.WatchEvent284+ "ERMITE_ADMIN_TOKEN="+adminToken, "ERMITE_LOG_LEVEL=debug", "ERMITE_LOG_FORMAT=json",
390- done := make(chan error, 1)285+ )
391- blocked := make(chan struct{})286+ var logs syncBuffer
392- first := true287+ cmd.Stdout, cmd.Stderr = &logs, &logs
393- go func() {288+ require.NoError(t, cmd.Start())
394- done <- r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{}, func(e ports.WatchEvent) error {289+ exited := make(chan error, 1)
395- if first { // block on the first event while the ring turns over290+ go func() { exited <- cmd.Wait() }()
396- first = false291+ t.Cleanup(func() { _ = cmd.Process.Kill() })
397- close(blocked)292+
398- <-release293+ base := "http://" + addr
399- }294+ require.Eventually(t, func() bool {
400- mu.Lock()295+ resp, err := http.Get(base + "/-/health")
401- got = append(got, e)296+ if err != nil {
402- mu.Unlock()297+ return false
403- return nil
404- })
405- }()
406- // A second, fast watcher tells when the use case has taken every event:
407- // it receives them in order.
408- fast := r.watch(context.Background(), domain.Root(), ports.WatchParams{})
409- synctest.Wait()
410- require.Equal(t, 2, r.events.Watchers())
411- r.push(n, 0)
412- <-blocked
413- for i := 1; i < 30; i++ {
414- r.push(n, i)
415- }
416- // The ring holds four events: the fast watcher may get a gap, but it ends
417- // on the last push, which proves the use case took all of them.
418- last := fmt.Sprintf("%040x", 29+1)
419- for {
420- e := fast.next(t)
421- if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
422- break
423- }
424 }298 }
425- close(release)299+ _ = resp.Body.Close()
426-300+ return resp.StatusCode == 200
427- synctest.Wait()301+ }, 15*time.Second, 50*time.Millisecond, "%s", logs.String())
428- mu.Lock()302+
429- defer mu.Unlock()303+ c := newClients(base+"/-/api", adminToken)
430- require.GreaterOrEqual(t, len(got), 2)304+ ctx, cancel := context.WithCancel(context.Background())
431- hasGap := false305+ defer cancel()
432- for _, e := range got[1:] {306+ st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{}))
433- hasGap = hasGap || e.Gap307+ require.NoError(t, err)
308+ ended := make(chan error, 1)
309+ go func() {
310+ for st.Receive() {
434 }311 }
435- assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)312+ ended <- st.Err()
436- })313+ }()
437-}
438 314
439-func TestEventsRepoPayloads(t *testing.T) {315+ // The server version comes from the real binary.
440- t.Parallel()316+ v, err := c.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
441- synctest.Test(t, func(t *testing.T) {317+ require.NoError(t, err)
442- r := newEventRig(t, 0)318+ assert.NotEmpty(t, v.Msg.GetVersion())
443- pub := r.repo("pub", domain.VisibilityPublic)
444- priv := r.repo("priv", domain.VisibilityPrivate)
445- ctx, cancel := context.WithCancel(context.Background())
446- defer cancel()
447- anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
448- root := r.watch(ctx, domain.Root(), ports.WatchParams{})
449- synctest.Wait()
450- require.Equal(t, 2, r.events.Watchers())
451-
452- r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
453- r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now}
454- // A deleted repository cannot be read any more: its visibility decides.
455- r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
456- r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
457-
458- for _, w := range []*watcher{anon, root} {
459- created := w.next(t)
460- require.NotNil(t, created.Repo)
461- assert.Equal(t, "main", created.Repo.DefaultBranch)
462- assert.Equal(t, pub, created.Repo.Name)
463- require.NotNil(t, w.next(t).Repo)
464- }
465- assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
466- assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
467- assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
468- anon.quiet(t, r, pub)
469- })
470-}
471 319
472-// tokenWith builds the principal of a stored token with an id and an expiry.320+ start := time.Now()
473-func tokenWith(id string, expires time.Time, repos ...string) domain.Principal {321+ require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
474- tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires}322+ select {
475- for _, r := range repos {323+ case err := <-ended:
476- n, err := domain.ParseRepoName(r)324+ assert.NoError(t, err, "the stream ends cleanly")
477- if err != nil {325+ case <-time.After(15 * watchWait):
478- panic(err)326+ t.Fatalf("the stream did not end\n%s", logs.String())
479- }327+ }
480- tok.Repos = append(tok.Repos, n)328+ select {
329+ case err := <-exited:
330+ require.NoError(t, err, "%s", logs.String())
331+ case <-time.After(15 * watchWait):
332+ t.Fatalf("the server did not exit\n%s", logs.String())
481 }333 }
482- return domain.TokenPrincipal(tok)334+ assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout")
335+ assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String())
336+ assert.False(t, errors.Is(err, context.Canceled))
483 }337 }
484 338
339+// A token that is revoked while its Watch is open ends the stream with
340+// unauthenticated, and the private events that follow never reach it.
485 func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {341 func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
486- t.Parallel()342+ h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond})
487- synctest.Test(t, func(t *testing.T) {343+ h.seed("priv", domain.VisibilityPrivate)
488- // Asking at every turn keeps the test independent of the clock.344+ created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{
489- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})345+ Label: "watcher", Scopes: scopes(domain.ScopeRepoRead),
490- pub := r.repo("pub", domain.VisibilityPublic)
491- priv := r.repo("priv", domain.VisibilityPrivate)
492- p := tokenWith("tok", time.Time{})
493- ctx, cancel := context.WithCancel(context.Background())
494- defer cancel()
495-
496- w := r.watch(ctx, p, ports.WatchParams{})
497- synctest.Wait()
498- require.Equal(t, 1, r.events.Watchers())
499- r.push(pub, 1)
500- w.next(t)
501-
502- r.revoked.Store("tok", true)
503- r.push(priv, 2) // would be delivered to a token that may read priv
504- synctest.Wait()
505- select {
506- case err := <-w.done:
507- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
508- default:
509- t.Fatal("the watch of a revoked token did not end")
510- }
511- select {
512- case e := <-w.got:
513- t.Fatalf("an event was sent after the revocation: %+v", e)
514- default:
515- }
516- })
517-}
518-
519-func TestWatchEndsWhenTheTokenExpires(t *testing.T) {
520- t.Parallel()
521- synctest.Test(t, func(t *testing.T) {
522- r := newEventRig(t, 0)
523- pub := r.repo("pub", domain.VisibilityPublic)
524- // The rig's clock stands at now: this token expired at now.
525- p := tokenWith("tok", now)
526- w := r.watch(context.Background(), p, ports.WatchParams{})
527- r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated
528- synctest.Wait()
529- select {
530- case err := <-w.done:
531- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
532- default:
533- t.Fatal("the watch of an expired token did not end")
534- }
535- assert.Empty(t, w.got)
536- })
537-}
538-
539-func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
540- t.Parallel()
541- t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
542- t.Parallel()
543- synctest.Test(t, func(t *testing.T) {
544- r := newEventRigConfig(t, app.EventsConfig{}) // the production default interval
545- w := r.watch(context.Background(), tokenWith("tok", time.Time{}), ports.WatchParams{})
546- synctest.Wait()
547- require.Equal(t, 1, r.events.Watchers())
548- r.revoked.Store("tok", true)
549- synctest.Sleep(app.DefaultRevalidateEvery - time.Nanosecond)
550- select {
551- case err := <-w.done:
552- t.Fatalf("the watch ended before the interval: %v", err)
553- default:
554- }
555- assert.Zero(t, r.revalidations.Load(), "no store lookup before the interval")
556- synctest.Sleep(time.Nanosecond)
557- select {
558- case err := <-w.done:
559- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
560- default:
561- t.Fatal("an idle watch of a revoked token did not end at the interval")
562- }
563- assert.Equal(t, int32(1), r.revalidations.Load())
564- })
565 })346 })
347+ require.NoError(t, err)
348+ w := h.watch(h.api(created.Secret), "")
349+ wt := h.workTree("main", "a.txt")
350+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
351+ assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv")
566 352
567- t.Run("the answer is kept between checks", func(t *testing.T) {353+ require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
568- t.Parallel()354+ h.writeFile(wt, "b.txt", []byte("b\n"))
569- synctest.Test(t, func(t *testing.T) {355+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
570- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})
571- pub := r.repo("pub", domain.VisibilityPublic)
572- w := r.watch(context.Background(), tokenWith("tok", time.Time{}), ports.WatchParams{})
573- synctest.Wait()
574- require.Equal(t, 1, r.events.Watchers())
575- for i := range 20 {
576- r.push(pub, i)
577- }
578- for range 20 {
579- w.next(t)
580- }
581- // Idle, the watcher wakes up every few seconds but keeps its answer
582- // until the interval is over.
583- synctest.Sleep(time.Hour - time.Nanosecond)
584- assert.Zero(t, r.revalidations.Load(), "no store lookup within the interval")
585- synctest.Sleep(time.Nanosecond)
586- assert.Equal(t, int32(1), r.revalidations.Load(), "one lookup when the interval is over")
587- })
588- })
589 356
590- t.Run("root and anonymous never ask", func(t *testing.T) {357+ select {
591- t.Parallel()358+ case <-w.done:
592- synctest.Test(t, func(t *testing.T) {359+ case <-time.After(5 * watchWait):
593- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})360+ t.Fatal("the stream of a revoked token did not end")
594- pub := r.repo("pub", domain.VisibilityPublic)361+ }
595- a := r.watch(context.Background(), domain.Anonymous(), ports.WatchParams{})362+ assert.Equal(t, "unauthenticated", codeName(w.err))
596- b := r.watch(context.Background(), domain.Root(), ports.WatchParams{})363+ for ev := range w.events {
597- synctest.Wait()364+ t.Fatalf("an event reached the revoked token: %v", ev)
598- require.Equal(t, 2, r.events.Watchers())365+ }
599- r.push(pub, 1)
600- a.next(t)
601- b.next(t)
602- r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)
603- })
604- })
605 }366 }
606 367
368+// Watch refuses too many topics, and too many streams from one client.
607 func TestWatchLimits(t *testing.T) {369 func TestWatchLimits(t *testing.T) {
608- t.Parallel()370+ h := newHarness(t, harnessOpts{})
609- noop := func(ports.WatchEvent) error { return nil }371+ api := h.api(adminToken)
610-
611- t.Run("too many topics", func(t *testing.T) {
612- t.Parallel()
613- synctest.Test(t, func(t *testing.T) {
614- r := newEventRig(t, 0)
615- r.repo("pub", domain.VisibilityPublic)
616- ok := make([]string, app.MaxWatchTopics)
617- for i := range ok {
618- ok[i] = "home"
619- }
620- ctx, cancel := context.WithCancel(context.Background())
621- cancel() // returns at once once the topics are accepted
622- assert.NoError(t, r.events.Watch(ctx, domain.Root(), ports.WatchParams{Topics: ok}, noop))
623- err := r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{Topics: append(ok, "home")}, noop)
624- assert.ErrorIs(t, err, domain.ErrInvalid)
625- })
626- })
627-
628- t.Run("total watchers", func(t *testing.T) {
629- t.Parallel()
630- synctest.Test(t, func(t *testing.T) {
631- r := newEventRig(t, 0)
632- ctx, cancel := context.WithCancel(context.Background())
633- defer cancel()
634- for range app.MaxWatchers {
635- r.watch(ctx, domain.Root(), ports.WatchParams{})
636- }
637- synctest.Wait()
638- require.Equal(t, app.MaxWatchers, r.events.Watchers())
639- err := r.events.Watch(ctx, domain.Root(), ports.WatchParams{}, noop)
640- assert.ErrorIs(t, err, domain.ErrResourceExhausted)
641- assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")
642-
643- cancel() // all end; a new one is accepted again
644- synctest.Wait()
645- require.Equal(t, 0, r.events.Watchers())
646- again, stop := context.WithCancel(context.Background())
647- stop()
648- assert.NoError(t, r.events.Watch(again, domain.Root(), ports.WatchParams{}, noop))
649- })
650- })
651 372
652- t.Run("watchers per client", func(t *testing.T) {373+ topics := make([]string, usecase.MaxWatchTopics+1)
653- t.Parallel()374+ for i := range topics {
654- synctest.Test(t, func(t *testing.T) {375+ topics[i] = "home"
655- r := newEventRig(t, 0)
656- ctx, cancel := context.WithCancel(context.Background())
657- defer cancel()
658- for range app.MaxWatchersPerClient {
659- r.watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.1"})
660- }
661- synctest.Wait()
662- require.Equal(t, app.MaxWatchersPerClient, r.events.Watchers())
663-
664- err := r.events.Watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.1"}, noop)
665- assert.ErrorIs(t, err, domain.ErrResourceExhausted)
666-
667- // Another client, and callers in process, are not affected.
668- r.watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.2"})
669- r.watch(ctx, domain.Root(), ports.WatchParams{})
670- synctest.Wait()
671- require.Equal(t, app.MaxWatchersPerClient+2, r.events.Watchers())
672-
673- cancel()
674- synctest.Wait()
675- require.Equal(t, 0, r.events.Watchers())
676- again, stop := context.WithCancel(context.Background())
677- stop()
678- assert.NoError(t, r.events.Watch(again, domain.Root(), ports.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")
679- })
680- })
681-}
682-
683-// repoAt declares a repository whose metadata the test changes.
684-type liveRepo struct {
685- mu sync.Mutex
686- repo domain.Repo
687-}
688-
689-func (l *liveRepo) set(f func(*domain.Repo)) {
690- l.mu.Lock()
691- defer l.mu.Unlock()
692- f(&l.repo)
693-}
694-
695-func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time) (*liveRepo, domain.RepoName) {
696- n := name(r.t, s)
697- l := &liveRepo{repo: domain.Repo{Name: n, Visibility: vis, CreatedAt: created}}
698- r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
699- l.mu.Lock()
700- defer l.mu.Unlock()
701- return l.repo, nil
702- }).Maybe()
703- return l, n
704-}
705-
706-func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {
707- t.Parallel()
708- t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {
709- t.Parallel()
710- synctest.Test(t, func(t *testing.T) {
711- ctx, cancel := context.WithCancel(context.Background())
712- defer cancel()
713- r := newEventRig(t, 0)
714- l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
715- root := r.watch(ctx, domain.Root(), ports.WatchParams{})
716- synctest.Wait()
717- require.Equal(t, 1, r.events.Watchers())
718- r.push(n, 1)
719- r.push(n, 2)
720- root.next(t)
721- root.next(t) // both are buffered now
722-
723- l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })
724- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}
725- root.next(t)
726- r.push(n, 3) // public era
727-
728- // An anonymous caller that resumes with an id nobody issued gets what
729- // is buffered, filtered: the public update and the public push, not the
730- // pushes of the private era.
731- anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"})
732- assert.True(t, anon.next(t).Gap)
733- upd := anon.next(t)
734- require.IsType(t, domain.RepoUpdated{}, upd.Event)
735- push := anon.next(t)
736- require.IsType(t, domain.PushEvent{}, push.Event)
737- assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)
738- anon.quiet(t, r, n)
739- })
740- })
741-
742- t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) {
743- t.Parallel()
744- synctest.Test(t, func(t *testing.T) {
745- ctx, cancel := context.WithCancel(context.Background())
746- defer cancel()
747- r := newEventRig(t, 0)
748- first := now.Add(-time.Hour)
749- l, n := r.liveRepo("again", domain.VisibilityPublic, first)
750- root := r.watch(ctx, domain.Root(), ports.WatchParams{})
751- synctest.Wait()
752- require.Equal(t, 1, r.events.Watchers())
753- r.push(n, 1)
754- root.next(t)
755-
756- l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again
757- r.push(n, 2)
758- root.next(t)
759-
760- anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"})
761- assert.True(t, anon.next(t).Gap)
762- e := anon.next(t)
763- require.IsType(t, domain.PushEvent{}, e.Event)
764- assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")
765- anon.quiet(t, r, n)
766- })
767- })
768-
769- t.Run("a repository made private tells those who could read it, once", func(t *testing.T) {
770- t.Parallel()
771- synctest.Test(t, func(t *testing.T) {
772- ctx, cancel := context.WithCancel(context.Background())
773- defer cancel()
774- r := newEventRig(t, 0)
775- l, n := r.liveRepo("hide", domain.VisibilityPublic, now)
776- anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
777- reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), ports.WatchParams{})
778- other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), ports.WatchParams{})
779- root := r.watch(ctx, domain.Root(), ports.WatchParams{})
780- synctest.Wait()
781- require.Equal(t, 4, r.events.Watchers())
782-
783- l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })
784- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}
785-
786- for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {
787- e := w.next(t)
788- up, ok := e.Event.(domain.RepoUpdated)
789- require.True(t, ok, name)
790- assert.Equal(t, n, up.Repo, name)
791- require.NotNil(t, e.Repo, name)
792- assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)
793- assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)
794- }
795-
796- // Later events of the repository reach those who may read it now, only.
797- r.push(n, 1)
798- assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)
799- assert.IsType(t, domain.PushEvent{}, root.next(t).Event)
800- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
801- other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
802- })
803- })
804-
805- t.Run("a private repository that never was public stays hidden", func(t *testing.T) {
806- t.Parallel()
807- synctest.Test(t, func(t *testing.T) {
808- ctx, cancel := context.WithCancel(context.Background())
809- defer cancel()
810- r := newEventRig(t, 0)
811- _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)
812- anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
813- synctest.Wait()
814- require.Equal(t, 1, r.events.Watchers())
815- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
816- r.push(n, 1)
817- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
818- })
819- })
820-}
821-
822-// TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was
823-// when published, not as the store says when the feeder gets to it.
824-func TestPushEventsKeepTheirSnapshot(t *testing.T) {
825- t.Parallel()
826- tests := []struct {
827- name string
828- swap func(repo *domain.Repo)
829- rootSees bool // the repository is still the one the push is about
830- }{
831- {"made public before the feeder appends", func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }, true},
832- {"recreated before the feeder appends", func(repo *domain.Repo) {
833- repo.Visibility = domain.VisibilityPublic
834- repo.CreatedAt = now.Add(time.Hour)
835- }, false},
836 }376 }
837- for _, tt := range tests {377+ assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...)))
838- t.Run(tt.name, func(t *testing.T) {378+
839- t.Parallel()379+ for range usecase.MaxWatchersPerClient {
840- synctest.Test(t, func(t *testing.T) {380+ h.watch(api, "")
841- ctx, cancel := context.WithCancel(context.Background())
842- defer cancel()
843- r := newEventRig(t, 0)
844- l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
845- anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
846- root := r.watch(ctx, domain.Root(), ports.WatchParams{})
847- synctest.Wait()
848- require.Equal(t, 2, r.events.Watchers())
849-
850- // Published while private; the store changes before the feeder
851- // can have looked at it: the event is built, then the state is
852- // swapped, and only then is it handed to the feeder.
853- ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main")
854- l.set(tt.swap)
855- r.feed <- ev
856- if tt.rootSees {
857- root.next(t)
858- }
859-
860- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
861-
862- late := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"})
863- assert.True(t, late.next(t).Gap)
864- late.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
865- })
866- })
867 }381 }
382+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == usecase.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
383+ assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api)))
384+ // A stream of another client address is not affected (the harness trusts
385+ // the loopback proxy only when configured: use the in-process caller).
386+ inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL)
387+ ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root()))
388+ defer cancel()
389+ st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{}))
390+ require.NoError(t, err)
391+ _ = st.Close()
392+}
868 393
869- t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) {394+// sentinel proves that nothing reached the stream since the last event it
870- t.Parallel()395+// returned: it creates a public repository, which every watcher of "home" may
871- synctest.Test(t, func(t *testing.T) {396+// see, and requires its RepoCreated to be the next event. Events arrive in the
872- ctx, cancel := context.WithCancel(context.Background())397+// order they were published.
873- defer cancel()398+func (h *harness) sentinel(t *testing.T, s *stream, name string) {
874- r := newEventRig(t, 0)399+ t.Helper()
875- n := r.repo("ghost", domain.VisibilityPublic)400+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
876- root := r.watch(ctx, domain.Root(), ports.WatchParams{})401+ require.NoError(t, err)
877- synctest.Wait()402+ ev := s.next(t, watchWait)
878- require.Equal(t, 1, r.events.Watchers())403+ assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev)
879- r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now}
880- root.quiet(t, r, n)
881- })
882- })
883 }404 }
R cmd/ermite/forge.go → internal/app/forge.go +25 -25 renamed, 88% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
@@ -24,11 +24,11 @@ import (
24 "bnema.dev/ermite/internal/adapters/rpc"24 "bnema.dev/ermite/internal/adapters/rpc"
25 "bnema.dev/ermite/internal/adapters/sshsig"25 "bnema.dev/ermite/internal/adapters/sshsig"
26 "bnema.dev/ermite/internal/adapters/web"26 "bnema.dev/ermite/internal/adapters/web"
27- "bnema.dev/ermite/internal/app"
28 "bnema.dev/ermite/internal/clientlimit"27 "bnema.dev/ermite/internal/clientlimit"
29 "bnema.dev/ermite/internal/config"28 "bnema.dev/ermite/internal/config"
30 "bnema.dev/ermite/internal/domain"29 "bnema.dev/ermite/internal/domain"
31 "bnema.dev/ermite/internal/ports"30 "bnema.dev/ermite/internal/ports"
31+ "bnema.dev/ermite/internal/usecase"
32 )32 )
33 33
34 // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in34 // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in
@@ -40,13 +40,13 @@ type options struct {
40 // hookExe is the executable the hook scripts exec; empty means this40 // hookExe is the executable the hook scripts exec; empty means this
41 // process's own executable.41 // process's own executable.
42 hookExe string42 hookExe string
43- // maintenanceDelay overrides app.MaintenanceDelay when positive.43+ // maintenanceDelay overrides usecase.MaintenanceDelay when positive.
44 maintenanceDelay time.Duration44 maintenanceDelay time.Duration
45 // maxBodyBytes overrides the limit of a push body when positive.45 // maxBodyBytes overrides the limit of a push body when positive.
46 maxBodyBytes int6446 maxBodyBytes int64
47- // eventBuffer overrides app.DefaultEventBuffer when positive.47+ // eventBuffer overrides usecase.DefaultEventBuffer when positive.
48 eventBuffer int48 eventBuffer int
49- // revalidateEvery overrides app.DefaultRevalidateEvery when not zero.49+ // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
50 revalidateEvery time.Duration50 revalidateEvery time.Duration
51 }51 }
52 52
@@ -56,24 +56,24 @@ type forge struct {
56 store *kvstore.Store56 store *kvstore.Store
57 server *httpserver.Server57 server *httpserver.Server
58 hooks *githttp.HookServer58 hooks *githttp.HookServer
59- maint *app.Maintainer59+ maint *usecase.Maintainer
60 git *gitcli.Git60 git *gitcli.Git
61 // reader is the ermite-git reader when ERMITE_GIT_READER selects it.61 // reader is the ermite-git reader when ERMITE_GIT_READER selects it.
62 reader *ermitegit.Adapter62 reader *ermitegit.Adapter
63 63
64 // The use cases and the API are exposed for tests and for the phases64 // The use cases and the API are exposed for tests and for the phases
65 // that add the web.65 // that add the web.
66- Repos *app.Repos66+ Repos *usecase.Repos
67- Tokens *app.Tokens67+ Tokens *usecase.Tokens
68- Auth *app.Auth68+ Auth *usecase.Auth
69- Pushes *app.Pushes69+ Pushes *usecase.Pushes
70- Browse *app.Browse70+ Browse *usecase.Browse
71 // MergeRequests and SigningKey serve MergeRequestService and71 // MergeRequests and SigningKey serve MergeRequestService and
72 // ServerService.GetSigningKey.72 // ServerService.GetSigningKey.
73- MergeRequests *app.MergeRequests73+ MergeRequests *usecase.MergeRequests
74- SigningKey *app.SigningKey74+ SigningKey *usecase.SigningKey
75- Sessions *app.Sessions75+ Sessions *usecase.Sessions
76- EventLog *app.Events76+ EventLog *usecase.Events
77 API *rpc.API77 API *rpc.API
78 Bus ports.EventBus78 Bus ports.EventBus
79 }79 }
@@ -139,7 +139,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
139 139
140 clock := systemClock{}140 clock := systemClock{}
141 f.Bus = eventbus.New()141 f.Bus = eventbus.New()
142- f.Repos = app.NewRepos(store, git, f.Bus, clock)142+ f.Repos = usecase.NewRepos(store, git, f.Bus, clock)
143 report, err := f.Repos.Reconcile(ctx)143 report, err := f.Repos.Reconcile(ctx)
144 if err != nil {144 if err != nil {
145 return nil, fmt.Errorf("reconcile repositories: %w", err)145 return nil, fmt.Errorf("reconcile repositories: %w", err)
@@ -150,10 +150,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
150 Int("invalid_dirs", len(report.InvalidDirs)).150 Int("invalid_dirs", len(report.InvalidDirs)).
151 Msg("repositories reconciled")151 Msg("repositories reconciled")
152 152
153- f.Tokens = app.NewTokens(store.Tokens(), clock, store.Sessions())153+ f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
154- f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)154+ f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
155- f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)155+ f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
156- f.Pushes = app.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)156+ f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
157 157
158 exe := opts.hookExe158 exe := opts.hookExe
159 if exe == "" {159 if exe == "" {
@@ -181,8 +181,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
181 reader = f.reader181 reader = f.reader
182 }182 }
183 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")183 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
184- f.Browse = app.NewBrowse(store, reader)184+ f.Browse = usecase.NewBrowse(store, reader)
185- f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{185+ f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
186 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,186 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
187 })187 })
188 188
@@ -196,14 +196,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
196 return nil, err196 return nil, err
197 }197 }
198 committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}198 committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
199- f.SigningKey = app.NewSigningKey(signer, committer)199+ f.SigningKey = usecase.NewSigningKey(signer, committer)
200- f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{200+ f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
201 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,201 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
202 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,202 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
203 })203 })
204 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")204 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
205 205
206- f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)206+ f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
207 if err != nil {207 if err != nil {
208 return nil, err208 return nil, err
209 }209 }
R cmd/ermite/gitreader_test.go → internal/app/gitreader_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "os"6 "os"
R cmd/ermite/hook.go → internal/app/hook.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "os"6 "os"
R cmd/ermite/main_test.go → internal/app/main_test.go +2 -2 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
@@ -43,7 +43,7 @@ func binary(t *testing.T) string {
43 return43 return
44 }44 }
45 binPath = filepath.Join(dir, "ermite")45 binPath = filepath.Join(dir, "ermite")
46- out, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput()46+ out, err := exec.Command("go", "build", "-o", binPath, "bnema.dev/ermite/cmd/ermite").CombinedOutput()
47 if err != nil {47 if err != nil {
48 buildErr = errors.New("go build: " + err.Error() + "\n" + string(out))48 buildErr = errors.New("go build: " + err.Error() + "\n" + string(out))
49 }49 }
R cmd/ermite/mr_api_test.go → internal/app/mr_api_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "os"6 "os"
R cmd/ermite/root.go → internal/app/root.go +2 -2 renamed, 93% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "github.com/spf13/cobra"6 "github.com/spf13/cobra"
@@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
21 "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +21 "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +
22 "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",22 "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",
23 SilenceUsage: true,23 SilenceUsage: true,
24- SilenceErrors: true, // main prints the error once24+ SilenceErrors: true, // cmd/ermite prints the error once
25 }25 }
26 root.AddCommand(26 root.AddCommand(
27 newServeCmd(), newHookCmd(),27 newServeCmd(), newHookCmd(),
R cmd/ermite/serve.go → internal/app/serve.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
R cmd/ermite/testdata/web/blob-binary.html → internal/app/testdata/web/blob-binary.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-code.html → internal/app/testdata/web/blob-code.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-empty.html → internal/app/testdata/web/blob-empty.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-image.html → internal/app/testdata/web/blob-image.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-markdown.html → internal/app/testdata/web/blob-markdown.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-svg.html → internal/app/testdata/web/blob-svg.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-text.html → internal/app/testdata/web/blob-text.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-too-large.html → internal/app/testdata/web/blob-too-large.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/blob-trojan.html → internal/app/testdata/web/blob-trojan.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-big.html → internal/app/testdata/web/commit-big.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-binary.html → internal/app/testdata/web/commit-binary.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-merge.html → internal/app/testdata/web/commit-merge.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-modify-split.html → internal/app/testdata/web/commit-modify-split.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-modify.html → internal/app/testdata/web/commit-modify.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-rename.html → internal/app/testdata/web/commit-rename.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-root.html → internal/app/testdata/web/commit-root.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commit-trojan.html → internal/app/testdata/web/commit-trojan.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commits-page2.html → internal/app/testdata/web/commits-page2.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commits-path.html → internal/app/testdata/web/commits-path.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commits-root.html → internal/app/testdata/web/commits-root.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/commits-small.html → internal/app/testdata/web/commits-small.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/empty.html → internal/app/testdata/web/empty.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/home.html → internal/app/testdata/web/home.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/not-found-page.html → internal/app/testdata/web/not-found-page.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/not-found.html → internal/app/testdata/web/not-found.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/refs.html → internal/app/testdata/web/refs.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/summary.html → internal/app/testdata/web/summary.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/tree-nested.html → internal/app/testdata/web/tree-nested.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/tree-root.html → internal/app/testdata/web/tree-root.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/tree-slash-ref.html → internal/app/testdata/web/tree-slash-ref.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/testdata/web/tree-subdir.html → internal/app/testdata/web/tree-subdir.html +0 -0 renamed, 100% similar

renamed, 100% similar

R cmd/ermite/web.go → internal/app/web.go +6 -6 renamed, 94% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
@@ -17,11 +17,11 @@ import (
17 "bnema.dev/ermite/internal/adapters/httpserver"17 "bnema.dev/ermite/internal/adapters/httpserver"
18 "bnema.dev/ermite/internal/adapters/rpc"18 "bnema.dev/ermite/internal/adapters/rpc"
19 "bnema.dev/ermite/internal/adapters/web"19 "bnema.dev/ermite/internal/adapters/web"
20- "bnema.dev/ermite/internal/app"
21 "bnema.dev/ermite/internal/clientlimit"20 "bnema.dev/ermite/internal/clientlimit"
22 "bnema.dev/ermite/internal/config"21 "bnema.dev/ermite/internal/config"
23 "bnema.dev/ermite/internal/domain"22 "bnema.dev/ermite/internal/domain"
24 "bnema.dev/ermite/internal/ports"23 "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/usecase"
25 )25 )
26 26
27 // newWeb builds the web pages on the API of this process: the generated27 // newWeb builds the web pages on the API of this process: the generated
@@ -29,7 +29,7 @@ import (
29 //29 //
30 // The owner's session cookie turns a request into the owner's (web.Server30 // The owner's session cookie turns a request into the owner's (web.Server
31 // checks it with sessions); every other request is anonymous.31 // checks it with sessions); every other request is anonymous.
32-func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) {32+func newWeb(cfg config.Config, api *rpc.API, sessions *usecase.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) {
33 hc := api.InProcessClient()33 hc := api.InProcessClient()
34 srv, err := web.New(web.Config{34 srv, err := web.New(web.Config{
35 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),35 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
@@ -100,7 +100,7 @@ func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClie
100 // webSessions adapts the session use cases to ports.WebSessionService: a valid100 // webSessions adapts the session use cases to ports.WebSessionService: a valid
101 // token gives the context of the owner, whose principal the API calls of the101 // token gives the context of the owner, whose principal the API calls of the
102 // page carry (rpc.API.InProcessClient).102 // page carry (rpc.API.InProcessClient).
103-type webSessions struct{ *app.Sessions }103+type webSessions struct{ *usecase.Sessions }
104 104
105 var _ ports.WebSessionService = webSessions{}105 var _ ports.WebSessionService = webSessions{}
106 106
@@ -116,8 +116,8 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura
116 // that made the link may cut short. The session token carries its own116 // that made the link may cut short. The session token carries its own
117 // expiry, so a cookie that outlives it by under a second is harmless.117 // expiry, so a cookie that outlives it by under a second is harmless.
118 // The expiry is truncated to the second, hence the slack on the full TTL.118 // The expiry is truncated to the second, hence the slack on the full TTL.
119- lifetime := app.SessionTTL119+ lifetime := usecase.SessionTTL
120- if d := time.Until(expiresAt); d < app.SessionTTL-time.Second {120+ if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
121 lifetime = d.Round(time.Second)121 lifetime = d.Round(time.Second)
122 }122 }
123 if lifetime < time.Second {123 if lifetime < time.Second {
R cmd/ermite/web_commit_test.go → internal/app/web_commit_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
R cmd/ermite/web_commits_test.go → internal/app/web_commits_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
R cmd/ermite/web_events_test.go → internal/app/web_events_test.go +4 -4 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bufio"6 "bufio"
@@ -15,9 +15,9 @@ import (
15 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 17
18- "bnema.dev/ermite/internal/app"
19 "bnema.dev/ermite/internal/domain"18 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21 )21 )
22 22
23 // sseEvent is one event read from /-/events.23 // sseEvent is one event read from /-/events.
@@ -381,7 +381,7 @@ func TestWebEventsEndOnShutdown(t *testing.T) {
381 // one address cannot take every watch slot of the forge.381 // one address cannot take every watch slot of the forge.
382 func TestWebEventsLimitPerVisitor(t *testing.T) {382 func TestWebEventsLimitPerVisitor(t *testing.T) {
383 h := newHarness(t, harnessOpts{})383 h := newHarness(t, harnessOpts{})
384- for range app.MaxWatchersPerClient {384+ for range usecase.MaxWatchersPerClient {
385 h.sse("topic=home", "")385 h.sse("topic=home", "")
386 }386 }
387 req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil)387 req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil)
@@ -391,7 +391,7 @@ func TestWebEventsLimitPerVisitor(t *testing.T) {
391 defer func() { _ = resp.Body.Close() }()391 defer func() { _ = resp.Body.Close() }()
392 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)392 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
393 assert.NotEmpty(t, resp.Header.Get("Retry-After"))393 assert.NotEmpty(t, resp.Header.Get("Retry-After"))
394- assert.Equal(t, app.MaxWatchersPerClient, h.forge.EventLog.Watchers())394+ assert.Equal(t, usecase.MaxWatchersPerClient, h.forge.EventLog.Watchers())
395 }395 }
396 396
397 // A visitor that goes away frees its watch.397 // A visitor that goes away frees its watch.
R cmd/ermite/web_files_test.go → internal/app/web_files_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "bytes"6 "bytes"
R cmd/ermite/web_mr_test.go → internal/app/web_mr_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "html"6 "html"
R cmd/ermite/web_session_test.go → internal/app/web_session_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "context"6 "context"
R cmd/ermite/web_test.go → internal/app/web_test.go +2 -2 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package main3+package app
4 4
5 import (5 import (
6 "flag"6 "flag"
@@ -100,7 +100,7 @@ func golden(t *testing.T, name, got string) {
100 return100 return
101 }101 }
102 want, err := os.ReadFile(path)102 want, err := os.ReadFile(path)
103- require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update")103+ require.NoError(t, err, "run: go test ./internal/app -run TestWebGolden -update")
104 assert.Equal(t, string(want), got, name)104 assert.Equal(t, string(want), got, name)
105 }105 }
106 106
A internal/boundary/boundary_test.go +281 -0
@@ -0,0 +1,281 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package boundary_test enforces the import rules between the layers of
4+// ermite. The module root holds no Go package (the binary lives in cmd/ermite),
5+// so the check lives in this test-only package and walks the module from its
6+// root. It parses import clauses only; it never loads packages.
7+package boundary_test
8+
9+import (
10+ "fmt"
11+ "go/parser"
12+ "go/token"
13+ "io/fs"
14+ "path/filepath"
15+ "sort"
16+ "strconv"
17+ "strings"
18+ "testing"
19+)
20+
21+const modulePath = "bnema.dev/ermite"
22+
23+// moduleRoot is the module root relative to this package's directory.
24+const moduleRoot = "../.."
25+
26+type packageLayer string
27+
28+const (
29+ layerCommand packageLayer = "command"
30+ layerApp packageLayer = "app"
31+ layerUsecase packageLayer = "usecase"
32+ layerAdapter packageLayer = "adapter"
33+ layerClientLimit packageLayer = "clientlimit"
34+ layerConfig packageLayer = "config"
35+ layerPorts packageLayer = "ports"
36+ layerDomain packageLayer = "domain"
37+ layerGenerated packageLayer = "generated"
38+ layerScript packageLayer = "script"
39+ layerBoundary packageLayer = "boundary"
40+ layerTestSupport packageLayer = "test-support"
41+)
42+
43+// productionDependencies lists, per layer, the layers its production files
44+// may import.
45+//
46+// - cmd/ermite is the entry point: it only calls internal/app.
47+// - internal/app is the composition root and the CLI: it wires everything.
48+// - internal/usecase holds the use cases: domain and ports only.
49+// - adapters implement ports and never import use cases or app; the
50+// per-package rule in packageImportDenied also keeps them from importing
51+// each other.
52+// - internal/clientlimit is infrastructure shared by the HTTP adapters.
53+// - internal/config parses the environment into plain values.
54+var productionDependencies = map[packageLayer]map[packageLayer]bool{
55+ layerCommand: {layerApp: true},
56+ layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
57+ layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true},
58+ layerAdapter: {layerAdapter: true, layerClientLimit: true, layerPorts: true, layerDomain: true, layerGenerated: true},
59+ layerClientLimit: {layerPorts: true},
60+ layerConfig: {},
61+ layerPorts: {layerDomain: true},
62+ layerDomain: {layerDomain: true},
63+ layerGenerated: {layerGenerated: true},
64+ layerScript: {},
65+ layerBoundary: {},
66+ layerTestSupport: {layerPorts: true, layerDomain: true},
67+}
68+
69+func TestImportBoundaries(t *testing.T) {
70+ violations, packages := inspectRepositoryDependencies(t)
71+ if len(violations) != 0 {
72+ t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n"))
73+ }
74+ for _, want := range []string{"cmd/ermite", "internal/app", "internal/usecase", "internal/ports", "internal/domain"} {
75+ if _, ok := packages[modulePath+"/"+want]; !ok {
76+ t.Fatalf("package inventory misses %s: the walk is not reading the module", want)
77+ }
78+ }
79+}
80+
81+func TestImportBoundaryFixtures(t *testing.T) {
82+ p := func(s string) string { return modulePath + "/" + s }
83+ tests := []struct {
84+ name, source, target string
85+ testFile bool
86+ want bool
87+ }{
88+ {"command accepts app", p("cmd/ermite"), p("internal/app"), false, true},
89+ {"command rejects usecase", p("cmd/ermite"), p("internal/usecase"), false, false},
90+ {"command rejects adapter", p("cmd/ermite"), p("internal/adapters/web"), false, false},
91+ {"command rejects domain", p("cmd/ermite"), p("internal/domain"), false, false},
92+ {"app accepts usecase", p("internal/app"), p("internal/usecase"), false, true},
93+ {"app accepts adapter", p("internal/app"), p("internal/adapters/gitcli"), false, true},
94+ {"app accepts config", p("internal/app"), p("internal/config"), false, true},
95+ {"app accepts clientlimit", p("internal/app"), p("internal/clientlimit"), false, true},
96+ {"app rejects command", p("internal/app"), p("cmd/ermite"), false, false},
97+ {"app test may import command", p("internal/app"), p("cmd/ermite"), true, true},
98+ {"usecase accepts domain", p("internal/usecase"), p("internal/domain"), false, true},
99+ {"usecase accepts ports", p("internal/usecase"), p("internal/ports"), false, true},
100+ {"usecase rejects adapter", p("internal/usecase"), p("internal/adapters/kvstore"), false, false},
101+ {"usecase rejects app", p("internal/usecase"), p("internal/app"), false, false},
102+ {"usecase rejects config", p("internal/usecase"), p("internal/config"), false, false},
103+ {"usecase rejects clientlimit", p("internal/usecase"), p("internal/clientlimit"), false, false},
104+ {"usecase test may import adapter", p("internal/usecase"), p("internal/adapters/kvstore"), true, true},
105+ {"adapter accepts ports", p("internal/adapters/rpc"), p("internal/ports"), false, true},
106+ {"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true},
107+ {"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true},
108+ {"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true},
109+ {"adapter production rejects generated mocks", p("internal/adapters/web"), p("internal/adapters/web/mocks"), false, false},
110+ {"adapter test may import generated mocks", p("internal/adapters/web"), p("internal/adapters/web/mocks"), true, true},
111+ {"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false},
112+ {"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false},
113+ {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false},
114+ {"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false},
115+ {"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true},
116+ {"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true},
117+ {"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false},
118+ {"config rejects domain", p("internal/config"), p("internal/domain"), false, false},
119+ {"ports accepts domain", p("internal/ports"), p("internal/domain"), false, true},
120+ {"ports rejects usecase", p("internal/ports"), p("internal/usecase"), false, false},
121+ {"ports rejects adapter", p("internal/ports"), p("internal/adapters/web"), false, false},
122+ {"ports mocks accept ports", p("internal/ports/mocks"), p("internal/ports"), false, true},
123+ {"ports mocks reject usecase", p("internal/ports/mocks"), p("internal/usecase"), false, false},
124+ {"reader conformance accepts ports", p("internal/readertest"), p("internal/ports"), false, true},
125+ {"reader conformance rejects adapter", p("internal/readertest"), p("internal/adapters/gitcli"), false, false},
126+ {"domain accepts domain", p("internal/domain"), p("internal/domain/x"), false, true},
127+ {"domain rejects ports", p("internal/domain"), p("internal/ports"), false, false},
128+ {"domain test may import anything", p("internal/domain"), p("internal/ports"), true, true},
129+ }
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ got, err := dependencyAllowed(tt.source, tt.target, tt.testFile)
133+ if err != nil {
134+ t.Fatalf("dependencyAllowed: %v", err)
135+ }
136+ if got != tt.want {
137+ t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want)
138+ }
139+ })
140+ }
141+}
142+
143+func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) {
144+ t.Helper()
145+ var violations []string
146+ packages := make(map[string]struct{})
147+ fset := token.NewFileSet()
148+ err := filepath.WalkDir(moduleRoot, func(path string, entry fs.DirEntry, walkErr error) error {
149+ if walkErr != nil {
150+ return walkErr
151+ }
152+ if entry.IsDir() {
153+ name := entry.Name()
154+ if name == ".git" || name == ".worktrees" || name == "vendor" || name == "testdata" || name == "node_modules" {
155+ return filepath.SkipDir
156+ }
157+ return nil
158+ }
159+ if !strings.HasSuffix(path, ".go") {
160+ return nil
161+ }
162+ rel, err := filepath.Rel(moduleRoot, path)
163+ if err != nil {
164+ return err
165+ }
166+ source := importPathForFile(rel)
167+ packages[source] = struct{}{}
168+ if _, err := classifyPackage(source); err != nil {
169+ violations = append(violations, rel+": "+err.Error())
170+ return nil
171+ }
172+ file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
173+ if err != nil {
174+ return err
175+ }
176+ testFile := strings.HasSuffix(path, "_test.go")
177+ for _, spec := range file.Imports {
178+ target, err := strconv.Unquote(spec.Path.Value)
179+ if err != nil || (target != modulePath && !strings.HasPrefix(target, modulePath+"/")) {
180+ continue
181+ }
182+ allowed, err := dependencyAllowed(source, target, testFile)
183+ if err != nil {
184+ violations = append(violations, fmt.Sprintf("%s: %v", rel, err))
185+ continue
186+ }
187+ if !allowed {
188+ violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target))
189+ }
190+ }
191+ return nil
192+ })
193+ if err != nil {
194+ t.Fatalf("walking module: %v", err)
195+ }
196+ sort.Strings(violations)
197+ return violations, packages
198+}
199+
200+// dependencyAllowed reports whether a file of package source may import
201+// target. Test files are exempt, so that composition tests can wire adapters
202+// and use cases together; their packages must still be classified.
203+func dependencyAllowed(source, target string, testFile bool) (bool, error) {
204+ sourceLayer, err := classifyPackage(source)
205+ if err != nil {
206+ return false, err
207+ }
208+ targetLayer, err := classifyPackage(target)
209+ if err != nil {
210+ return false, err
211+ }
212+ if testFile {
213+ return true, nil
214+ }
215+ if packageImportDenied(source, target) {
216+ return false, nil
217+ }
218+ return productionDependencies[sourceLayer][targetLayer], nil
219+}
220+
221+// packageImportDenied narrows the layer rules to packages: an adapter may
222+// import only packages under its own directory (its mocks, for instance),
223+// never a sibling adapter.
224+func packageImportDenied(source, target string) bool {
225+ const adapters = modulePath + "/internal/adapters/"
226+ if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) {
227+ return adapterRoot(source) != adapterRoot(target)
228+ }
229+ return false
230+}
231+
232+// adapterRoot is the first path element under internal/adapters.
233+func adapterRoot(path string) string {
234+ rest := strings.TrimPrefix(path, modulePath+"/internal/adapters/")
235+ root, _, _ := strings.Cut(rest, "/")
236+ return root
237+}
238+
239+func under(path, root string) bool {
240+ return path == root || strings.HasPrefix(path, root+"/")
241+}
242+
243+func classifyPackage(path string) (packageLayer, error) {
244+ p := func(s string) string { return modulePath + "/" + s }
245+ switch {
246+ case under(path, p("cmd")):
247+ return layerCommand, nil
248+ case under(path, p("scripts")):
249+ return layerScript, nil
250+ case under(path, p("internal/boundary")):
251+ return layerBoundary, nil
252+ case under(path, p("internal/app")):
253+ return layerApp, nil
254+ case under(path, p("internal/usecase")):
255+ return layerUsecase, nil
256+ case path == p("internal/ports/mocks"), under(path, p("internal/adapters/web/mocks")), under(path, p("internal/readertest")):
257+ return layerTestSupport, nil
258+ case under(path, p("internal/adapters")):
259+ return layerAdapter, nil
260+ case under(path, p("internal/clientlimit")):
261+ return layerClientLimit, nil
262+ case under(path, p("internal/config")):
263+ return layerConfig, nil
264+ case path == p("internal/ports"):
265+ return layerPorts, nil
266+ case under(path, p("internal/domain")):
267+ return layerDomain, nil
268+ case under(path, p("gen")):
269+ return layerGenerated, nil
270+ default:
271+ return "", fmt.Errorf("unclassified package %q", path)
272+ }
273+}
274+
275+func importPathForFile(rel string) string {
276+ dir := filepath.ToSlash(filepath.Dir(rel))
277+ if dir == "." {
278+ return modulePath
279+ }
280+ return modulePath + "/" + dir
281+}
M internal/config/config.go +1 -1
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3 // Package config parses and validates the server configuration from the3 // Package config parses and validates the server configuration from the
4-// environment. It is an entrypoint helper: only cmd imports it, and it never4+// environment. It is a composition-root helper: only internal/app imports it, and it never
5 // reads the process environment itself, the caller passes a lookup function.5 // reads the process environment itself, the caller passes a lookup function.
6 package config6 package config
7 7
M internal/ports/ports.go +3 -3
@@ -5,9 +5,9 @@
5 //5 //
6 // Outbound ports (ports.go) are what the use cases need from the outside6 // Outbound ports (ports.go) are what the use cases need from the outside
7 // world: adapters implement them. Inbound ports (services.go) are the use7 // world: adapters implement them. Inbound ports (services.go) are the use
8-// cases as the adapters that serve requests see them: the types of package app8+// cases as the adapters that serve requests see them: the types of package usecase
9-// implement them. Adapters and app depend on this package and on domain, never9+// implement them. Adapters and use cases depend on this package and on domain,
10-// on each other; cmd wires the implementations. All the mocks are generated in10+// never on each other; internal/app wires the implementations. All the mocks are generated in
11 // ports/mocks.11 // ports/mocks.
12 package ports12 package ports
13 13
M internal/ports/services.go +16 -16
@@ -12,31 +12,31 @@ import (
12 )12 )
13 13
14 // The interfaces below are the use cases as the inbound adapters (the API,14 // The interfaces below are the use cases as the inbound adapters (the API,
15-// Git over HTTP, the web) and the entrypoint see them. The types of package15+// Git over HTTP, the web) and the composition root see them. The types of package
16-// app implement them, each assertion being made in app; the mocks in16+// usecase implement them, each assertion being made in usecase; the mocks in
17 // ports/mocks stand in for them in the tests of the adapters.17 // ports/mocks stand in for them in the tests of the adapters.
18 18
19 // AuthService turns the secret of a request into a principal. It returns19 // AuthService turns the secret of a request into a principal. It returns
20-// domain.ErrUnauthenticated for a secret that is not valid. *app.Auth20+// domain.ErrUnauthenticated for a secret that is not valid. *usecase.Auth
21 // implements it.21 // implements it.
22 type AuthService interface {22 type AuthService interface {
23 Authenticate(ctx context.Context, secret string) (domain.Principal, error)23 Authenticate(ctx context.Context, secret string) (domain.Principal, error)
24 }24 }
25 25
26 // Revalidator confirms that a principal obtained earlier is still26 // Revalidator confirms that a principal obtained earlier is still
27-// authorized. *app.Auth implements it.27+// authorized. *usecase.Auth implements it.
28 type Revalidator interface {28 type Revalidator interface {
29 Revalidate(ctx context.Context, p domain.Principal) error29 Revalidate(ctx context.Context, p domain.Principal) error
30 }30 }
31 31
32 // RepoLookup is the read side of a single repository. A repository the32 // RepoLookup is the read side of a single repository. A repository the
33-// principal may not read is domain.ErrNotFound. *app.Repos implements it, as33+// principal may not read is domain.ErrNotFound. *usecase.Repos implements it, as
34 // part of RepoService.34 // part of RepoService.
35 type RepoLookup interface {35 type RepoLookup interface {
36 Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)36 Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)
37 }37 }
38 38
39-// RepoService is the repository use cases the API serves. *app.Repos39+// RepoService is the repository use cases the API serves. *usecase.Repos
40 // implements it.40 // implements it.
41 type RepoService interface {41 type RepoService interface {
42 RepoLookup42 RepoLookup
@@ -47,7 +47,7 @@ type RepoService interface {
47 Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error47 Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error
48 }48 }
49 49
50-// TokenService is the access token use cases the API serves. *app.Tokens50+// TokenService is the access token use cases the API serves. *usecase.Tokens
51 // implements it.51 // implements it.
52 type TokenService interface {52 type TokenService interface {
53 Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error)53 Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error)
@@ -55,7 +55,7 @@ type TokenService interface {
55 Revoke(ctx context.Context, p domain.Principal, id string) error55 Revoke(ctx context.Context, p domain.Principal, id string) error
56 }56 }
57 57
58-// BrowseService is the read side of a repository's content. *app.Browse58+// BrowseService is the read side of a repository's content. *usecase.Browse
59 // implements it.59 // implements it.
60 type BrowseService interface {60 type BrowseService interface {
61 ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error)61 ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error)
@@ -66,12 +66,12 @@ type BrowseService interface {
66 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)66 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
67 }67 }
68 68
69-// EventService streams the events of the forge. *app.Events implements it.69+// EventService streams the events of the forge. *usecase.Events implements it.
70 type EventService interface {70 type EventService interface {
71 Watch(ctx context.Context, p domain.Principal, params WatchParams, send func(WatchEvent) error) error71 Watch(ctx context.Context, p domain.Principal, params WatchParams, send func(WatchEvent) error) error
72 }72 }
73 73
74-// MergeRequestService is the merge request use cases. *app.MergeRequests74+// MergeRequestService is the merge request use cases. *usecase.MergeRequests
75 // implements it.75 // implements it.
76 type MergeRequestService interface {76 type MergeRequestService interface {
77 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)77 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
@@ -85,13 +85,13 @@ type MergeRequestService interface {
85 }85 }
86 86
87 // SigningKeyService tells which key signs the commits of the forge.87 // SigningKeyService tells which key signs the commits of the forge.
88-// *app.SigningKey implements it.88+// *usecase.SigningKey implements it.
89 type SigningKeyService interface {89 type SigningKeyService interface {
90 Info() domain.SigningKeyInfo90 Info() domain.SigningKeyInfo
91 }91 }
92 92
93 // SessionService is the owner's web session use cases the API serves.93 // SessionService is the owner's web session use cases the API serves.
94-// *app.Sessions implements it.94+// *usecase.Sessions implements it.
95 type SessionService interface {95 type SessionService interface {
96 CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error)96 CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error)
97 SignOutEverywhere(ctx context.Context, p domain.Principal) error97 SignOutEverywhere(ctx context.Context, p domain.Principal) error
@@ -102,7 +102,7 @@ type SessionService interface {
102 var ErrLinkRejected = errors.New("login link rejected")102 var ErrLinkRejected = errors.New("login link rejected")
103 103
104 // WebSessionService is the session side of the use cases, as the web needs104 // WebSessionService is the session side of the use cases, as the web needs
105-// it. The entrypoint implements it over *app.Sessions.105+// it. The composition root implements it over *usecase.Sessions.
106 type WebSessionService interface {106 type WebSessionService interface {
107 // LoginLinkValid reports whether the link is pending, without using it.107 // LoginLinkValid reports whether the link is pending, without using it.
108 LoginLinkValid(nonce string) bool108 LoginLinkValid(nonce string) bool
@@ -120,14 +120,14 @@ type WebSessionService interface {
120 // for an anonymous caller, domain.ErrNotFound for a repository the caller may120 // for an anonymous caller, domain.ErrNotFound for a repository the caller may
121 // not write and cannot see (or that is missing and may not be created),121 // not write and cannot see (or that is missing and may not be created),
122 // domain.ErrForbidden for one it can see but not write. With create, a122 // domain.ErrForbidden for one it can see but not write. With create, a
123-// missing repository the principal may write is created. *app.Pushes123+// missing repository the principal may write is created. *usecase.Pushes
124 // implements it.124 // implements it.
125 type PushService interface {125 type PushService interface {
126 Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error126 Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error
127 }127 }
128 128
129 // PushHookService is what the hook socket asks of the use cases.129 // PushHookService is what the hook socket asks of the use cases.
130-// *app.Pushes implements it.130+// *usecase.Pushes implements it.
131 type PushHookService interface {131 type PushHookService interface {
132 // Check decides a push before Git applies it; an error matching132 // Check decides a push before Git applies it; an error matching
133 // domain.ErrRejected refuses it, any other error refuses it as a failure.133 // domain.ErrRejected refuses it, any other error refuses it as a failure.
@@ -136,7 +136,7 @@ type PushHookService interface {
136 Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error136 Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
137 }137 }
138 138
139-// MaintenanceScheduler queues repository maintenance. *app.Maintainer139+// MaintenanceScheduler queues repository maintenance. *usecase.Maintainer
140 // implements it.140 // implements it.
141 type MaintenanceScheduler interface {141 type MaintenanceScheduler interface {
142 Schedule(ctx context.Context, name domain.RepoName)142 Schedule(ctx context.Context, name domain.RepoName)
R internal/app/access.go → internal/usecase/access.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "fmt"6 "fmt"
R internal/app/browse.go → internal/usecase/browse.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/browse_test.go → internal/usecase/browse_test.go +20 -20 renamed, 91% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -15,22 +15,22 @@ import (
15 "github.com/stretchr/testify/mock"15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 17
18- "bnema.dev/ermite/internal/app"
19 "bnema.dev/ermite/internal/domain"18 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
21 "bnema.dev/ermite/internal/ports/mocks"20 "bnema.dev/ermite/internal/ports/mocks"
21+ "bnema.dev/ermite/internal/usecase"
22 )22 )
23 23
24 type browseFixture struct {24 type browseFixture struct {
25 meta *mocks.RepoMetaStore25 meta *mocks.RepoMetaStore
26 reader *mocks.RepoReader26 reader *mocks.RepoReader
27- browse *app.Browse27+ browse *usecase.Browse
28 }28 }
29 29
30 func newBrowseFixture(t *testing.T) *browseFixture {30 func newBrowseFixture(t *testing.T) *browseFixture {
31 t.Helper()31 t.Helper()
32 f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)}32 f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)}
33- f.browse = app.NewBrowse(f.meta, f.reader)33+ f.browse = usecase.NewBrowse(f.meta, f.reader)
34 return f34 return f
35 }35 }
36 36
@@ -46,7 +46,7 @@ func oid(c string) domain.ObjectID {
46 // The reader expectation of each is what must (not) run.46 // The reader expectation of each is what must (not) run.
47 type browseOp struct {47 type browseOp struct {
48 name string48 name string
49- call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error49+ call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
50 // expect registers the reader call a permitted caller makes.50 // expect registers the reader call a permitted caller makes.
51 expect func(r *mocks.RepoReader)51 expect func(r *mocks.RepoReader)
52 }52 }
@@ -54,29 +54,29 @@ type browseOp struct {
54 func browseOps() []browseOp {54 func browseOps() []browseOp {
55 root := domain.TreePath{}55 root := domain.TreePath{}
56 return []browseOp{56 return []browseOp{
57- {"ListRefs", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {57+ {"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
58 _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{})58 _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{})
59 return err59 return err
60 }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }},60 }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }},
61- {"ResolveRevision", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {61+ {"ResolveRevision", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
62 _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})62 _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
63 return err63 return err
64 }, func(r *mocks.RepoReader) {64 }, func(r *mocks.RepoReader) {
65 r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)65 r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
66 }},66 }},
67- {"ResolveRefPath", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {67+ {"ResolveRefPath", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
68 _, err := b.ResolveRefPath(ctx, p, repo, "main/x")68 _, err := b.ResolveRefPath(ctx, p, repo, "main/x")
69 return err69 return err
70 }, func(r *mocks.RepoReader) {70 }, func(r *mocks.RepoReader) {
71 r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)71 r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
72 }},72 }},
73- {"GetTree", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {73+ {"GetTree", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
74 _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)74 _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
75 return err75 return err
76 }, func(r *mocks.RepoReader) {76 }, func(r *mocks.RepoReader) {
77 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)77 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
78 }},78 }},
79- {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {79+ {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
80 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)80 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
81 if rc != nil {81 if rc != nil {
82 _ = rc.Close()82 _ = rc.Close()
@@ -86,13 +86,13 @@ func browseOps() []browseOp {
86 r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).86 r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
87 Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)87 Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
88 }},88 }},
89- {"ListCommits", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {89+ {"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
90 _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{})90 _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{})
91 return err91 return err
92 }, func(r *mocks.RepoReader) {92 }, func(r *mocks.RepoReader) {
93 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)93 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
94 }},94 }},
95- {"GetCommit", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {95+ {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
96 _, err := b.GetCommit(ctx, p, repo, domain.Revision{})96 _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
97 return err97 return err
98 }, func(r *mocks.RepoReader) {98 }, func(r *mocks.RepoReader) {
@@ -201,7 +201,7 @@ func TestListRefsPagingAndKinds(t *testing.T) {
201 }201 }
202 return out202 return out
203 }203 }
204- setup := func(t *testing.T, refs []domain.Ref) *app.Browse {204+ setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse {
205 f := newBrowseFixture(t)205 f := newBrowseFixture(t)
206 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)206 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
207 f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)207 f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
@@ -247,19 +247,19 @@ func TestListRefsPagingAndKinds(t *testing.T) {
247 t.Run("default and maximum page size", func(t *testing.T) {247 t.Run("default and maximum page size", func(t *testing.T) {
248 var many []domain.Ref248 var many []domain.Ref
249 many = append(many, ref(domain.RefKindHead, "HEAD"))249 many = append(many, ref(domain.RefKindHead, "HEAD"))
250- for i := range app.MaxRefPageSize + 100 {250+ for i := range usecase.MaxRefPageSize + 100 {
251 many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))251 many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))
252 }252 }
253 b := setup(t, many)253 b := setup(t, many)
254 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{})254 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{})
255 require.NoError(t, err)255 require.NoError(t, err)
256- assert.Len(t, page.Refs, app.DefaultPageSize)256+ assert.Len(t, page.Refs, usecase.DefaultPageSize)
257 assert.NotEmpty(t, page.NextPageToken)257 assert.NotEmpty(t, page.NextPageToken)
258 258
259 b = setup(t, many)259 b = setup(t, many)
260 page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000})260 page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000})
261 require.NoError(t, err)261 require.NoError(t, err)
262- assert.Len(t, page.Refs, app.MaxRefPageSize, "a page holds up to MaxRefPageSize refs")262+ assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs")
263 assert.NotEmpty(t, page.NextPageToken)263 assert.NotEmpty(t, page.NextPageToken)
264 })264 })
265 265
@@ -354,11 +354,11 @@ func TestListCommitsPaging(t *testing.T) {
354 return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))354 return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))
355 }355 }
356 356
357- f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == app.MaxCommitSkip })).357+ f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })).
358 Return(ports.CommitPage{Head: oid("a")}, nil).Once()358 Return(ports.CommitPage{Head: oid("a")}, nil).Once()
359- _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip)})359+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)})
360 require.NoError(t, err, "the cap itself is allowed")360 require.NoError(t, err, "the cap itself is allowed")
361- _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip + 1)})361+ _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)})
362 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)362 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
363 })363 })
364 364
@@ -366,7 +366,7 @@ func TestListCommitsPaging(t *testing.T) {
366 t.Parallel()366 t.Parallel()
367 f := newBrowseFixture(t)367 f := newBrowseFixture(t)
368 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)368 f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2)
369- f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: app.DefaultPageSize}).Return(ports.CommitPage{}, nil)369+ f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil)
370 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)370 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
371 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})371 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
372 require.NoError(t, err)372 require.NoError(t, err)
R internal/app/browsecache.go → internal/usecase/browsecache.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "container/list"6 "container/list"
R internal/app/browsecache_test.go → internal/usecase/browsecache_test.go +5 -5 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -13,10 +13,10 @@ import (
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16- "bnema.dev/ermite/internal/app"
17 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/ports/mocks"18 "bnema.dev/ermite/internal/ports/mocks"
19+ "bnema.dev/ermite/internal/usecase"
20 )20 )
21 21
22 func treePath(t *testing.T, s string) domain.TreePath {22 func treePath(t *testing.T, s string) domain.TreePath {
@@ -118,19 +118,19 @@ func TestHistoryCacheNotServedAcrossRecreate(t *testing.T) {
118 118
119 tests := []struct {119 tests := []struct {
120 name string120 name string
121- call func(b *app.Browse) error121+ call func(b *usecase.Browse) error
122 // expect registers the reader call of the old repository (answers)122 // expect registers the reader call of the old repository (answers)
123 // and of the new one (not found).123 // and of the new one (not found).
124 expect func(r *mocks.RepoReader)124 expect func(r *mocks.RepoReader)
125 }{125 }{
126- {"tree with last commits", func(b *app.Browse) error {126+ {"tree with last commits", func(b *usecase.Browse) error {
127 _, err := b.GetTree(ctx, domain.Anonymous(), n, rev, dir, true)127 _, err := b.GetTree(ctx, domain.Anonymous(), n, rev, dir, true)
128 return err128 return err
129 }, func(r *mocks.RepoReader) {129 }, func(r *mocks.RepoReader) {
130 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(treeWithLast("b"), nil).Once()130 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(treeWithLast("b"), nil).Once()
131 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(domain.Tree{}, domain.ErrNotFound).Once()131 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(domain.Tree{}, domain.ErrNotFound).Once()
132 }},132 }},
133- {"last change of a path", func(b *app.Browse) error {133+ {"last change of a path", func(b *usecase.Browse) error {
134 _, err := b.ListCommits(ctx, domain.Anonymous(), n, ports.ListCommitsParams{Revision: rev, Path: dir, PageSize: 1})134 _, err := b.ListCommits(ctx, domain.Anonymous(), n, ports.ListCommitsParams{Revision: rev, Path: dir, PageSize: 1})
135 return err135 return err
136 }, func(r *mocks.RepoReader) {136 }, func(r *mocks.RepoReader) {
R internal/app/events.go → internal/usecase/events.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
A internal/usecase/events_test.go +883 -0
@@ -0,0 +1,883 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "testing/synctest"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+// eventRig is an Events use case fed by a channel the test writes to.
26+type eventRig struct {
27+ t *testing.T
28+ meta *mocks.RepoMetaStore
29+ git *mocks.GitRepos
30+ feed chan domain.Event
31+ auth *mocks.Revalidator
32+ revoked sync.Map // token IDs revoked by the test
33+ revalidations atomic.Int32 // calls to auth.Revalidate
34+ events *usecase.Events
35+}
36+
37+func newEventRig(t *testing.T, size int) *eventRig {
38+ t.Helper()
39+ return newEventRigConfig(t, usecase.EventsConfig{Size: size})
40+}
41+
42+func newEventRigConfig(t *testing.T, cfg usecase.EventsConfig) *eventRig {
43+ t.Helper()
44+ r := &eventRig{
45+ t: t,
46+ meta: mocks.NewRepoMetaStore(t),
47+ git: mocks.NewGitRepos(t),
48+ feed: make(chan domain.Event, 4096),
49+ auth: mocks.NewRevalidator(t),
50+ }
51+ // Tokens are valid until the test revokes them.
52+ r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error {
53+ r.revalidations.Add(1)
54+ if _, gone := r.revoked.Load(p.TokenID()); gone {
55+ return domain.ErrUnauthenticated
56+ }
57+ return nil
58+ }).Maybe()
59+ cfg.Auth = r.auth
60+ bus := mocks.NewEventBus(t)
61+ bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
62+ go func() { <-ctx.Done(); close(r.feed) }()
63+ return r.feed
64+ })
65+ clock := mocks.NewClock(t)
66+ clock.EXPECT().Now().Return(now).Maybe()
67+ r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe()
68+ repos := usecase.NewRepos(r.meta, r.git, bus, clock)
69+ r.events = usecase.NewEvents(context.Background(), repos, bus, clock, cfg)
70+ t.Cleanup(func() { r.events.Close(context.Background()) })
71+ return r
72+}
73+
74+// repo declares a repository the rig knows.
75+func (r *eventRig) repo(s string, vis domain.Visibility) domain.RepoName {
76+ n := name(r.t, s)
77+ r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil).Maybe()
78+ return n
79+}
80+
81+// pushEvent builds the PushEvent the Pushes use case would publish now: it
82+// carries the repository as the store answers at this moment.
83+func (r *eventRig) pushEvent(n domain.RepoName, oid, ref string) domain.PushEvent {
84+ repo, err := r.meta.Get(context.Background(), n)
85+ require.NoError(r.t, err)
86+ return domain.PushEvent{
87+ Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: ref}}, At: now,
88+ Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
89+ }
90+}
91+
92+func (r *eventRig) push(n domain.RepoName, i int) {
93+ r.feed <- r.pushEvent(n, fmt.Sprintf("%040x", i+1), "refs/heads/main")
94+}
95+
96+// watcher is a running Watch.
97+type watcher struct {
98+ got chan ports.WatchEvent
99+ done chan error
100+}
101+
102+func (r *eventRig) watch(ctx context.Context, p domain.Principal, params ports.WatchParams) *watcher {
103+ w := &watcher{got: make(chan ports.WatchEvent, 4096), done: make(chan error, 1)}
104+ go func() {
105+ w.done <- r.events.Watch(ctx, p, params, func(e ports.WatchEvent) error {
106+ w.got <- e
107+ return nil
108+ })
109+ }()
110+ return w
111+}
112+
113+func (w *watcher) next(t *testing.T) ports.WatchEvent {
114+ t.Helper()
115+ synctest.Wait() // everything the event can reach has run
116+ select {
117+ case e := <-w.got:
118+ return e
119+ case err := <-w.done:
120+ t.Fatalf("watch ended: %v", err)
121+ default:
122+ t.Fatal("no event")
123+ }
124+ return ports.WatchEvent{}
125+}
126+
127+// sentinelOID marks the push events that only prove the ones before them were
128+// handled.
129+var sentinelOID = fmt.Sprintf("%040x", 0xfeed)
130+
131+// quiet proves that the watcher got nothing since its last read: it pushes an
132+// event on n, which the watcher may see, and requires it to be the next one.
133+// Events reach a watcher in order, so anything sent before would show up first.
134+func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) {
135+ t.Helper()
136+ r.feed <- r.pushEvent(n, sentinelOID, "refs/heads/sentinel")
137+ e := w.next(t)
138+ push, ok := e.Event.(domain.PushEvent)
139+ if !ok || len(push.Updates) != 1 || push.Updates[0].New != sentinelOID {
140+ t.Fatalf("expected nothing before the sentinel, got %+v", e)
141+ }
142+}
143+
144+func TestWatchLiveAndFiltered(t *testing.T) {
145+ t.Parallel()
146+ synctest.Test(t, func(t *testing.T) {
147+ r := newEventRig(t, 0)
148+ pub := r.repo("pub", domain.VisibilityPublic)
149+ priv := r.repo("priv", domain.VisibilityPrivate)
150+ ctx, cancel := context.WithCancel(context.Background())
151+ defer cancel()
152+
153+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
154+ root := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"home"}})
155+ scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), ports.WatchParams{})
156+ onlyPriv := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
157+ synctest.Wait()
158+ require.Equal(t, 4, r.events.Watchers())
159+
160+ r.push(priv, 1)
161+ r.push(pub, 2)
162+
163+ // Anonymous and a token limited to another repository never see priv.
164+ for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
165+ e := w.next(t)
166+ assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
167+ w.quiet(t, r, pub)
168+ }
169+ first, second := root.next(t), root.next(t)
170+ assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
171+ assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)
172+ assert.NotEmpty(t, first.ID)
173+ assert.NotEqual(t, first.ID, second.ID)
174+
175+ assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
176+ onlyPriv.quiet(t, r, priv)
177+ })
178+}
179+
180+func TestWatchRechecksAccessPerEvent(t *testing.T) {
181+ t.Parallel()
182+ synctest.Test(t, func(t *testing.T) {
183+ r := newEventRig(t, 0)
184+ n := name(t, "flip")
185+ vis := domain.VisibilityPublic
186+ var mu sync.Mutex
187+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
188+ mu.Lock()
189+ defer mu.Unlock()
190+ return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil
191+ })
192+ ctx, cancel := context.WithCancel(context.Background())
193+ defer cancel()
194+ w := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
195+ synctest.Wait()
196+ require.Equal(t, 1, r.events.Watchers())
197+
198+ r.push(n, 1)
199+ w.next(t)
200+
201+ mu.Lock()
202+ vis = domain.VisibilityPrivate
203+ mu.Unlock()
204+ r.push(n, 2)
205+ w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
206+ })
207+}
208+
209+func TestWatchResumeAndGap(t *testing.T) {
210+ t.Parallel()
211+ const size = 8
212+ // Each subtest gets its own bubble and a fresh run of 20 events recorded
213+ // through a first watcher; the ring then holds the last 8.
214+ setup := func(t *testing.T) (r *eventRig, n domain.RepoName, ctx context.Context, ids []string) {
215+ r = newEventRig(t, size)
216+ n = r.repo("pub", domain.VisibilityPublic)
217+ ctx, cancel := context.WithCancel(context.Background())
218+ t.Cleanup(cancel)
219+
220+ first := r.watch(ctx, domain.Root(), ports.WatchParams{})
221+ synctest.Wait()
222+ require.Equal(t, 1, r.events.Watchers())
223+ // One at a time: this watcher must not fall behind the small ring.
224+ for i := range 20 {
225+ r.push(n, i)
226+ ids = append(ids, first.next(t).ID)
227+ }
228+ require.Len(t, ids, 20)
229+ return r, n, ctx, ids
230+ }
231+
232+ t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
233+ t.Parallel()
234+ synctest.Test(t, func(t *testing.T) {
235+ r, _, ctx, ids := setup(t)
236+ // 20 events were taken, the buffer holds the last 8: ids[12] is the
237+ // oldest still present; ids[0] is long gone.
238+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[0]})
239+ gap := w.next(t)
240+ assert.True(t, gap.Gap)
241+ assert.Empty(t, gap.ID)
242+ oldest := w.next(t)
243+ assert.False(t, oldest.Gap)
244+ assert.Equal(t, ids[12], oldest.ID)
245+ })
246+ })
247+
248+ t.Run("an unknown id gives a gap", func(t *testing.T) {
249+ t.Parallel()
250+ synctest.Test(t, func(t *testing.T) {
251+ r, _, ctx, _ := setup(t)
252+ for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {
253+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: id})
254+ assert.True(t, w.next(t).Gap, id)
255+ }
256+ })
257+ })
258+
259+ t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) {
260+ t.Parallel()
261+ synctest.Test(t, func(t *testing.T) {
262+ r, _, ctx, _ := setup(t)
263+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: "otherboot-999"})
264+ assert.True(t, w.next(t).Gap)
265+ })
266+ })
267+
268+ t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
269+ t.Parallel()
270+ synctest.Test(t, func(t *testing.T) {
271+ r, n, ctx, ids := setup(t)
272+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[16]})
273+ for _, want := range ids[17:] {
274+ e := w.next(t)
275+ assert.False(t, e.Gap)
276+ assert.Equal(t, want, e.ID)
277+ }
278+ // Nothing else was replayed: the next event is the one pushed now.
279+ r.push(n, 98)
280+ live := w.next(t)
281+ assert.False(t, live.Gap)
282+ assert.NotContains(t, ids, live.ID)
283+ })
284+ })
285+
286+ t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) {
287+ t.Parallel()
288+ synctest.Test(t, func(t *testing.T) {
289+ r, n, ctx, ids := setup(t)
290+ w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[len(ids)-1]})
291+ r.push(n, 99)
292+ e := w.next(t)
293+ assert.False(t, e.Gap)
294+ assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")
295+ })
296+ })
297+}
298+
299+func TestWatchTopicErrors(t *testing.T) {
300+ t.Parallel()
301+ r := newEventRig(t, 0)
302+ r.repo("pub", domain.VisibilityPublic)
303+ r.repo("priv", domain.VisibilityPrivate)
304+ r.meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound).Maybe()
305+ noop := func(ports.WatchEvent) error { return nil }
306+ ctx := context.Background()
307+
308+ for topic, want := range map[string]error{
309+ "nope": domain.ErrInvalid,
310+ "repo:Bad": domain.ErrInvalidName,
311+ "repo:": domain.ErrInvalidName,
312+ "repo:gone": domain.ErrNotFound,
313+ "repo:priv": domain.ErrNotFound, // hidden from anonymous
314+ "repo:pub:foo": domain.ErrInvalidName,
315+ } {
316+ err := r.events.Watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{topic}}, noop)
317+ assert.ErrorIs(t, err, want, topic)
318+ }
319+}
320+
321+func TestWatchEndsCleanly(t *testing.T) {
322+ t.Parallel()
323+ t.Run("on context cancellation", func(t *testing.T) {
324+ t.Parallel()
325+ synctest.Test(t, func(t *testing.T) {
326+ r := newEventRig(t, 0)
327+ ctx, cancel := context.WithCancel(context.Background())
328+ w := r.watch(ctx, domain.Root(), ports.WatchParams{})
329+ synctest.Wait()
330+ require.Equal(t, 1, r.events.Watchers())
331+ cancel()
332+ synctest.Wait()
333+ select {
334+ case err := <-w.done:
335+ assert.NoError(t, err)
336+ default:
337+ t.Fatal("watch did not end")
338+ }
339+ assert.Zero(t, r.events.Watchers())
340+ })
341+ })
342+
343+ t.Run("on Close", func(t *testing.T) {
344+ t.Parallel()
345+ synctest.Test(t, func(t *testing.T) {
346+ r := newEventRig(t, 0)
347+ w := r.watch(context.Background(), domain.Root(), ports.WatchParams{})
348+ synctest.Wait()
349+ require.Equal(t, 1, r.events.Watchers())
350+ r.events.Close(context.Background())
351+ r.events.Close(context.Background()) // twice is fine
352+ synctest.Wait()
353+ select {
354+ case err := <-w.done:
355+ assert.NoError(t, err)
356+ default:
357+ t.Fatal("watch did not end")
358+ }
359+ })
360+ })
361+
362+ t.Run("a failing send ends the watch with its error", func(t *testing.T) {
363+ t.Parallel()
364+ synctest.Test(t, func(t *testing.T) {
365+ r := newEventRig(t, 0)
366+ n := r.repo("pub", domain.VisibilityPublic)
367+ boom := errors.New("client went away")
368+ done := make(chan error, 1)
369+ go func() {
370+ done <- r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{}, func(ports.WatchEvent) error { return boom })
371+ }()
372+ synctest.Wait()
373+ require.Equal(t, 1, r.events.Watchers())
374+ r.push(n, 1)
375+ assert.ErrorIs(t, <-done, boom)
376+ })
377+ })
378+}
379+
380+func TestWatchSlowWatcherGetsAGap(t *testing.T) {
381+ t.Parallel()
382+ synctest.Test(t, func(t *testing.T) {
383+ const size = 4
384+ r := newEventRig(t, size)
385+ n := r.repo("pub", domain.VisibilityPublic)
386+
387+ release := make(chan struct{})
388+ var mu sync.Mutex
389+ var got []ports.WatchEvent
390+ done := make(chan error, 1)
391+ blocked := make(chan struct{})
392+ first := true
393+ go func() {
394+ done <- r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{}, func(e ports.WatchEvent) error {
395+ if first { // block on the first event while the ring turns over
396+ first = false
397+ close(blocked)
398+ <-release
399+ }
400+ mu.Lock()
401+ got = append(got, e)
402+ mu.Unlock()
403+ return nil
404+ })
405+ }()
406+ // A second, fast watcher tells when the use case has taken every event:
407+ // it receives them in order.
408+ fast := r.watch(context.Background(), domain.Root(), ports.WatchParams{})
409+ synctest.Wait()
410+ require.Equal(t, 2, r.events.Watchers())
411+ r.push(n, 0)
412+ <-blocked
413+ for i := 1; i < 30; i++ {
414+ r.push(n, i)
415+ }
416+ // The ring holds four events: the fast watcher may get a gap, but it ends
417+ // on the last push, which proves the use case took all of them.
418+ last := fmt.Sprintf("%040x", 29+1)
419+ for {
420+ e := fast.next(t)
421+ if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
422+ break
423+ }
424+ }
425+ close(release)
426+
427+ synctest.Wait()
428+ mu.Lock()
429+ defer mu.Unlock()
430+ require.GreaterOrEqual(t, len(got), 2)
431+ hasGap := false
432+ for _, e := range got[1:] {
433+ hasGap = hasGap || e.Gap
434+ }
435+ assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
436+ })
437+}
438+
439+func TestEventsRepoPayloads(t *testing.T) {
440+ t.Parallel()
441+ synctest.Test(t, func(t *testing.T) {
442+ r := newEventRig(t, 0)
443+ pub := r.repo("pub", domain.VisibilityPublic)
444+ priv := r.repo("priv", domain.VisibilityPrivate)
445+ ctx, cancel := context.WithCancel(context.Background())
446+ defer cancel()
447+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
448+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
449+ synctest.Wait()
450+ require.Equal(t, 2, r.events.Watchers())
451+
452+ r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
453+ r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now}
454+ // A deleted repository cannot be read any more: its visibility decides.
455+ r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
456+ r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
457+
458+ for _, w := range []*watcher{anon, root} {
459+ created := w.next(t)
460+ require.NotNil(t, created.Repo)
461+ assert.Equal(t, "main", created.Repo.DefaultBranch)
462+ assert.Equal(t, pub, created.Repo.Name)
463+ require.NotNil(t, w.next(t).Repo)
464+ }
465+ assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
466+ assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
467+ assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
468+ anon.quiet(t, r, pub)
469+ })
470+}
471+
472+// tokenWith builds the principal of a stored token with an id and an expiry.
473+func tokenWith(id string, expires time.Time, repos ...string) domain.Principal {
474+ tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires}
475+ for _, r := range repos {
476+ n, err := domain.ParseRepoName(r)
477+ if err != nil {
478+ panic(err)
479+ }
480+ tok.Repos = append(tok.Repos, n)
481+ }
482+ return domain.TokenPrincipal(tok)
483+}
484+
485+func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
486+ t.Parallel()
487+ synctest.Test(t, func(t *testing.T) {
488+ // Asking at every turn keeps the test independent of the clock.
489+ r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1})
490+ pub := r.repo("pub", domain.VisibilityPublic)
491+ priv := r.repo("priv", domain.VisibilityPrivate)
492+ p := tokenWith("tok", time.Time{})
493+ ctx, cancel := context.WithCancel(context.Background())
494+ defer cancel()
495+
496+ w := r.watch(ctx, p, ports.WatchParams{})
497+ synctest.Wait()
498+ require.Equal(t, 1, r.events.Watchers())
499+ r.push(pub, 1)
500+ w.next(t)
501+
502+ r.revoked.Store("tok", true)
503+ r.push(priv, 2) // would be delivered to a token that may read priv
504+ synctest.Wait()
505+ select {
506+ case err := <-w.done:
507+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
508+ default:
509+ t.Fatal("the watch of a revoked token did not end")
510+ }
511+ select {
512+ case e := <-w.got:
513+ t.Fatalf("an event was sent after the revocation: %+v", e)
514+ default:
515+ }
516+ })
517+}
518+
519+func TestWatchEndsWhenTheTokenExpires(t *testing.T) {
520+ t.Parallel()
521+ synctest.Test(t, func(t *testing.T) {
522+ r := newEventRig(t, 0)
523+ pub := r.repo("pub", domain.VisibilityPublic)
524+ // The rig's clock stands at now: this token expired at now.
525+ p := tokenWith("tok", now)
526+ w := r.watch(context.Background(), p, ports.WatchParams{})
527+ r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated
528+ synctest.Wait()
529+ select {
530+ case err := <-w.done:
531+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
532+ default:
533+ t.Fatal("the watch of an expired token did not end")
534+ }
535+ assert.Empty(t, w.got)
536+ })
537+}
538+
539+func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
540+ t.Parallel()
541+ t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
542+ t.Parallel()
543+ synctest.Test(t, func(t *testing.T) {
544+ r := newEventRigConfig(t, usecase.EventsConfig{}) // the production default interval
545+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), ports.WatchParams{})
546+ synctest.Wait()
547+ require.Equal(t, 1, r.events.Watchers())
548+ r.revoked.Store("tok", true)
549+ synctest.Sleep(usecase.DefaultRevalidateEvery - time.Nanosecond)
550+ select {
551+ case err := <-w.done:
552+ t.Fatalf("the watch ended before the interval: %v", err)
553+ default:
554+ }
555+ assert.Zero(t, r.revalidations.Load(), "no store lookup before the interval")
556+ synctest.Sleep(time.Nanosecond)
557+ select {
558+ case err := <-w.done:
559+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
560+ default:
561+ t.Fatal("an idle watch of a revoked token did not end at the interval")
562+ }
563+ assert.Equal(t, int32(1), r.revalidations.Load())
564+ })
565+ })
566+
567+ t.Run("the answer is kept between checks", func(t *testing.T) {
568+ t.Parallel()
569+ synctest.Test(t, func(t *testing.T) {
570+ r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: time.Hour})
571+ pub := r.repo("pub", domain.VisibilityPublic)
572+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), ports.WatchParams{})
573+ synctest.Wait()
574+ require.Equal(t, 1, r.events.Watchers())
575+ for i := range 20 {
576+ r.push(pub, i)
577+ }
578+ for range 20 {
579+ w.next(t)
580+ }
581+ // Idle, the watcher wakes up every few seconds but keeps its answer
582+ // until the interval is over.
583+ synctest.Sleep(time.Hour - time.Nanosecond)
584+ assert.Zero(t, r.revalidations.Load(), "no store lookup within the interval")
585+ synctest.Sleep(time.Nanosecond)
586+ assert.Equal(t, int32(1), r.revalidations.Load(), "one lookup when the interval is over")
587+ })
588+ })
589+
590+ t.Run("root and anonymous never ask", func(t *testing.T) {
591+ t.Parallel()
592+ synctest.Test(t, func(t *testing.T) {
593+ r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1})
594+ pub := r.repo("pub", domain.VisibilityPublic)
595+ a := r.watch(context.Background(), domain.Anonymous(), ports.WatchParams{})
596+ b := r.watch(context.Background(), domain.Root(), ports.WatchParams{})
597+ synctest.Wait()
598+ require.Equal(t, 2, r.events.Watchers())
599+ r.push(pub, 1)
600+ a.next(t)
601+ b.next(t)
602+ r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)
603+ })
604+ })
605+}
606+
607+func TestWatchLimits(t *testing.T) {
608+ t.Parallel()
609+ noop := func(ports.WatchEvent) error { return nil }
610+
611+ t.Run("too many topics", func(t *testing.T) {
612+ t.Parallel()
613+ synctest.Test(t, func(t *testing.T) {
614+ r := newEventRig(t, 0)
615+ r.repo("pub", domain.VisibilityPublic)
616+ ok := make([]string, usecase.MaxWatchTopics)
617+ for i := range ok {
618+ ok[i] = "home"
619+ }
620+ ctx, cancel := context.WithCancel(context.Background())
621+ cancel() // returns at once once the topics are accepted
622+ assert.NoError(t, r.events.Watch(ctx, domain.Root(), ports.WatchParams{Topics: ok}, noop))
623+ err := r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{Topics: append(ok, "home")}, noop)
624+ assert.ErrorIs(t, err, domain.ErrInvalid)
625+ })
626+ })
627+
628+ t.Run("total watchers", func(t *testing.T) {
629+ t.Parallel()
630+ synctest.Test(t, func(t *testing.T) {
631+ r := newEventRig(t, 0)
632+ ctx, cancel := context.WithCancel(context.Background())
633+ defer cancel()
634+ for range usecase.MaxWatchers {
635+ r.watch(ctx, domain.Root(), ports.WatchParams{})
636+ }
637+ synctest.Wait()
638+ require.Equal(t, usecase.MaxWatchers, r.events.Watchers())
639+ err := r.events.Watch(ctx, domain.Root(), ports.WatchParams{}, noop)
640+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
641+ assert.Equal(t, usecase.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")
642+
643+ cancel() // all end; a new one is accepted again
644+ synctest.Wait()
645+ require.Equal(t, 0, r.events.Watchers())
646+ again, stop := context.WithCancel(context.Background())
647+ stop()
648+ assert.NoError(t, r.events.Watch(again, domain.Root(), ports.WatchParams{}, noop))
649+ })
650+ })
651+
652+ t.Run("watchers per client", func(t *testing.T) {
653+ t.Parallel()
654+ synctest.Test(t, func(t *testing.T) {
655+ r := newEventRig(t, 0)
656+ ctx, cancel := context.WithCancel(context.Background())
657+ defer cancel()
658+ for range usecase.MaxWatchersPerClient {
659+ r.watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.1"})
660+ }
661+ synctest.Wait()
662+ require.Equal(t, usecase.MaxWatchersPerClient, r.events.Watchers())
663+
664+ err := r.events.Watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.1"}, noop)
665+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
666+
667+ // Another client, and callers in process, are not affected.
668+ r.watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.2"})
669+ r.watch(ctx, domain.Root(), ports.WatchParams{})
670+ synctest.Wait()
671+ require.Equal(t, usecase.MaxWatchersPerClient+2, r.events.Watchers())
672+
673+ cancel()
674+ synctest.Wait()
675+ require.Equal(t, 0, r.events.Watchers())
676+ again, stop := context.WithCancel(context.Background())
677+ stop()
678+ assert.NoError(t, r.events.Watch(again, domain.Root(), ports.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")
679+ })
680+ })
681+}
682+
683+// repoAt declares a repository whose metadata the test changes.
684+type liveRepo struct {
685+ mu sync.Mutex
686+ repo domain.Repo
687+}
688+
689+func (l *liveRepo) set(f func(*domain.Repo)) {
690+ l.mu.Lock()
691+ defer l.mu.Unlock()
692+ f(&l.repo)
693+}
694+
695+func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time) (*liveRepo, domain.RepoName) {
696+ n := name(r.t, s)
697+ l := &liveRepo{repo: domain.Repo{Name: n, Visibility: vis, CreatedAt: created}}
698+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
699+ l.mu.Lock()
700+ defer l.mu.Unlock()
701+ return l.repo, nil
702+ }).Maybe()
703+ return l, n
704+}
705+
706+func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {
707+ t.Parallel()
708+ t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {
709+ t.Parallel()
710+ synctest.Test(t, func(t *testing.T) {
711+ ctx, cancel := context.WithCancel(context.Background())
712+ defer cancel()
713+ r := newEventRig(t, 0)
714+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
715+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
716+ synctest.Wait()
717+ require.Equal(t, 1, r.events.Watchers())
718+ r.push(n, 1)
719+ r.push(n, 2)
720+ root.next(t)
721+ root.next(t) // both are buffered now
722+
723+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })
724+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}
725+ root.next(t)
726+ r.push(n, 3) // public era
727+
728+ // An anonymous caller that resumes with an id nobody issued gets what
729+ // is buffered, filtered: the public update and the public push, not the
730+ // pushes of the private era.
731+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"})
732+ assert.True(t, anon.next(t).Gap)
733+ upd := anon.next(t)
734+ require.IsType(t, domain.RepoUpdated{}, upd.Event)
735+ push := anon.next(t)
736+ require.IsType(t, domain.PushEvent{}, push.Event)
737+ assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)
738+ anon.quiet(t, r, n)
739+ })
740+ })
741+
742+ t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) {
743+ t.Parallel()
744+ synctest.Test(t, func(t *testing.T) {
745+ ctx, cancel := context.WithCancel(context.Background())
746+ defer cancel()
747+ r := newEventRig(t, 0)
748+ first := now.Add(-time.Hour)
749+ l, n := r.liveRepo("again", domain.VisibilityPublic, first)
750+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
751+ synctest.Wait()
752+ require.Equal(t, 1, r.events.Watchers())
753+ r.push(n, 1)
754+ root.next(t)
755+
756+ l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again
757+ r.push(n, 2)
758+ root.next(t)
759+
760+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"})
761+ assert.True(t, anon.next(t).Gap)
762+ e := anon.next(t)
763+ require.IsType(t, domain.PushEvent{}, e.Event)
764+ assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")
765+ anon.quiet(t, r, n)
766+ })
767+ })
768+
769+ t.Run("a repository made private tells those who could read it, once", func(t *testing.T) {
770+ t.Parallel()
771+ synctest.Test(t, func(t *testing.T) {
772+ ctx, cancel := context.WithCancel(context.Background())
773+ defer cancel()
774+ r := newEventRig(t, 0)
775+ l, n := r.liveRepo("hide", domain.VisibilityPublic, now)
776+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
777+ reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), ports.WatchParams{})
778+ other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), ports.WatchParams{})
779+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
780+ synctest.Wait()
781+ require.Equal(t, 4, r.events.Watchers())
782+
783+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })
784+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}
785+
786+ for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {
787+ e := w.next(t)
788+ up, ok := e.Event.(domain.RepoUpdated)
789+ require.True(t, ok, name)
790+ assert.Equal(t, n, up.Repo, name)
791+ require.NotNil(t, e.Repo, name)
792+ assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)
793+ assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)
794+ }
795+
796+ // Later events of the repository reach those who may read it now, only.
797+ r.push(n, 1)
798+ assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)
799+ assert.IsType(t, domain.PushEvent{}, root.next(t).Event)
800+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
801+ other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
802+ })
803+ })
804+
805+ t.Run("a private repository that never was public stays hidden", func(t *testing.T) {
806+ t.Parallel()
807+ synctest.Test(t, func(t *testing.T) {
808+ ctx, cancel := context.WithCancel(context.Background())
809+ defer cancel()
810+ r := newEventRig(t, 0)
811+ _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)
812+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
813+ synctest.Wait()
814+ require.Equal(t, 1, r.events.Watchers())
815+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
816+ r.push(n, 1)
817+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
818+ })
819+ })
820+}
821+
822+// TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was
823+// when published, not as the store says when the feeder gets to it.
824+func TestPushEventsKeepTheirSnapshot(t *testing.T) {
825+ t.Parallel()
826+ tests := []struct {
827+ name string
828+ swap func(repo *domain.Repo)
829+ rootSees bool // the repository is still the one the push is about
830+ }{
831+ {"made public before the feeder appends", func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }, true},
832+ {"recreated before the feeder appends", func(repo *domain.Repo) {
833+ repo.Visibility = domain.VisibilityPublic
834+ repo.CreatedAt = now.Add(time.Hour)
835+ }, false},
836+ }
837+ for _, tt := range tests {
838+ t.Run(tt.name, func(t *testing.T) {
839+ t.Parallel()
840+ synctest.Test(t, func(t *testing.T) {
841+ ctx, cancel := context.WithCancel(context.Background())
842+ defer cancel()
843+ r := newEventRig(t, 0)
844+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
845+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
846+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
847+ synctest.Wait()
848+ require.Equal(t, 2, r.events.Watchers())
849+
850+ // Published while private; the store changes before the feeder
851+ // can have looked at it: the event is built, then the state is
852+ // swapped, and only then is it handed to the feeder.
853+ ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main")
854+ l.set(tt.swap)
855+ r.feed <- ev
856+ if tt.rootSees {
857+ root.next(t)
858+ }
859+
860+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
861+
862+ late := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"})
863+ assert.True(t, late.next(t).Gap)
864+ late.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
865+ })
866+ })
867+ }
868+
869+ t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) {
870+ t.Parallel()
871+ synctest.Test(t, func(t *testing.T) {
872+ ctx, cancel := context.WithCancel(context.Background())
873+ defer cancel()
874+ r := newEventRig(t, 0)
875+ n := r.repo("ghost", domain.VisibilityPublic)
876+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
877+ synctest.Wait()
878+ require.Equal(t, 1, r.events.Watchers())
879+ r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now}
880+ root.quiet(t, r, n)
881+ })
882+ })
883+}
R internal/app/export_test.go → internal/usecase/export_test.go +1 -1 renamed, 92% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import "bnema.dev/ermite/internal/domain"5 import "bnema.dev/ermite/internal/domain"
6 6
R internal/app/maintenance.go → internal/usecase/maintenance.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/maintenance_test.go → internal/usecase/maintenance_test.go +16 -16 renamed, 86% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -15,9 +15,9 @@ import (
15 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 17
18- "bnema.dev/ermite/internal/app"
19 "bnema.dev/ermite/internal/domain"18 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports/mocks"19 "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
21 )21 )
22 22
23 // Every test runs in a synctest bubble with a fake clock: the delays are the23 // Every test runs in a synctest bubble with a fake clock: the delays are the
@@ -35,13 +35,13 @@ func TestMaintainerDebounces(t *testing.T) {
35 runs.Add(1)35 runs.Add(1)
36 return nil36 return nil
37 })37 })
38- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)38+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
39 defer m.Close(ctx)39 defer m.Close(ctx)
40 40
41 for range 10 {41 for range 10 {
42 m.Schedule(ctx, n)42 m.Schedule(ctx, n)
43 }43 }
44- synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)44+ synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond)
45 assert.Equal(t, int32(0), runs.Load(), "waits for the full delay")45 assert.Equal(t, int32(0), runs.Load(), "waits for the full delay")
46 synctest.Sleep(time.Nanosecond)46 synctest.Sleep(time.Nanosecond)
47 assert.Equal(t, int32(1), runs.Load(), "a burst makes one run")47 assert.Equal(t, int32(1), runs.Load(), "a burst makes one run")
@@ -49,7 +49,7 @@ func TestMaintainerDebounces(t *testing.T) {
49 assert.Equal(t, int32(1), runs.Load(), "no second run is coming")49 assert.Equal(t, int32(1), runs.Load(), "no second run is coming")
50 50
51 m.Schedule(ctx, n)51 m.Schedule(ctx, n)
52- synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)52+ synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond)
53 assert.Equal(t, int32(1), runs.Load(), "a second burst waits for its own delay")53 assert.Equal(t, int32(1), runs.Load(), "a second burst waits for its own delay")
54 synctest.Sleep(time.Nanosecond)54 synctest.Sleep(time.Nanosecond)
55 assert.Equal(t, int32(2), runs.Load(), "a second burst, a second run")55 assert.Equal(t, int32(2), runs.Load(), "a second burst, a second run")
@@ -82,8 +82,8 @@ func TestMaintainerOneRunAtATimePerRepo(t *testing.T) {
82 otherRan.Store(true)82 otherRan.Store(true)
83 return nil83 return nil
84 })84 })
85- const d = app.MaintenanceDelay85+ const d = usecase.MaintenanceDelay
86- m := app.NewMaintainer(ctx, git, d)86+ m := usecase.NewMaintainer(ctx, git, d)
87 defer m.Close(ctx)87 defer m.Close(ctx)
88 88
89 m.Schedule(ctx, n)89 m.Schedule(ctx, n)
@@ -121,9 +121,9 @@ func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
121 told := make(chan struct{}, 2)121 told := make(chan struct{}, 2)
122 fresh := mocks.NewRepoFreshener(t)122 fresh := mocks.NewRepoFreshener(t)
123 fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()123 fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()
124- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay).WithFreshener(fresh)124+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay).WithFreshener(fresh)
125 m.Schedule(ctx, n)125 m.Schedule(ctx, n)
126- synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)126+ synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond)
127 assert.Empty(t, told, "not before the run")127 assert.Empty(t, told, "not before the run")
128 synctest.Sleep(time.Nanosecond)128 synctest.Sleep(time.Nanosecond)
129 assert.Len(t, told, 1, "the reader is told when the run ends")129 assert.Len(t, told, 1, "the reader is told when the run ends")
@@ -146,13 +146,13 @@ func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {
146 runs.Add(1)146 runs.Add(1)
147 return errors.New("boom")147 return errors.New("boom")
148 })148 })
149- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)149+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
150 defer m.Close(ctx)150 defer m.Close(ctx)
151 m.Schedule(ctx, n)151 m.Schedule(ctx, n)
152- synctest.Sleep(app.MaintenanceDelay)152+ synctest.Sleep(usecase.MaintenanceDelay)
153 assert.Equal(t, int32(1), runs.Load())153 assert.Equal(t, int32(1), runs.Load())
154 m.Schedule(ctx, n)154 m.Schedule(ctx, n)
155- synctest.Sleep(app.MaintenanceDelay)155+ synctest.Sleep(usecase.MaintenanceDelay)
156 assert.Equal(t, int32(2), runs.Load(), "later runs still happen")156 assert.Equal(t, int32(2), runs.Load(), "later runs still happen")
157 })157 })
158 }158 }
@@ -166,7 +166,7 @@ func TestMaintainerClose(t *testing.T) {
166 t.Parallel()166 t.Parallel()
167 synctest.Test(t, func(t *testing.T) {167 synctest.Test(t, func(t *testing.T) {
168 git := mocks.NewGitRepos(t) // no Maintain expected168 git := mocks.NewGitRepos(t) // no Maintain expected
169- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)169+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
170 m.Schedule(ctx, n)170 m.Schedule(ctx, n)
171 m.Close(ctx)171 m.Close(ctx)
172 m.Schedule(ctx, n) // after Close: ignored172 m.Schedule(ctx, n) // after Close: ignored
@@ -185,9 +185,9 @@ func TestMaintainerClose(t *testing.T) {
185 cancelled.Store(true)185 cancelled.Store(true)
186 return ctx.Err()186 return ctx.Err()
187 })187 })
188- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)188+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
189 m.Schedule(ctx, n)189 m.Schedule(ctx, n)
190- synctest.Sleep(app.MaintenanceDelay) // the run is blocked in Maintain190+ synctest.Sleep(usecase.MaintenanceDelay) // the run is blocked in Maintain
191 assert.False(t, cancelled.Load())191 assert.False(t, cancelled.Load())
192 m.Close(ctx) // a hang is a bubble deadlock: reported, no timeout needed192 m.Close(ctx) // a hang is a bubble deadlock: reported, no timeout needed
193 assert.True(t, cancelled.Load())193 assert.True(t, cancelled.Load())
@@ -201,7 +201,7 @@ func TestMaintainerConcurrentSchedule(t *testing.T) {
201 ctx := context.Background()201 ctx := context.Background()
202 git := mocks.NewGitRepos(t)202 git := mocks.NewGitRepos(t)
203 git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()203 git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()
204- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)204+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
205 var wg sync.WaitGroup205 var wg sync.WaitGroup
206 for i := range 8 {206 for i := range 8 {
207 wg.Add(1)207 wg.Add(1)
R internal/app/mergerequests.go → internal/usecase/mergerequests.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/mergerequests_delete_test.go → internal/usecase/mergerequests_delete_test.go +3 -3 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -10,9 +10,9 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 "bnema.dev/ermite/internal/adapters/gitcli"12 "bnema.dev/ermite/internal/adapters/gitcli"
13- "bnema.dev/ermite/internal/app"
14 "bnema.dev/ermite/internal/domain"13 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"14 "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
16 )16 )
17 17
18 // A merge can delete its source branch; the push event carries both refs.18 // A merge can delete its source branch; the push event carries both refs.
@@ -181,7 +181,7 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
181 head := r.feature("feat")181 head := r.feature("feat")
182 mr := r.open("feat")182 mr := r.open("feat")
183 var moved domain.ObjectID183 var moved domain.ObjectID
184- mrs := app.NewMergeRequests(app.MergeRequestsConfig{184+ mrs := usecase.NewMergeRequests(usecase.MergeRequestsConfig{
185 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,185 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
186 Git: movingGit{Git: r.git, move: func() {186 Git: movingGit{Git: r.git, move: func() {
187 moved = r.commit(author, "late.txt", "late\n", "pushed during the merge")187 moved = r.commit(author, "late.txt", "late\n", "pushed during the merge")
R internal/app/mergerequests_integration_test.go → internal/usecase/mergerequests_integration_test.go +12 -12 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "bytes"6 "bytes"
@@ -22,10 +22,10 @@ import (
22 "bnema.dev/ermite/internal/adapters/gitcli"22 "bnema.dev/ermite/internal/adapters/gitcli"
23 "bnema.dev/ermite/internal/adapters/kvstore"23 "bnema.dev/ermite/internal/adapters/kvstore"
24 "bnema.dev/ermite/internal/adapters/sshsig"24 "bnema.dev/ermite/internal/adapters/sshsig"
25- "bnema.dev/ermite/internal/app"
26 "bnema.dev/ermite/internal/domain"25 "bnema.dev/ermite/internal/domain"
27 "bnema.dev/ermite/internal/ports"26 "bnema.dev/ermite/internal/ports"
28 "bnema.dev/ermite/internal/ports/mocks"27 "bnema.dev/ermite/internal/ports/mocks"
28+ "bnema.dev/ermite/internal/usecase"
29 )29 )
30 30
31 // queuedRepos records the repositories whose maintenance was queued.31 // queuedRepos records the repositories whose maintenance was queued.
@@ -53,9 +53,9 @@ type mrRig struct {
53 t *testing.T53 t *testing.T
54 ctx context.Context54 ctx context.Context
55 data string55 data string
56- repos *app.Repos56+ repos *usecase.Repos
57- mrs *app.MergeRequests57+ mrs *usecase.MergeRequests
58- key *app.SigningKey58+ key *usecase.SigningKey
59 store *kvstore.Store59 store *kvstore.Store
60 git *gitcli.Git60 git *gitcli.Git
61 bus *eventbus.Bus61 bus *eventbus.Bus
@@ -101,12 +101,12 @@ func newMRRig(t *testing.T) *mrRig {
101 r := &mrRig{101 r := &mrRig{
102 sched: sched, queued: queued,102 sched: sched, queued: queued,
103 t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),103 t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
104- repos: app.NewRepos(store, git, bus, fixedClock{}),104+ repos: usecase.NewRepos(store, git, bus, fixedClock{}),
105- key: app.NewSigningKey(signer, committer),105+ key: usecase.NewSigningKey(signer, committer),
106 work: t.TempDir(),106 work: t.TempDir(),
107 repo: name(t, "proj"),107 repo: name(t, "proj"),
108 }108 }
109- r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{109+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
110 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,110 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
111 Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched,111 Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched,
112 })112 })
@@ -543,14 +543,14 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) {
543 r := newMRRig(t)543 r := newMRRig(t)
544 r.seed()544 r.seed()
545 r.sh("checkout", "-q", "-B", "long", "main")545 r.sh("checkout", "-q", "-B", "long", "main")
546- for i := range app.MaxMergeRequestCommits + 5 {546+ for i := range usecase.MaxMergeRequestCommits + 5 {
547 r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i))547 r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i))
548 }548 }
549 r.push("long")549 r.push("long")
550 mr := r.open("long")550 mr := r.open("long")
551 d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)551 d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
552 require.NoError(t, err)552 require.NoError(t, err)
553- assert.Len(t, d.Commits, app.MaxMergeRequestCommits)553+ assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits)
554 assert.True(t, d.CommitsTruncated)554 assert.True(t, d.CommitsTruncated)
555 assert.Equal(t, "c104", d.Commits[0].Summary)555 assert.Equal(t, "c104", d.Commits[0].Summary)
556 }556 }
@@ -1285,7 +1285,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
1285 r.seed()1285 r.seed()
1286 r.feature("feat")1286 r.feature("feat")
1287 mr := r.open("feat")1287 mr := r.open("feat")
1288- for i := range app.MaxCommentsPerMergeRequest {1288+ for i := range usecase.MaxCommentsPerMergeRequest {
1289 _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)})1289 _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
1290 require.NoError(t, err, "comment %d", i+1)1290 require.NoError(t, err, "comment %d", i+1)
1291 }1291 }
@@ -1293,7 +1293,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
1293 assert.ErrorIs(t, err, domain.ErrResourceExhausted)1293 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1294 d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)1294 d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
1295 require.NoError(t, err)1295 require.NoError(t, err)
1296- assert.Len(t, d.Comments, app.MaxCommentsPerMergeRequest)1296+ assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)
1297 1297
1298 // Resolving still works, and another request has its own budget.1298 // Resolving still works, and another request has its own budget.
1299 _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 1, true)1299 _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 1, true)
R internal/app/mergerequests_test.go → internal/usecase/mergerequests_test.go +4 -4 renamed, 96% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -11,10 +11,10 @@ import (
11 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14- "bnema.dev/ermite/internal/app"
15 "bnema.dev/ermite/internal/domain"14 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"15 "bnema.dev/ermite/internal/ports"
17 "bnema.dev/ermite/internal/ports/mocks"16 "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18 )18 )
19 19
20 type mrUnit struct {20 type mrUnit struct {
@@ -22,7 +22,7 @@ type mrUnit struct {
22 store *mocks.MergeRequestStore22 store *mocks.MergeRequestStore
23 repos *mocks.GitRepos23 repos *mocks.GitRepos
24 git *mocks.MergeGit24 git *mocks.MergeGit
25- mrs *app.MergeRequests25+ mrs *usecase.MergeRequests
26 repo domain.Repo26 repo domain.Repo
27 }27 }
28 28
@@ -39,7 +39,7 @@ func newMRUnit(t *testing.T) *mrUnit {
39 clock.EXPECT().Now().Return(now).Maybe()39 clock.EXPECT().Now().Return(now).Maybe()
40 sched := mocks.NewMaintenanceScheduler(t)40 sched := mocks.NewMaintenanceScheduler(t)
41 sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()41 sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
42- u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{42+ u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
43 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,43 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
44 Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,44 Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
45 })45 })
R internal/app/paging.go → internal/usecase/paging.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/pushes.go → internal/usecase/pushes.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/pushes_test.go → internal/usecase/pushes_test.go +6 -6 renamed, 97% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -12,10 +12,10 @@ import (
12 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15- "bnema.dev/ermite/internal/app"
16 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"16 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"17 "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19 )19 )
20 20
21 type pushFixture struct {21 type pushFixture struct {
@@ -25,7 +25,7 @@ type pushFixture struct {
25 clock *mocks.Clock25 clock *mocks.Clock
26 tokens *mocks.TokenStore26 tokens *mocks.TokenStore
27 sched *mocks.MaintenanceScheduler27 sched *mocks.MaintenanceScheduler
28- pushes *app.Pushes28+ pushes *usecase.Pushes
29 }29 }
30 30
31 func newPushFixture(t *testing.T) *pushFixture {31 func newPushFixture(t *testing.T) *pushFixture {
@@ -39,9 +39,9 @@ func newPushFixture(t *testing.T) *pushFixture {
39 sched: mocks.NewMaintenanceScheduler(t),39 sched: mocks.NewMaintenanceScheduler(t),
40 }40 }
41 f.clock.EXPECT().Now().Return(now).Maybe()41 f.clock.EXPECT().Now().Return(now).Maybe()
42- repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)42+ repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
43- auth := app.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)43+ auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
44- f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)44+ f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
45 return f45 return f
46 }46 }
47 47
R internal/app/repos.go → internal/usecase/repos.go +2 -2 renamed, 98% similar
@@ -1,8 +1,8 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-// Package app holds the use cases of ermite. It depends on the domain and on3+// Package usecase holds the use cases of ermite. It depends on the domain and on
4 // the ports only.4 // the ports only.
5-package app5+package usecase
6 6
7 import (7 import (
8 "context"8 "context"
R internal/app/repos_integration_test.go → internal/usecase/repos_integration_test.go +3 -3 renamed, 95% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -18,9 +18,9 @@ import (
18 "bnema.dev/ermite/internal/adapters/eventbus"18 "bnema.dev/ermite/internal/adapters/eventbus"
19 "bnema.dev/ermite/internal/adapters/gitcli"19 "bnema.dev/ermite/internal/adapters/gitcli"
20 "bnema.dev/ermite/internal/adapters/kvstore"20 "bnema.dev/ermite/internal/adapters/kvstore"
21- "bnema.dev/ermite/internal/app"
22 "bnema.dev/ermite/internal/domain"21 "bnema.dev/ermite/internal/domain"
23 "bnema.dev/ermite/internal/ports"22 "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/usecase"
24 )24 )
25 25
26 type fixedClock struct{}26 type fixedClock struct{}
@@ -47,7 +47,7 @@ func TestCreateConcurrentSameNameRealAdapters(t *testing.T) {
47 require.NoError(t, err)47 require.NoError(t, err)
48 t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })48 t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
49 49
50- repos := app.NewRepos(store, git, eventbus.New(), fixedClock{})50+ repos := usecase.NewRepos(store, git, eventbus.New(), fixedClock{})
51 n := name(t, "tools")51 n := name(t, "tools")
52 52
53 const workers = 853 const workers = 8
R internal/app/repos_test.go → internal/usecase/repos_test.go +4 -4 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -12,10 +12,10 @@ import (
12 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15- "bnema.dev/ermite/internal/app"
16 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"16 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"17 "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19 )19 )
20 20
21 var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)21 var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
@@ -25,7 +25,7 @@ type fixture struct {
25 git *mocks.GitRepos25 git *mocks.GitRepos
26 clock *mocks.Clock26 clock *mocks.Clock
27 bus *mocks.EventBus27 bus *mocks.EventBus
28- repos *app.Repos28+ repos *usecase.Repos
29 }29 }
30 30
31 func newFixture(t *testing.T) *fixture {31 func newFixture(t *testing.T) *fixture {
@@ -37,7 +37,7 @@ func newFixture(t *testing.T) *fixture {
37 bus: mocks.NewEventBus(t),37 bus: mocks.NewEventBus(t),
38 }38 }
39 f.clock.EXPECT().Now().Return(now).Maybe()39 f.clock.EXPECT().Now().Return(now).Maybe()
40- f.repos = app.NewRepos(f.meta, f.git, f.bus, f.clock)40+ f.repos = usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
41 return f41 return f
42 }42 }
43 43
R internal/app/repos_write.go → internal/usecase/repos_write.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/repos_write_test.go → internal/usecase/repos_write_test.go +7 -7 renamed, 96% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -13,9 +13,9 @@ import (
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16- "bnema.dev/ermite/internal/app"
17 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/usecase"
19 )19 )
20 20
21 func ptr[T any](v T) *T { return &v }21 func ptr[T any](v T) *T { return &v }
@@ -128,7 +128,7 @@ func TestUpdate(t *testing.T) {
128 "empty": {},128 "empty": {},
129 "empty branch": {DefaultBranch: ptr("")},129 "empty branch": {DefaultBranch: ptr("")},
130 "multi-line": {Description: ptr("a\nb")},130 "multi-line": {Description: ptr("a\nb")},
131- "too long": {Description: ptr(strings.Repeat("x", app.MaxDescriptionLen+1))},131+ "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
132 "bad utf-8": {Description: ptr("\xff")},132 "bad utf-8": {Description: ptr("\xff")},
133 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},133 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
134 } {134 } {
@@ -232,7 +232,7 @@ func TestListPageSizes(t *testing.T) {
232 t.Parallel()232 t.Parallel()
233 ctx := context.Background()233 ctx := context.Background()
234 var all []domain.Repo234 var all []domain.Repo
235- for i := range app.MaxRepoPageSize + 10 {235+ for i := range usecase.MaxRepoPageSize + 10 {
236 all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic})236 all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic})
237 }237 }
238 for _, tt := range []struct {238 for _, tt := range []struct {
@@ -241,10 +241,10 @@ func TestListPageSizes(t *testing.T) {
241 want int241 want int
242 more bool242 more bool
243 }{243 }{
244- {"default", 0, app.DefaultPageSize, true},244+ {"default", 0, usecase.DefaultPageSize, true},
245 {"within the maximum", 500, 500, true},245 {"within the maximum", 500, 500, true},
246- {"the maximum", app.MaxRepoPageSize, app.MaxRepoPageSize, true},246+ {"the maximum", usecase.MaxRepoPageSize, usecase.MaxRepoPageSize, true},
247- {"lowered to the maximum", app.MaxRepoPageSize * 10, app.MaxRepoPageSize, true},247+ {"lowered to the maximum", usecase.MaxRepoPageSize * 10, usecase.MaxRepoPageSize, true},
248 } {248 } {
249 t.Run(tt.name, func(t *testing.T) {249 t.Run(tt.name, func(t *testing.T) {
250 t.Parallel()250 t.Parallel()
R internal/app/sessions.go → internal/usecase/sessions.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/sessions_test.go → internal/usecase/sessions_test.go +18 -18 renamed, 91% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -15,16 +15,16 @@ import (
15 "github.com/stretchr/testify/mock"15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 17
18- "bnema.dev/ermite/internal/app"
19 "bnema.dev/ermite/internal/domain"18 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports/mocks"19 "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
21 )21 )
22 22
23 type sessionFixture struct {23 type sessionFixture struct {
24 store *mocks.SessionStore24 store *mocks.SessionStore
25 clock *mocks.Clock25 clock *mocks.Clock
26 at atomic.Pointer[time.Time] // what the clock reads; the test moves it26 at atomic.Pointer[time.Time] // what the clock reads; the test moves it
27- sessions *app.Sessions27+ sessions *usecase.Sessions
28 key []byte28 key []byte
29 }29 }
30 30
@@ -50,7 +50,7 @@ func newSessionFixture(t *testing.T) *sessionFixture {
50 f.store.EXPECT().Epoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Load(), nil }).Maybe()50 f.store.EXPECT().Epoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Load(), nil }).Maybe()
51 f.store.EXPECT().BumpEpoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Add(1), nil }).Maybe()51 f.store.EXPECT().BumpEpoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Add(1), nil }).Maybe()
52 52
53- s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")53+ s, err := usecase.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")
54 require.NoError(t, err)54 require.NoError(t, err)
55 f.sessions = s55 f.sessions = s
56 return f56 return f
@@ -120,7 +120,7 @@ func TestLoginLinkLifecycle(t *testing.T) {
120 120
121 link, err := f.sessions.CreateLoginLink(ctx, domain.Root())121 link, err := f.sessions.CreateLoginLink(ctx, domain.Root())
122 require.NoError(t, err)122 require.NoError(t, err)
123- assert.Equal(t, f.clock.Now().Add(app.LoginLinkTTL), link.ExpiresAt)123+ assert.Equal(t, f.clock.Now().Add(usecase.LoginLinkTTL), link.ExpiresAt)
124 nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/")124 nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/")
125 assert.Len(t, nonce, 43, "256 bits")125 assert.Len(t, nonce, 43, "256 bits")
126 126
@@ -130,14 +130,14 @@ func TestLoginLinkLifecycle(t *testing.T) {
130 token, exp, err := f.sessions.Login(ctx, nonce)130 token, exp, err := f.sessions.Login(ctx, nonce)
131 require.NoError(t, err)131 require.NoError(t, err)
132 assert.NotEmpty(t, token)132 assert.NotEmpty(t, token)
133- assert.Equal(t, f.clock.Now().Add(app.SessionTTL), exp)133+ assert.Equal(t, f.clock.Now().Add(usecase.SessionTTL), exp)
134 assert.False(t, f.sessions.LoginLinkValid(nonce))134 assert.False(t, f.sessions.LoginLinkValid(nonce))
135 _, _, err = f.sessions.Login(ctx, nonce)135 _, _, err = f.sessions.Login(ctx, nonce)
136 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay")136 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay")
137 137
138 // Expired.138 // Expired.
139 nonce = f.nonce(t)139 nonce = f.nonce(t)
140- f.advance(app.LoginLinkTTL)140+ f.advance(usecase.LoginLinkTTL)
141 assert.False(t, f.sessions.LoginLinkValid(nonce))141 assert.False(t, f.sessions.LoginLinkValid(nonce))
142 _, _, err = f.sessions.Login(ctx, nonce)142 _, _, err = f.sessions.Login(ctx, nonce)
143 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")143 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")
@@ -174,12 +174,12 @@ func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) {
174 p domain.Principal174 p domain.Principal
175 want time.Time175 want time.Time
176 }{176 }{
177- "root": {domain.Root(), start.Add(app.SessionTTL)},177+ "root": {domain.Root(), start.Add(usecase.SessionTTL)},
178- "owner session": {domain.Owner(), start.Add(app.SessionTTL)},178+ "owner session": {domain.Owner(), start.Add(usecase.SessionTTL)},
179- "never expires": {allScopeToken(), start.Add(app.SessionTTL)},179+ "never expires": {allScopeToken(), start.Add(usecase.SessionTTL)},
180 "expires sooner": {tokenExpiring(2 * time.Hour), start.Add(2 * time.Hour)},180 "expires sooner": {tokenExpiring(2 * time.Hour), start.Add(2 * time.Hour)},
181- "expires later": {tokenExpiring(48 * time.Hour), start.Add(app.SessionTTL)},181+ "expires later": {tokenExpiring(48 * time.Hour), start.Add(usecase.SessionTTL)},
182- "expires exactly TTL": {tokenExpiring(app.SessionTTL), start.Add(app.SessionTTL)},182+ "expires exactly TTL": {tokenExpiring(usecase.SessionTTL), start.Add(usecase.SessionTTL)},
183 } {183 } {
184 tok, exp, err := login(tc.p)184 tok, exp, err := login(tc.p)
185 require.NoError(t, err, name)185 require.NoError(t, err, name)
@@ -238,13 +238,13 @@ func TestLoginLinksAreBounded(t *testing.T) {
238 t.Parallel()238 t.Parallel()
239 ctx := context.Background()239 ctx := context.Background()
240 f := newSessionFixture(t)240 f := newSessionFixture(t)
241- for range app.MaxPendingLoginLinks {241+ for range usecase.MaxPendingLoginLinks {
242 f.nonce(t)242 f.nonce(t)
243 }243 }
244 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())244 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())
245 assert.ErrorIs(t, err, domain.ErrResourceExhausted)245 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
246 246
247- f.advance(app.LoginLinkTTL)247+ f.advance(usecase.LoginLinkTTL)
248 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())248 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())
249 assert.NoError(t, err, "expired links make room")249 assert.NoError(t, err, "expired links make room")
250 }250 }
@@ -308,7 +308,7 @@ func TestSessionTokenVerification(t *testing.T) {
308 assert.NoError(t, err)308 assert.NoError(t, err)
309 309
310 // Expiry after the session lifetime; and the epoch ends every session.310 // Expiry after the session lifetime; and the epoch ends every session.
311- f.advance(app.SessionTTL)311+ f.advance(usecase.SessionTTL)
312 _, err = f.sessions.Verify(ctx, token)312 _, err = f.sessions.Verify(ctx, token)
313 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")313 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")
314 }314 }
@@ -339,7 +339,7 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) {
339 // signs as "root" although both hold every right.339 // signs as "root" although both hold every right.
340 func TestAuthorOfOwnerSession(t *testing.T) {340 func TestAuthorOfOwnerSession(t *testing.T) {
341 t.Parallel()341 t.Parallel()
342- assert.Equal(t, "owner", app.AuthorOf(domain.Owner()))342+ assert.Equal(t, "owner", usecase.AuthorOf(domain.Owner()))
343- assert.Equal(t, "root", app.AuthorOf(domain.Root()))343+ assert.Equal(t, "root", usecase.AuthorOf(domain.Root()))
344- assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))344+ assert.Equal(t, "ci", usecase.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
345 }345 }
R internal/app/signing.go → internal/usecase/signing.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "bnema.dev/ermite/internal/domain"6 "bnema.dev/ermite/internal/domain"
R internal/app/tokens.go → internal/usecase/tokens.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app3+package usecase
4 4
5 import (5 import (
6 "context"6 "context"
R internal/app/tokens_test.go → internal/usecase/tokens_test.go +6 -6 renamed, 98% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package app_test3+package usecase_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -14,18 +14,18 @@ import (
14 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 16
17- "bnema.dev/ermite/internal/app"
18 "bnema.dev/ermite/internal/domain"17 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"18 "bnema.dev/ermite/internal/ports"
20 "bnema.dev/ermite/internal/ports/mocks"19 "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
21 )21 )
22 22
23 type tokenFixture struct {23 type tokenFixture struct {
24 store *mocks.TokenStore24 store *mocks.TokenStore
25 clock *mocks.Clock25 clock *mocks.Clock
26 sessions *mocks.SessionStore26 sessions *mocks.SessionStore
27- tokens *app.Tokens27+ tokens *usecase.Tokens
28- auth *app.Auth28+ auth *usecase.Auth
29 }29 }
30 30
31 var rootSecret = "root-secret-0123456789abcdef0123456789"31 var rootSecret = "root-secret-0123456789abcdef0123456789"
@@ -34,8 +34,8 @@ func newTokenFixture(t *testing.T) *tokenFixture {
34 t.Helper()34 t.Helper()
35 f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t), sessions: mocks.NewSessionStore(t)}35 f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t), sessions: mocks.NewSessionStore(t)}
36 f.clock.EXPECT().Now().Return(now).Maybe()36 f.clock.EXPECT().Now().Return(now).Maybe()
37- f.tokens = app.NewTokens(f.store, f.clock, f.sessions)37+ f.tokens = usecase.NewTokens(f.store, f.clock, f.sessions)
38- f.auth = app.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)38+ f.auth = usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
39 return f39 return f
40 }40 }
41 41

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close