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"
1010 entrypoint = ["./tmp/ermite", "serve"]
1111 # The web assets are embedded (go:embed static), so they need a rebuild too.
1212 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"]
1414 # *_templ.go is the output of templ generate: a .templ change triggers the
1515 # rebuild, the generated Go file does not trigger a second one.
1616 exclude_regex = ["_test\\.go", "_templ\\.go"]
M .hexcheck.yaml +14 -5
@@ -4,10 +4,10 @@ components:
44 role: core
55 paths:
66 - internal/domain/**
7- app:
7+ usecase:
88 role: usecase
99 paths:
10- - internal/app/**
10+ - internal/usecase/**
1111 ports:
1212 role: ports
1313 paths:
@@ -22,9 +22,17 @@ components:
2222 paths:
2323 - internal/clientlimit/**
2424 entrypoints:
25+ # cmd/ermite only calls app.Run.
2526 role: entrypoint
2627 paths:
2728 - 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/**
2836 config:
2937 role: entrypoint
3038 paths:
@@ -40,9 +48,10 @@ components:
4048 role: ignore
4149 paths:
4250 - internal/readertest/**
43-# Cross-layer contracts live in ports: adapters and app import ports and domain,
44-# never each other. hexcheck has no rule for an adapter importing app (only the
45-# reverse, no-infra-imports-in-usecase), so that direction is kept by review.
51+# Cross-layer contracts live in ports: adapters and use cases import ports and
52+# domain, never each other. hexcheck has no rule for an adapter importing a use
53+# case or internal/app; internal/boundary/boundary_test.go enforces the full
54+# import matrix.
4655 rules:
4756 no-adapter-imports-in-core: error
4857 no-infra-imports-in-usecase: error
M Dockerfile +1 -1
@@ -24,7 +24,7 @@ COPY . .
2424 ARG VERSION=
2525 RUN --mount=type=cache,target=/go/pkg/mod \
2626 --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}" \
2828 -o /out/ermite ./cmd/ermite
2929
3030 FROM alpine:3.24
M README.md +11 -0
@@ -98,6 +98,17 @@ go build ./cmd/ermite
9898 go test ./...
9999 ```
100100
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+
101112 ## Develop
102113
103114 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
66 import (
77 "fmt"
88 "os"
9- "runtime/debug"
9+
10+ "bnema.dev/ermite/internal/app"
1011 )
1112
1213 func main() {
13- if err := newRootCmd().Execute(); err != nil {
14+ if err := app.Run(os.Args[1:]); err != nil {
1415 fmt.Fprintln(os.Stderr, "ermite:", err)
15- os.Exit(1)
16+ os.Exit(app.ExitCode(err))
1617 }
1718 }
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 @@
22
33 // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
44 // 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 error
5+// of usecase and back, the interceptor that authenticates callers, and the error
66 // mapping. The same handlers serve remote clients over HTTP and the web in
77 // process (see InProcessClient).
88 package rpc
M internal/adapters/web/ASSETS.md +1 -1
@@ -18,7 +18,7 @@ content-hashed names. Nothing is fetched at runtime.
1818
1919 The three htmx files are the exact files the approved mockup was built with.
2020 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`).
2222
2323 `*_templ.go` files are generated by `go tool templ generate` (templ is pinned
2424 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 @@
44 // showcase, rendered with templ from the data of the ermite API.
55 //
66 // 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 the
7+// generated ermite-api Connect clients, which internal/app binds in process to the
88 // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use
99 // 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. The
10+// by the middleware internal/app puts in front of the handler: anonymous in P5. The
1111 // API then shows public repositories only; a private repository is
1212 // indistinguishable from a missing one, here as everywhere.
1313 //
R cmd/ermite/api_helpers_test.go → internal/app/api_helpers_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
R cmd/ermite/api_test.go → internal/app/api_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
R cmd/ermite/cli_binary_test.go → internal/app/cli_binary_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
R cmd/ermite/cli_e2e_test.go → internal/app/cli_e2e_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
R cmd/ermite/cli_fake_test.go → internal/app/cli_fake_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
R cmd/ermite/cli_mr.go → internal/app/cli_mr.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
R cmd/ermite/cli_mr_test.go → internal/app/cli_mr_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "strings"
R cmd/ermite/cli_repo.go → internal/app/cli_repo.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
R cmd/ermite/cli_repo_test.go → internal/app/cli_repo_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "errors"
R cmd/ermite/cli_signing.go → internal/app/cli_signing.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/cli_token.go → internal/app/cli_token.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
R cmd/ermite/cli_token_test.go → internal/app/cli_token_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "testing"
R cmd/ermite/cli_version.go → internal/app/cli_version.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/cli_version_test.go → internal/app/cli_version_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "errors"
R cmd/ermite/cli_watch.go → internal/app/cli_watch.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/cli_watch_test.go → internal/app/cli_watch_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
R cmd/ermite/cli_web.go → internal/app/cli_web.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "errors"
R cmd/ermite/clock.go → internal/app/clock.go +1 -1 renamed, 94% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import "time"
66
R cmd/ermite/datadir.go → internal/app/datadir.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/e2e_helpers_test.go → internal/app/e2e_helpers_test.go +2 -2 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
@@ -69,7 +69,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
6969 if o.trustedProxies != "" {
7070 env[config.EnvTrustedProxies] = o.trustedProxies
7171 }
72- // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite
72+ // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./internal/app
7373 if r := os.Getenv(config.EnvGitReader); r != "" {
7474 env[config.EnvGitReader] = r
7575 }
R cmd/ermite/e2e_test.go → internal/app/e2e_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
M internal/app/events_test.go +346 -825
@@ -1,883 +1,404 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package app
44
55 import (
66 "context"
77 "errors"
88 "fmt"
9- "sync"
10- "sync/atomic"
9+ "net/http"
10+ "os/exec"
11+ "syscall"
1112 "testing"
12- "testing/synctest"
1313 "time"
1414
15+ "connectrpc.com/connect"
1516 "github.com/stretchr/testify/assert"
16- "github.com/stretchr/testify/mock"
1717 "github.com/stretchr/testify/require"
18+ "google.golang.org/protobuf/types/known/fieldmaskpb"
1819
19- "bnema.dev/ermite/internal/app"
20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite/internal/adapters/rpc"
2022 "bnema.dev/ermite/internal/domain"
2123 "bnema.dev/ermite/internal/ports"
22- "bnema.dev/ermite/internal/ports/mocks"
24+ "bnema.dev/ermite/internal/usecase"
2325 )
2426
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 *app.Events
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())
3569 }
3670
37-func newEventRig(t *testing.T, size int) *eventRig {
38- t.Helper()
39- return newEventRigConfig(t, app.EventsConfig{Size: size})
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")
40105 }
41106
42-func newEventRigConfig(t *testing.T, cfg app.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
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"))
56119 }
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 := 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,
120+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
121+ ids = append(ids, w.next(t, watchWait).GetId())
89122 }
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+ 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())
123131 }
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())
125145 }
126146
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)
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())
141162 }
142-}
143163
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-}
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+ }
179172
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- })
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())
207176 }
208177
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
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)
230230 }
231231
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- })
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")
297241 }
298242
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- }
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())
319259 }
320260
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- })
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)
378273 }
379274
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- }
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
424298 }
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
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() {
434311 }
435- assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
436- })
437-}
312+ ended <- st.Err()
313+ }()
438314
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-}
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())
471319
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)
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())
481333 }
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))
483337 }
484338
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.
485341 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, app.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, 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- })
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),
565346 })
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")
566352
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, 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- })
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")
589356
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, app.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- })
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+ }
605366 }
606367
368+// Watch refuses too many topics, and too many streams from one client.
607369 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, 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- })
370+ h := newHarness(t, harnessOpts{})
371+ api := h.api(adminToken)
651372
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 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},
373+ topics := make([]string, usecase.MaxWatchTopics+1)
374+ for i := range topics {
375+ topics[i] = "home"
836376 }
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- })
377+ assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...)))
378+
379+ for range usecase.MaxWatchersPerClient {
380+ h.watch(api, "")
867381 }
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+}
868393
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- })
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)
883404 }
R cmd/ermite/forge.go → internal/app/forge.go +25 -25 renamed, 88% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
@@ -24,11 +24,11 @@ import (
2424 "bnema.dev/ermite/internal/adapters/rpc"
2525 "bnema.dev/ermite/internal/adapters/sshsig"
2626 "bnema.dev/ermite/internal/adapters/web"
27- "bnema.dev/ermite/internal/app"
2827 "bnema.dev/ermite/internal/clientlimit"
2928 "bnema.dev/ermite/internal/config"
3029 "bnema.dev/ermite/internal/domain"
3130 "bnema.dev/ermite/internal/ports"
31+ "bnema.dev/ermite/internal/usecase"
3232 )
3333
3434 // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in
@@ -40,13 +40,13 @@ type options struct {
4040 // hookExe is the executable the hook scripts exec; empty means this
4141 // process's own executable.
4242 hookExe string
43- // maintenanceDelay overrides app.MaintenanceDelay when positive.
43+ // maintenanceDelay overrides usecase.MaintenanceDelay when positive.
4444 maintenanceDelay time.Duration
4545 // maxBodyBytes overrides the limit of a push body when positive.
4646 maxBodyBytes int64
47- // eventBuffer overrides app.DefaultEventBuffer when positive.
47+ // eventBuffer overrides usecase.DefaultEventBuffer when positive.
4848 eventBuffer int
49- // revalidateEvery overrides app.DefaultRevalidateEvery when not zero.
49+ // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5050 revalidateEvery time.Duration
5151 }
5252
@@ -56,24 +56,24 @@ type forge struct {
5656 store *kvstore.Store
5757 server *httpserver.Server
5858 hooks *githttp.HookServer
59- maint *app.Maintainer
59+ maint *usecase.Maintainer
6060 git *gitcli.Git
6161 // reader is the ermite-git reader when ERMITE_GIT_READER selects it.
6262 reader *ermitegit.Adapter
6363
6464 // The use cases and the API are exposed for tests and for the phases
6565 // that add the web.
66- Repos *app.Repos
67- Tokens *app.Tokens
68- Auth *app.Auth
69- Pushes *app.Pushes
70- Browse *app.Browse
66+ Repos *usecase.Repos
67+ Tokens *usecase.Tokens
68+ Auth *usecase.Auth
69+ Pushes *usecase.Pushes
70+ Browse *usecase.Browse
7171 // MergeRequests and SigningKey serve MergeRequestService and
7272 // ServerService.GetSigningKey.
73- MergeRequests *app.MergeRequests
74- SigningKey *app.SigningKey
75- Sessions *app.Sessions
76- EventLog *app.Events
73+ MergeRequests *usecase.MergeRequests
74+ SigningKey *usecase.SigningKey
75+ Sessions *usecase.Sessions
76+ EventLog *usecase.Events
7777 API *rpc.API
7878 Bus ports.EventBus
7979 }
@@ -139,7 +139,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
139139
140140 clock := systemClock{}
141141 f.Bus = eventbus.New()
142- f.Repos = app.NewRepos(store, git, f.Bus, clock)
142+ f.Repos = usecase.NewRepos(store, git, f.Bus, clock)
143143 report, err := f.Repos.Reconcile(ctx)
144144 if err != nil {
145145 return nil, fmt.Errorf("reconcile repositories: %w", err)
@@ -150,10 +150,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
150150 Int("invalid_dirs", len(report.InvalidDirs)).
151151 Msg("repositories reconciled")
152152
153- f.Tokens = app.NewTokens(store.Tokens(), clock, store.Sessions())
154- f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
155- f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)
156- f.Pushes = app.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
153+ f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
154+ f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
155+ f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
156+ f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
157157
158158 exe := opts.hookExe
159159 if exe == "" {
@@ -181,8 +181,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
181181 reader = f.reader
182182 }
183183 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
184- f.Browse = app.NewBrowse(store, reader)
185- f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{
184+ f.Browse = usecase.NewBrowse(store, reader)
185+ f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
186186 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
187187 })
188188
@@ -196,14 +196,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
196196 return nil, err
197197 }
198198 committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
199- f.SigningKey = app.NewSigningKey(signer, committer)
200- f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{
199+ f.SigningKey = usecase.NewSigningKey(signer, committer)
200+ f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
201201 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
202202 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
203203 })
204204 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
205205
206- f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
206+ f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
207207 if err != nil {
208208 return nil, err
209209 }
R cmd/ermite/gitreader_test.go → internal/app/gitreader_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
R cmd/ermite/hook.go → internal/app/hook.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
R cmd/ermite/main_test.go → internal/app/main_test.go +2 -2 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
@@ -43,7 +43,7 @@ func binary(t *testing.T) string {
4343 return
4444 }
4545 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()
4747 if err != nil {
4848 buildErr = errors.New("go build: " + err.Error() + "\n" + string(out))
4949 }
R cmd/ermite/mr_api_test.go → internal/app/mr_api_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "os"
R cmd/ermite/root.go → internal/app/root.go +2 -2 renamed, 93% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "github.com/spf13/cobra"
@@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
2121 "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +
2222 "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",
2323 SilenceUsage: true,
24- SilenceErrors: true, // main prints the error once
24+ SilenceErrors: true, // cmd/ermite prints the error once
2525 }
2626 root.AddCommand(
2727 newServeCmd(), newHookCmd(),
R cmd/ermite/serve.go → internal/app/serve.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
@@ -17,11 +17,11 @@ import (
1717 "bnema.dev/ermite/internal/adapters/httpserver"
1818 "bnema.dev/ermite/internal/adapters/rpc"
1919 "bnema.dev/ermite/internal/adapters/web"
20- "bnema.dev/ermite/internal/app"
2120 "bnema.dev/ermite/internal/clientlimit"
2221 "bnema.dev/ermite/internal/config"
2322 "bnema.dev/ermite/internal/domain"
2423 "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/usecase"
2525 )
2626
2727 // newWeb builds the web pages on the API of this process: the generated
@@ -29,7 +29,7 @@ import (
2929 //
3030 // The owner's session cookie turns a request into the owner's (web.Server
3131 // 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) {
3333 hc := api.InProcessClient()
3434 srv, err := web.New(web.Config{
3535 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
@@ -100,7 +100,7 @@ func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClie
100100 // webSessions adapts the session use cases to ports.WebSessionService: a valid
101101 // token gives the context of the owner, whose principal the API calls of the
102102 // page carry (rpc.API.InProcessClient).
103-type webSessions struct{ *app.Sessions }
103+type webSessions struct{ *usecase.Sessions }
104104
105105 var _ ports.WebSessionService = webSessions{}
106106
@@ -116,8 +116,8 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura
116116 // that made the link may cut short. The session token carries its own
117117 // expiry, so a cookie that outlives it by under a second is harmless.
118118 // The expiry is truncated to the second, hence the slack on the full TTL.
119- lifetime := app.SessionTTL
120- if d := time.Until(expiresAt); d < app.SessionTTL-time.Second {
119+ lifetime := usecase.SessionTTL
120+ if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
121121 lifetime = d.Round(time.Second)
122122 }
123123 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/web_commits_test.go → internal/app/web_commits_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "fmt"
R cmd/ermite/web_events_test.go → internal/app/web_events_test.go +4 -4 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bufio"
@@ -15,9 +15,9 @@ import (
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/require"
1717
18- "bnema.dev/ermite/internal/app"
1918 "bnema.dev/ermite/internal/domain"
2019 "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
2121 )
2222
2323 // sseEvent is one event read from /-/events.
@@ -381,7 +381,7 @@ func TestWebEventsEndOnShutdown(t *testing.T) {
381381 // one address cannot take every watch slot of the forge.
382382 func TestWebEventsLimitPerVisitor(t *testing.T) {
383383 h := newHarness(t, harnessOpts{})
384- for range app.MaxWatchersPerClient {
384+ for range usecase.MaxWatchersPerClient {
385385 h.sse("topic=home", "")
386386 }
387387 req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil)
@@ -391,7 +391,7 @@ func TestWebEventsLimitPerVisitor(t *testing.T) {
391391 defer func() { _ = resp.Body.Close() }()
392392 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
393393 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())
395395 }
396396
397397 // 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "bytes"
R cmd/ermite/web_mr_test.go → internal/app/web_mr_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "html"
R cmd/ermite/web_session_test.go → internal/app/web_session_test.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "context"
R cmd/ermite/web_test.go → internal/app/web_test.go +2 -2 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package main
3+package app
44
55 import (
66 "flag"
@@ -100,7 +100,7 @@ func golden(t *testing.T, name, got string) {
100100 return
101101 }
102102 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")
104104 assert.Equal(t, string(want), got, name)
105105 }
106106
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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
33 // Package config parses and validates the server configuration from the
4-// environment. It is an entrypoint helper: only cmd imports it, and it never
4+// environment. It is a composition-root helper: only internal/app imports it, and it never
55 // reads the process environment itself, the caller passes a lookup function.
66 package config
77
M internal/ports/ports.go +3 -3
@@ -5,9 +5,9 @@
55 //
66 // Outbound ports (ports.go) are what the use cases need from the outside
77 // 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 app
9-// implement them. Adapters and app depend on this package and on domain, never
10-// on each other; cmd wires the implementations. All the mocks are generated in
8+// cases as the adapters that serve requests see them: the types of package usecase
9+// implement them. Adapters and use cases depend on this package and on domain,
10+// never on each other; internal/app wires the implementations. All the mocks are generated in
1111 // ports/mocks.
1212 package ports
1313
M internal/ports/services.go +16 -16
@@ -12,31 +12,31 @@ import (
1212 )
1313
1414 // 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 package
16-// app implement them, each assertion being made in app; the mocks in
15+// Git over HTTP, the web) and the composition root see them. The types of package
16+// usecase implement them, each assertion being made in usecase; the mocks in
1717 // ports/mocks stand in for them in the tests of the adapters.
1818
1919 // AuthService turns the secret of a request into a principal. It returns
20-// domain.ErrUnauthenticated for a secret that is not valid. *app.Auth
20+// domain.ErrUnauthenticated for a secret that is not valid. *usecase.Auth
2121 // implements it.
2222 type AuthService interface {
2323 Authenticate(ctx context.Context, secret string) (domain.Principal, error)
2424 }
2525
2626 // Revalidator confirms that a principal obtained earlier is still
27-// authorized. *app.Auth implements it.
27+// authorized. *usecase.Auth implements it.
2828 type Revalidator interface {
2929 Revalidate(ctx context.Context, p domain.Principal) error
3030 }
3131
3232 // RepoLookup is the read side of a single repository. A repository the
33-// principal may not read is domain.ErrNotFound. *app.Repos implements it, as
33+// principal may not read is domain.ErrNotFound. *usecase.Repos implements it, as
3434 // part of RepoService.
3535 type RepoLookup interface {
3636 Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)
3737 }
3838
39-// RepoService is the repository use cases the API serves. *app.Repos
39+// RepoService is the repository use cases the API serves. *usecase.Repos
4040 // implements it.
4141 type RepoService interface {
4242 RepoLookup
@@ -47,7 +47,7 @@ type RepoService interface {
4747 Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error
4848 }
4949
50-// TokenService is the access token use cases the API serves. *app.Tokens
50+// TokenService is the access token use cases the API serves. *usecase.Tokens
5151 // implements it.
5252 type TokenService interface {
5353 Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error)
@@ -55,7 +55,7 @@ type TokenService interface {
5555 Revoke(ctx context.Context, p domain.Principal, id string) error
5656 }
5757
58-// BrowseService is the read side of a repository's content. *app.Browse
58+// BrowseService is the read side of a repository's content. *usecase.Browse
5959 // implements it.
6060 type BrowseService interface {
6161 ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error)
@@ -66,12 +66,12 @@ type BrowseService interface {
6666 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
6767 }
6868
69-// EventService streams the events of the forge. *app.Events implements it.
69+// EventService streams the events of the forge. *usecase.Events implements it.
7070 type EventService interface {
7171 Watch(ctx context.Context, p domain.Principal, params WatchParams, send func(WatchEvent) error) error
7272 }
7373
74-// MergeRequestService is the merge request use cases. *app.MergeRequests
74+// MergeRequestService is the merge request use cases. *usecase.MergeRequests
7575 // implements it.
7676 type MergeRequestService interface {
7777 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
@@ -85,13 +85,13 @@ type MergeRequestService interface {
8585 }
8686
8787 // SigningKeyService tells which key signs the commits of the forge.
88-// *app.SigningKey implements it.
88+// *usecase.SigningKey implements it.
8989 type SigningKeyService interface {
9090 Info() domain.SigningKeyInfo
9191 }
9292
9393 // SessionService is the owner's web session use cases the API serves.
94-// *app.Sessions implements it.
94+// *usecase.Sessions implements it.
9595 type SessionService interface {
9696 CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error)
9797 SignOutEverywhere(ctx context.Context, p domain.Principal) error
@@ -102,7 +102,7 @@ type SessionService interface {
102102 var ErrLinkRejected = errors.New("login link rejected")
103103
104104 // 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.
106106 type WebSessionService interface {
107107 // LoginLinkValid reports whether the link is pending, without using it.
108108 LoginLinkValid(nonce string) bool
@@ -120,14 +120,14 @@ type WebSessionService interface {
120120 // for an anonymous caller, domain.ErrNotFound for a repository the caller may
121121 // not write and cannot see (or that is missing and may not be created),
122122 // domain.ErrForbidden for one it can see but not write. With create, a
123-// missing repository the principal may write is created. *app.Pushes
123+// missing repository the principal may write is created. *usecase.Pushes
124124 // implements it.
125125 type PushService interface {
126126 Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error
127127 }
128128
129129 // PushHookService is what the hook socket asks of the use cases.
130-// *app.Pushes implements it.
130+// *usecase.Pushes implements it.
131131 type PushHookService interface {
132132 // Check decides a push before Git applies it; an error matching
133133 // domain.ErrRejected refuses it, any other error refuses it as a failure.
@@ -136,7 +136,7 @@ type PushHookService interface {
136136 Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
137137 }
138138
139-// MaintenanceScheduler queues repository maintenance. *app.Maintainer
139+// MaintenanceScheduler queues repository maintenance. *usecase.Maintainer
140140 // implements it.
141141 type MaintenanceScheduler interface {
142142 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "fmt"
R internal/app/browse.go → internal/usecase/browse.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/browse_test.go → internal/usecase/browse_test.go +20 -20 renamed, 91% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -15,22 +15,22 @@ import (
1515 "github.com/stretchr/testify/mock"
1616 "github.com/stretchr/testify/require"
1717
18- "bnema.dev/ermite/internal/app"
1918 "bnema.dev/ermite/internal/domain"
2019 "bnema.dev/ermite/internal/ports"
2120 "bnema.dev/ermite/internal/ports/mocks"
21+ "bnema.dev/ermite/internal/usecase"
2222 )
2323
2424 type browseFixture struct {
2525 meta *mocks.RepoMetaStore
2626 reader *mocks.RepoReader
27- browse *app.Browse
27+ browse *usecase.Browse
2828 }
2929
3030 func newBrowseFixture(t *testing.T) *browseFixture {
3131 t.Helper()
3232 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)
3434 return f
3535 }
3636
@@ -46,7 +46,7 @@ func oid(c string) domain.ObjectID {
4646 // The reader expectation of each is what must (not) run.
4747 type browseOp struct {
4848 name string
49- call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
49+ call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error
5050 // expect registers the reader call a permitted caller makes.
5151 expect func(r *mocks.RepoReader)
5252 }
@@ -54,29 +54,29 @@ type browseOp struct {
5454 func browseOps() []browseOp {
5555 root := domain.TreePath{}
5656 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 {
5858 _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{})
5959 return err
6060 }, 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 {
6262 _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{})
6363 return err
6464 }, func(r *mocks.RepoReader) {
6565 r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil)
6666 }},
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 {
6868 _, err := b.ResolveRefPath(ctx, p, repo, "main/x")
6969 return err
7070 }, func(r *mocks.RepoReader) {
7171 r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil)
7272 }},
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 {
7474 _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true)
7575 return err
7676 }, func(r *mocks.RepoReader) {
7777 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
7878 }},
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 {
8080 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
8181 if rc != nil {
8282 _ = rc.Close()
@@ -86,13 +86,13 @@ func browseOps() []browseOp {
8686 r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
8787 Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil)
8888 }},
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 {
9090 _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{})
9191 return err
9292 }, func(r *mocks.RepoReader) {
9393 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
9494 }},
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 {
9696 _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
9797 return err
9898 }, func(r *mocks.RepoReader) {
@@ -201,7 +201,7 @@ func TestListRefsPagingAndKinds(t *testing.T) {
201201 }
202202 return out
203203 }
204- setup := func(t *testing.T, refs []domain.Ref) *app.Browse {
204+ setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse {
205205 f := newBrowseFixture(t)
206206 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
207207 f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil)
@@ -247,19 +247,19 @@ func TestListRefsPagingAndKinds(t *testing.T) {
247247 t.Run("default and maximum page size", func(t *testing.T) {
248248 var many []domain.Ref
249249 many = append(many, ref(domain.RefKindHead, "HEAD"))
250- for i := range app.MaxRefPageSize + 100 {
250+ for i := range usecase.MaxRefPageSize + 100 {
251251 many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i)))
252252 }
253253 b := setup(t, many)
254254 page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{})
255255 require.NoError(t, err)
256- assert.Len(t, page.Refs, app.DefaultPageSize)
256+ assert.Len(t, page.Refs, usecase.DefaultPageSize)
257257 assert.NotEmpty(t, page.NextPageToken)
258258
259259 b = setup(t, many)
260260 page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000})
261261 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")
263263 assert.NotEmpty(t, page.NextPageToken)
264264 })
265265
@@ -354,11 +354,11 @@ func TestListCommitsPaging(t *testing.T) {
354354 return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":")))
355355 }
356356
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 })).
358358 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)})
360360 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)})
362362 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
363363 })
364364
@@ -366,7 +366,7 @@ func TestListCommitsPaging(t *testing.T) {
366366 t.Parallel()
367367 f := newBrowseFixture(t)
368368 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)
370370 f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil)
371371 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
372372 require.NoError(t, err)
R internal/app/browsecache.go → internal/usecase/browsecache.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "container/list"
R internal/app/browsecache_test.go → internal/usecase/browsecache_test.go +5 -5 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -13,10 +13,10 @@ import (
1313 "github.com/stretchr/testify/mock"
1414 "github.com/stretchr/testify/require"
1515
16- "bnema.dev/ermite/internal/app"
1716 "bnema.dev/ermite/internal/domain"
1817 "bnema.dev/ermite/internal/ports"
1918 "bnema.dev/ermite/internal/ports/mocks"
19+ "bnema.dev/ermite/internal/usecase"
2020 )
2121
2222 func treePath(t *testing.T, s string) domain.TreePath {
@@ -118,19 +118,19 @@ func TestHistoryCacheNotServedAcrossRecreate(t *testing.T) {
118118
119119 tests := []struct {
120120 name string
121- call func(b *app.Browse) error
121+ call func(b *usecase.Browse) error
122122 // expect registers the reader call of the old repository (answers)
123123 // and of the new one (not found).
124124 expect func(r *mocks.RepoReader)
125125 }{
126- {"tree with last commits", func(b *app.Browse) error {
126+ {"tree with last commits", func(b *usecase.Browse) error {
127127 _, err := b.GetTree(ctx, domain.Anonymous(), n, rev, dir, true)
128128 return err
129129 }, func(r *mocks.RepoReader) {
130130 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(treeWithLast("b"), nil).Once()
131131 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(domain.Tree{}, domain.ErrNotFound).Once()
132132 }},
133- {"last change of a path", func(b *app.Browse) error {
133+ {"last change of a path", func(b *usecase.Browse) error {
134134 _, err := b.ListCommits(ctx, domain.Anonymous(), n, ports.ListCommitsParams{Revision: rev, Path: dir, PageSize: 1})
135135 return err
136136 }, func(r *mocks.RepoReader) {
R internal/app/events.go → internal/usecase/events.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import "bnema.dev/ermite/internal/domain"
66
R internal/app/maintenance.go → internal/usecase/maintenance.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/maintenance_test.go → internal/usecase/maintenance_test.go +16 -16 renamed, 86% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -15,9 +15,9 @@ import (
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/require"
1717
18- "bnema.dev/ermite/internal/app"
1918 "bnema.dev/ermite/internal/domain"
2019 "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
2121 )
2222
2323 // Every test runs in a synctest bubble with a fake clock: the delays are the
@@ -35,13 +35,13 @@ func TestMaintainerDebounces(t *testing.T) {
3535 runs.Add(1)
3636 return nil
3737 })
38- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
38+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
3939 defer m.Close(ctx)
4040
4141 for range 10 {
4242 m.Schedule(ctx, n)
4343 }
44- synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
44+ synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond)
4545 assert.Equal(t, int32(0), runs.Load(), "waits for the full delay")
4646 synctest.Sleep(time.Nanosecond)
4747 assert.Equal(t, int32(1), runs.Load(), "a burst makes one run")
@@ -49,7 +49,7 @@ func TestMaintainerDebounces(t *testing.T) {
4949 assert.Equal(t, int32(1), runs.Load(), "no second run is coming")
5050
5151 m.Schedule(ctx, n)
52- synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
52+ synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond)
5353 assert.Equal(t, int32(1), runs.Load(), "a second burst waits for its own delay")
5454 synctest.Sleep(time.Nanosecond)
5555 assert.Equal(t, int32(2), runs.Load(), "a second burst, a second run")
@@ -82,8 +82,8 @@ func TestMaintainerOneRunAtATimePerRepo(t *testing.T) {
8282 otherRan.Store(true)
8383 return nil
8484 })
85- const d = app.MaintenanceDelay
86- m := app.NewMaintainer(ctx, git, d)
85+ const d = usecase.MaintenanceDelay
86+ m := usecase.NewMaintainer(ctx, git, d)
8787 defer m.Close(ctx)
8888
8989 m.Schedule(ctx, n)
@@ -121,9 +121,9 @@ func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
121121 told := make(chan struct{}, 2)
122122 fresh := mocks.NewRepoFreshener(t)
123123 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)
125125 m.Schedule(ctx, n)
126- synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
126+ synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond)
127127 assert.Empty(t, told, "not before the run")
128128 synctest.Sleep(time.Nanosecond)
129129 assert.Len(t, told, 1, "the reader is told when the run ends")
@@ -146,13 +146,13 @@ func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {
146146 runs.Add(1)
147147 return errors.New("boom")
148148 })
149- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
149+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
150150 defer m.Close(ctx)
151151 m.Schedule(ctx, n)
152- synctest.Sleep(app.MaintenanceDelay)
152+ synctest.Sleep(usecase.MaintenanceDelay)
153153 assert.Equal(t, int32(1), runs.Load())
154154 m.Schedule(ctx, n)
155- synctest.Sleep(app.MaintenanceDelay)
155+ synctest.Sleep(usecase.MaintenanceDelay)
156156 assert.Equal(t, int32(2), runs.Load(), "later runs still happen")
157157 })
158158 }
@@ -166,7 +166,7 @@ func TestMaintainerClose(t *testing.T) {
166166 t.Parallel()
167167 synctest.Test(t, func(t *testing.T) {
168168 git := mocks.NewGitRepos(t) // no Maintain expected
169- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
169+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
170170 m.Schedule(ctx, n)
171171 m.Close(ctx)
172172 m.Schedule(ctx, n) // after Close: ignored
@@ -185,9 +185,9 @@ func TestMaintainerClose(t *testing.T) {
185185 cancelled.Store(true)
186186 return ctx.Err()
187187 })
188- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
188+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
189189 m.Schedule(ctx, n)
190- synctest.Sleep(app.MaintenanceDelay) // the run is blocked in Maintain
190+ synctest.Sleep(usecase.MaintenanceDelay) // the run is blocked in Maintain
191191 assert.False(t, cancelled.Load())
192192 m.Close(ctx) // a hang is a bubble deadlock: reported, no timeout needed
193193 assert.True(t, cancelled.Load())
@@ -201,7 +201,7 @@ func TestMaintainerConcurrentSchedule(t *testing.T) {
201201 ctx := context.Background()
202202 git := mocks.NewGitRepos(t)
203203 git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()
204- m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
204+ m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay)
205205 var wg sync.WaitGroup
206206 for i := range 8 {
207207 wg.Add(1)
R internal/app/mergerequests.go → internal/usecase/mergerequests.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/mergerequests_delete_test.go → internal/usecase/mergerequests_delete_test.go +3 -3 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -10,9 +10,9 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "bnema.dev/ermite/internal/adapters/gitcli"
13- "bnema.dev/ermite/internal/app"
1413 "bnema.dev/ermite/internal/domain"
1514 "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
1616 )
1717
1818 // A merge can delete its source branch; the push event carries both refs.
@@ -181,7 +181,7 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
181181 head := r.feature("feat")
182182 mr := r.open("feat")
183183 var moved domain.ObjectID
184- mrs := app.NewMergeRequests(app.MergeRequestsConfig{
184+ mrs := usecase.NewMergeRequests(usecase.MergeRequestsConfig{
185185 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
186186 Git: movingGit{Git: r.git, move: func() {
187187 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "bytes"
@@ -22,10 +22,10 @@ import (
2222 "bnema.dev/ermite/internal/adapters/gitcli"
2323 "bnema.dev/ermite/internal/adapters/kvstore"
2424 "bnema.dev/ermite/internal/adapters/sshsig"
25- "bnema.dev/ermite/internal/app"
2625 "bnema.dev/ermite/internal/domain"
2726 "bnema.dev/ermite/internal/ports"
2827 "bnema.dev/ermite/internal/ports/mocks"
28+ "bnema.dev/ermite/internal/usecase"
2929 )
3030
3131 // queuedRepos records the repositories whose maintenance was queued.
@@ -53,9 +53,9 @@ type mrRig struct {
5353 t *testing.T
5454 ctx context.Context
5555 data string
56- repos *app.Repos
57- mrs *app.MergeRequests
58- key *app.SigningKey
56+ repos *usecase.Repos
57+ mrs *usecase.MergeRequests
58+ key *usecase.SigningKey
5959 store *kvstore.Store
6060 git *gitcli.Git
6161 bus *eventbus.Bus
@@ -101,12 +101,12 @@ func newMRRig(t *testing.T) *mrRig {
101101 r := &mrRig{
102102 sched: sched, queued: queued,
103103 t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
104- repos: app.NewRepos(store, git, bus, fixedClock{}),
105- key: app.NewSigningKey(signer, committer),
104+ repos: usecase.NewRepos(store, git, bus, fixedClock{}),
105+ key: usecase.NewSigningKey(signer, committer),
106106 work: t.TempDir(),
107107 repo: name(t, "proj"),
108108 }
109- r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
109+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
110110 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
111111 Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched,
112112 })
@@ -543,14 +543,14 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) {
543543 r := newMRRig(t)
544544 r.seed()
545545 r.sh("checkout", "-q", "-B", "long", "main")
546- for i := range app.MaxMergeRequestCommits + 5 {
546+ for i := range usecase.MaxMergeRequestCommits + 5 {
547547 r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i))
548548 }
549549 r.push("long")
550550 mr := r.open("long")
551551 d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
552552 require.NoError(t, err)
553- assert.Len(t, d.Commits, app.MaxMergeRequestCommits)
553+ assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits)
554554 assert.True(t, d.CommitsTruncated)
555555 assert.Equal(t, "c104", d.Commits[0].Summary)
556556 }
@@ -1285,7 +1285,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
12851285 r.seed()
12861286 r.feature("feat")
12871287 mr := r.open("feat")
1288- for i := range app.MaxCommentsPerMergeRequest {
1288+ for i := range usecase.MaxCommentsPerMergeRequest {
12891289 _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
12901290 require.NoError(t, err, "comment %d", i+1)
12911291 }
@@ -1293,7 +1293,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
12931293 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
12941294 d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
12951295 require.NoError(t, err)
1296- assert.Len(t, d.Comments, app.MaxCommentsPerMergeRequest)
1296+ assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)
12971297
12981298 // Resolving still works, and another request has its own budget.
12991299 _, 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -11,10 +11,10 @@ import (
1111 "github.com/stretchr/testify/mock"
1212 "github.com/stretchr/testify/require"
1313
14- "bnema.dev/ermite/internal/app"
1514 "bnema.dev/ermite/internal/domain"
1615 "bnema.dev/ermite/internal/ports"
1716 "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
1818 )
1919
2020 type mrUnit struct {
@@ -22,7 +22,7 @@ type mrUnit struct {
2222 store *mocks.MergeRequestStore
2323 repos *mocks.GitRepos
2424 git *mocks.MergeGit
25- mrs *app.MergeRequests
25+ mrs *usecase.MergeRequests
2626 repo domain.Repo
2727 }
2828
@@ -39,7 +39,7 @@ func newMRUnit(t *testing.T) *mrUnit {
3939 clock.EXPECT().Now().Return(now).Maybe()
4040 sched := mocks.NewMaintenanceScheduler(t)
4141 sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
42- u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
42+ u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
4343 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
4444 Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
4545 })
R internal/app/paging.go → internal/usecase/paging.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/pushes.go → internal/usecase/pushes.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/pushes_test.go → internal/usecase/pushes_test.go +6 -6 renamed, 97% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -12,10 +12,10 @@ import (
1212 "github.com/stretchr/testify/mock"
1313 "github.com/stretchr/testify/require"
1414
15- "bnema.dev/ermite/internal/app"
1615 "bnema.dev/ermite/internal/domain"
1716 "bnema.dev/ermite/internal/ports"
1817 "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
1919 )
2020
2121 type pushFixture struct {
@@ -25,7 +25,7 @@ type pushFixture struct {
2525 clock *mocks.Clock
2626 tokens *mocks.TokenStore
2727 sched *mocks.MaintenanceScheduler
28- pushes *app.Pushes
28+ pushes *usecase.Pushes
2929 }
3030
3131 func newPushFixture(t *testing.T) *pushFixture {
@@ -39,9 +39,9 @@ func newPushFixture(t *testing.T) *pushFixture {
3939 sched: mocks.NewMaintenanceScheduler(t),
4040 }
4141 f.clock.EXPECT().Now().Return(now).Maybe()
42- repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)
43- auth := app.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)
42+ repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
43+ auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
44+ f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
4545 return f
4646 }
4747
R internal/app/repos.go → internal/usecase/repos.go +2 -2 renamed, 98% similar
@@ -1,8 +1,8 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-// Package app holds the use cases of ermite. It depends on the domain and on
3+// Package usecase holds the use cases of ermite. It depends on the domain and on
44 // the ports only.
5-package app
5+package usecase
66
77 import (
88 "context"
R internal/app/repos_integration_test.go → internal/usecase/repos_integration_test.go +3 -3 renamed, 95% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -18,9 +18,9 @@ import (
1818 "bnema.dev/ermite/internal/adapters/eventbus"
1919 "bnema.dev/ermite/internal/adapters/gitcli"
2020 "bnema.dev/ermite/internal/adapters/kvstore"
21- "bnema.dev/ermite/internal/app"
2221 "bnema.dev/ermite/internal/domain"
2322 "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/usecase"
2424 )
2525
2626 type fixedClock struct{}
@@ -47,7 +47,7 @@ func TestCreateConcurrentSameNameRealAdapters(t *testing.T) {
4747 require.NoError(t, err)
4848 t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
4949
50- repos := app.NewRepos(store, git, eventbus.New(), fixedClock{})
50+ repos := usecase.NewRepos(store, git, eventbus.New(), fixedClock{})
5151 n := name(t, "tools")
5252
5353 const workers = 8
R internal/app/repos_test.go → internal/usecase/repos_test.go +4 -4 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -12,10 +12,10 @@ import (
1212 "github.com/stretchr/testify/mock"
1313 "github.com/stretchr/testify/require"
1414
15- "bnema.dev/ermite/internal/app"
1615 "bnema.dev/ermite/internal/domain"
1716 "bnema.dev/ermite/internal/ports"
1817 "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
1919 )
2020
2121 var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
@@ -25,7 +25,7 @@ type fixture struct {
2525 git *mocks.GitRepos
2626 clock *mocks.Clock
2727 bus *mocks.EventBus
28- repos *app.Repos
28+ repos *usecase.Repos
2929 }
3030
3131 func newFixture(t *testing.T) *fixture {
@@ -37,7 +37,7 @@ func newFixture(t *testing.T) *fixture {
3737 bus: mocks.NewEventBus(t),
3838 }
3939 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)
4141 return f
4242 }
4343
R internal/app/repos_write.go → internal/usecase/repos_write.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/repos_write_test.go → internal/usecase/repos_write_test.go +7 -7 renamed, 96% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -13,9 +13,9 @@ import (
1313 "github.com/stretchr/testify/mock"
1414 "github.com/stretchr/testify/require"
1515
16- "bnema.dev/ermite/internal/app"
1716 "bnema.dev/ermite/internal/domain"
1817 "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/usecase"
1919 )
2020
2121 func ptr[T any](v T) *T { return &v }
@@ -128,7 +128,7 @@ func TestUpdate(t *testing.T) {
128128 "empty": {},
129129 "empty branch": {DefaultBranch: ptr("")},
130130 "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))},
132132 "bad utf-8": {Description: ptr("\xff")},
133133 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
134134 } {
@@ -232,7 +232,7 @@ func TestListPageSizes(t *testing.T) {
232232 t.Parallel()
233233 ctx := context.Background()
234234 var all []domain.Repo
235- for i := range app.MaxRepoPageSize + 10 {
235+ for i := range usecase.MaxRepoPageSize + 10 {
236236 all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic})
237237 }
238238 for _, tt := range []struct {
@@ -241,10 +241,10 @@ func TestListPageSizes(t *testing.T) {
241241 want int
242242 more bool
243243 }{
244- {"default", 0, app.DefaultPageSize, true},
244+ {"default", 0, usecase.DefaultPageSize, true},
245245 {"within the maximum", 500, 500, true},
246- {"the maximum", app.MaxRepoPageSize, app.MaxRepoPageSize, true},
247- {"lowered to the maximum", app.MaxRepoPageSize * 10, app.MaxRepoPageSize, true},
246+ {"the maximum", usecase.MaxRepoPageSize, usecase.MaxRepoPageSize, true},
247+ {"lowered to the maximum", usecase.MaxRepoPageSize * 10, usecase.MaxRepoPageSize, true},
248248 } {
249249 t.Run(tt.name, func(t *testing.T) {
250250 t.Parallel()
R internal/app/sessions.go → internal/usecase/sessions.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/sessions_test.go → internal/usecase/sessions_test.go +18 -18 renamed, 91% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -15,16 +15,16 @@ import (
1515 "github.com/stretchr/testify/mock"
1616 "github.com/stretchr/testify/require"
1717
18- "bnema.dev/ermite/internal/app"
1918 "bnema.dev/ermite/internal/domain"
2019 "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
2121 )
2222
2323 type sessionFixture struct {
2424 store *mocks.SessionStore
2525 clock *mocks.Clock
2626 at atomic.Pointer[time.Time] // what the clock reads; the test moves it
27- sessions *app.Sessions
27+ sessions *usecase.Sessions
2828 key []byte
2929 }
3030
@@ -50,7 +50,7 @@ func newSessionFixture(t *testing.T) *sessionFixture {
5050 f.store.EXPECT().Epoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Load(), nil }).Maybe()
5151 f.store.EXPECT().BumpEpoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Add(1), nil }).Maybe()
5252
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")
5454 require.NoError(t, err)
5555 f.sessions = s
5656 return f
@@ -120,7 +120,7 @@ func TestLoginLinkLifecycle(t *testing.T) {
120120
121121 link, err := f.sessions.CreateLoginLink(ctx, domain.Root())
122122 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)
124124 nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/")
125125 assert.Len(t, nonce, 43, "256 bits")
126126
@@ -130,14 +130,14 @@ func TestLoginLinkLifecycle(t *testing.T) {
130130 token, exp, err := f.sessions.Login(ctx, nonce)
131131 require.NoError(t, err)
132132 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)
134134 assert.False(t, f.sessions.LoginLinkValid(nonce))
135135 _, _, err = f.sessions.Login(ctx, nonce)
136136 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay")
137137
138138 // Expired.
139139 nonce = f.nonce(t)
140- f.advance(app.LoginLinkTTL)
140+ f.advance(usecase.LoginLinkTTL)
141141 assert.False(t, f.sessions.LoginLinkValid(nonce))
142142 _, _, err = f.sessions.Login(ctx, nonce)
143143 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")
@@ -174,12 +174,12 @@ func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) {
174174 p domain.Principal
175175 want time.Time
176176 }{
177- "root": {domain.Root(), start.Add(app.SessionTTL)},
178- "owner session": {domain.Owner(), start.Add(app.SessionTTL)},
179- "never expires": {allScopeToken(), start.Add(app.SessionTTL)},
177+ "root": {domain.Root(), start.Add(usecase.SessionTTL)},
178+ "owner session": {domain.Owner(), start.Add(usecase.SessionTTL)},
179+ "never expires": {allScopeToken(), start.Add(usecase.SessionTTL)},
180180 "expires sooner": {tokenExpiring(2 * time.Hour), start.Add(2 * time.Hour)},
181- "expires later": {tokenExpiring(48 * time.Hour), start.Add(app.SessionTTL)},
182- "expires exactly TTL": {tokenExpiring(app.SessionTTL), start.Add(app.SessionTTL)},
181+ "expires later": {tokenExpiring(48 * time.Hour), start.Add(usecase.SessionTTL)},
182+ "expires exactly TTL": {tokenExpiring(usecase.SessionTTL), start.Add(usecase.SessionTTL)},
183183 } {
184184 tok, exp, err := login(tc.p)
185185 require.NoError(t, err, name)
@@ -238,13 +238,13 @@ func TestLoginLinksAreBounded(t *testing.T) {
238238 t.Parallel()
239239 ctx := context.Background()
240240 f := newSessionFixture(t)
241- for range app.MaxPendingLoginLinks {
241+ for range usecase.MaxPendingLoginLinks {
242242 f.nonce(t)
243243 }
244244 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())
245245 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
246246
247- f.advance(app.LoginLinkTTL)
247+ f.advance(usecase.LoginLinkTTL)
248248 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())
249249 assert.NoError(t, err, "expired links make room")
250250 }
@@ -308,7 +308,7 @@ func TestSessionTokenVerification(t *testing.T) {
308308 assert.NoError(t, err)
309309
310310 // Expiry after the session lifetime; and the epoch ends every session.
311- f.advance(app.SessionTTL)
311+ f.advance(usecase.SessionTTL)
312312 _, err = f.sessions.Verify(ctx, token)
313313 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")
314314 }
@@ -339,7 +339,7 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) {
339339 // signs as "root" although both hold every right.
340340 func TestAuthorOfOwnerSession(t *testing.T) {
341341 t.Parallel()
342- assert.Equal(t, "owner", app.AuthorOf(domain.Owner()))
343- assert.Equal(t, "root", app.AuthorOf(domain.Root()))
344- assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
342+ assert.Equal(t, "owner", usecase.AuthorOf(domain.Owner()))
343+ assert.Equal(t, "root", usecase.AuthorOf(domain.Root()))
344+ assert.Equal(t, "ci", usecase.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
345345 }
R internal/app/signing.go → internal/usecase/signing.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "bnema.dev/ermite/internal/domain"
R internal/app/tokens.go → internal/usecase/tokens.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app
3+package usecase
44
55 import (
66 "context"
R internal/app/tokens_test.go → internal/usecase/tokens_test.go +6 -6 renamed, 98% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package app_test
3+package usecase_test
44
55 import (
66 "context"
@@ -14,18 +14,18 @@ import (
1414 "github.com/stretchr/testify/mock"
1515 "github.com/stretchr/testify/require"
1616
17- "bnema.dev/ermite/internal/app"
1817 "bnema.dev/ermite/internal/domain"
1918 "bnema.dev/ermite/internal/ports"
2019 "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
2121 )
2222
2323 type tokenFixture struct {
2424 store *mocks.TokenStore
2525 clock *mocks.Clock
2626 sessions *mocks.SessionStore
27- tokens *app.Tokens
28- auth *app.Auth
27+ tokens *usecase.Tokens
28+ auth *usecase.Auth
2929 }
3030
3131 var rootSecret = "root-secret-0123456789abcdef0123456789"
@@ -34,8 +34,8 @@ func newTokenFixture(t *testing.T) *tokenFixture {
3434 t.Helper()
3535 f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t), sessions: mocks.NewSessionStore(t)}
3636 f.clock.EXPECT().Now().Return(now).Maybe()
37- f.tokens = app.NewTokens(f.store, f.clock, f.sessions)
38- f.auth = app.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
37+ f.tokens = usecase.NewTokens(f.store, f.clock, f.sessions)
38+ f.auth = usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
3939 return f
4040 }
4141

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 41

↑↓ moveenter openesc close