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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
111 files · +1809 -1476
M .air.toml +1 -1
| @@ -10,7 +10,7 @@ tmp_dir = "tmp" | |||
| 10 | 10 | entrypoint = ["./tmp/ermite", "serve"] | |
| 11 | 11 | # The web assets are embedded (go:embed static), so they need a rebuild too. | |
| 12 | 12 | include_ext = ["go", "templ", "css", "js", "svg", "json", "woff2"] | |
| 13 | - | exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "cmd/ermite/testdata"] | |
| 13 | + | exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "internal/app/testdata"] | |
| 14 | 14 | # *_templ.go is the output of templ generate: a .templ change triggers the | |
| 15 | 15 | # rebuild, the generated Go file does not trigger a second one. | |
| 16 | 16 | exclude_regex = ["_test\\.go", "_templ\\.go"] | |
M .hexcheck.yaml +14 -5
| @@ -4,10 +4,10 @@ components: | |||
| 4 | 4 | role: core | |
| 5 | 5 | paths: | |
| 6 | 6 | - internal/domain/** | |
| 7 | - | app: | |
| 7 | + | usecase: | |
| 8 | 8 | role: usecase | |
| 9 | 9 | paths: | |
| 10 | - | - internal/app/** | |
| 10 | + | - internal/usecase/** | |
| 11 | 11 | ports: | |
| 12 | 12 | role: ports | |
| 13 | 13 | paths: | |
| @@ -22,9 +22,17 @@ components: | |||
| 22 | 22 | paths: | |
| 23 | 23 | - internal/clientlimit/** | |
| 24 | 24 | entrypoints: | |
| 25 | + | # cmd/ermite only calls app.Run. | |
| 25 | 26 | role: entrypoint | |
| 26 | 27 | paths: | |
| 27 | 28 | - cmd/** | |
| 29 | + | app: | |
| 30 | + | # Composition root and CLI: builds the adapters and wires them to the use | |
| 31 | + | # cases. hexcheck has no dedicated bootstrap role; entrypoint is the | |
| 32 | + | # closest. | |
| 33 | + | role: entrypoint | |
| 34 | + | paths: | |
| 35 | + | - internal/app/** | |
| 28 | 36 | config: | |
| 29 | 37 | role: entrypoint | |
| 30 | 38 | paths: | |
| @@ -40,9 +48,10 @@ components: | |||
| 40 | 48 | role: ignore | |
| 41 | 49 | paths: | |
| 42 | 50 | - 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. | |
| 46 | 55 | rules: | |
| 47 | 56 | no-adapter-imports-in-core: error | |
| 48 | 57 | no-infra-imports-in-usecase: error | |
M Dockerfile +1 -1
| @@ -24,7 +24,7 @@ COPY . . | |||
| 24 | 24 | ARG VERSION= | |
| 25 | 25 | RUN --mount=type=cache,target=/go/pkg/mod \ | |
| 26 | 26 | --mount=type=cache,target=/root/.cache/go-build \ | |
| 27 | - | go build -trimpath -buildvcs=false -ldflags "-s -w -X main.stamped=${VERSION}" \ | |
| 27 | + | go build -trimpath -buildvcs=false -ldflags "-s -w -X bnema.dev/ermite/internal/app.stamped=${VERSION}" \ | |
| 28 | 28 | -o /out/ermite ./cmd/ermite | |
| 29 | 29 | ||
| 30 | 30 | FROM alpine:3.24 | |
M README.md +11 -0
| @@ -98,6 +98,17 @@ go build ./cmd/ermite | |||
| 98 | 98 | go test ./... | |
| 99 | 99 | ``` | |
| 100 | 100 | ||
| 101 | + | ## Architecture | |
| 102 | + | ||
| 103 | + | `cmd/ermite` (entry point, calls `app.Run`) → `internal/app` (CLI and composition root: builds the adapters and wires them to the use cases) → `internal/usecase` (use cases), `internal/ports` (interfaces and parameter types that cross layers), `internal/domain` (pure values and rules) and `internal/adapters` (Git, KV store, HTTP, API, web, client implementations of the ports). | |
| 104 | + | ||
| 105 | + | - `internal/usecase` imports only `internal/domain` and `internal/ports`. | |
| 106 | + | - Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter. | |
| 107 | + | - `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module. | |
| 108 | + | - `internal/config` (environment parsing) is used by `internal/app` only. | |
| 109 | + | ||
| 110 | + | `internal/boundary/boundary_test.go` enforces these import rules on production files; `hexcheck` (`.hexcheck.yaml`) checks the layering of types and behaviour. The release version is stamped with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`. | |
| 111 | + | ||
| 101 | 112 | ## Develop | |
| 102 | 113 | ||
| 103 | 114 | Tools are pinned in `go.mod`: | |
D cmd/ermite/events_test.go +0 -404
| @@ -1,404 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | - | ||
| 3 | - | package main | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "net/http" | |
| 10 | - | "os/exec" | |
| 11 | - | "syscall" | |
| 12 | - | "testing" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "connectrpc.com/connect" | |
| 16 | - | "github.com/stretchr/testify/assert" | |
| 17 | - | "github.com/stretchr/testify/require" | |
| 18 | - | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 19 | - | ||
| 20 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | - | "bnema.dev/ermite/internal/adapters/rpc" | |
| 22 | - | "bnema.dev/ermite/internal/app" | |
| 23 | - | "bnema.dev/ermite/internal/domain" | |
| 24 | - | "bnema.dev/ermite/internal/ports" | |
| 25 | - | ) | |
| 26 | - | ||
| 27 | - | // watchWait is how long a test waits for an event that must arrive. Only | |
| 28 | - | // TestAC5PushEventWithinASecond holds the server to a second. | |
| 29 | - | const watchWait = 10 * time.Second | |
| 30 | - | ||
| 31 | - | // AC5: a push shows up in Watch within a second, with the ref updates. | |
| 32 | - | func TestAC5PushEventWithinASecond(t *testing.T) { | |
| 33 | - | h := newHarness(t, harnessOpts{}) | |
| 34 | - | h.seed("pub", domain.VisibilityPublic) | |
| 35 | - | w := h.watch(h.api(""), "", "home") | |
| 36 | - | ||
| 37 | - | wt := h.workTree("main", "a.txt") | |
| 38 | - | start := time.Now() | |
| 39 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 40 | - | ev := w.next(t, time.Second) | |
| 41 | - | assert.Less(t, time.Since(start), 2*time.Second) | |
| 42 | - | ||
| 43 | - | pushed := ev.GetPushed() | |
| 44 | - | require.NotNil(t, pushed, "got %v", ev) | |
| 45 | - | assert.Equal(t, "pub", pushed.GetRepo()) | |
| 46 | - | require.Len(t, pushed.GetRefUpdates(), 1) | |
| 47 | - | up := pushed.GetRefUpdates()[0] | |
| 48 | - | assert.Equal(t, "refs/heads/main", up.GetRef()) | |
| 49 | - | assert.Empty(t, up.GetOldId(), "a created ref has no old id") | |
| 50 | - | assert.Equal(t, h.bareGit("pub", "rev-parse", "main"), up.GetNewId()) | |
| 51 | - | assert.NotEmpty(t, ev.GetId()) | |
| 52 | - | assert.NotNil(t, ev.GetTime()) | |
| 53 | - | ||
| 54 | - | // A second push to the same ref carries both ids; a deletion has no new id. | |
| 55 | - | old := up.GetNewId() | |
| 56 | - | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 57 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 58 | - | up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 59 | - | assert.Equal(t, old, up2.GetOldId()) | |
| 60 | - | assert.NotEqual(t, old, up2.GetNewId()) | |
| 61 | - | ||
| 62 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | |
| 63 | - | w.next(t, watchWait) | |
| 64 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | |
| 65 | - | del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 66 | - | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | |
| 67 | - | assert.NotEmpty(t, del.GetOldId()) | |
| 68 | - | assert.Empty(t, del.GetNewId()) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // AC5: creating, updating and deleting repositories through the API publish | |
| 72 | - | // RepoCreated, RepoUpdated and RepoDeleted; so does a create-on-push. | |
| 73 | - | func TestAC5RepoLifecycleEvents(t *testing.T) { | |
| 74 | - | h := newHarness(t, harnessOpts{}) | |
| 75 | - | root := h.api(adminToken) | |
| 76 | - | w := h.watch(root, "") | |
| 77 | - | ||
| 78 | - | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) | |
| 79 | - | require.NoError(t, err) | |
| 80 | - | created := w.next(t, watchWait).GetRepoCreated().GetRepo() | |
| 81 | - | require.NotNil(t, created) | |
| 82 | - | assert.Equal(t, "life", created.GetName()) | |
| 83 | - | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) | |
| 84 | - | assert.Equal(t, "main", created.GetDefaultBranch()) | |
| 85 | - | ||
| 86 | - | _, err = root.repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 87 | - | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 88 | - | })) | |
| 89 | - | require.NoError(t, err) | |
| 90 | - | updated := w.next(t, watchWait).GetRepoUpdated().GetRepo() | |
| 91 | - | require.NotNil(t, updated) | |
| 92 | - | assert.Equal(t, "new", updated.GetDescription()) | |
| 93 | - | ||
| 94 | - | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) | |
| 95 | - | require.NoError(t, err) | |
| 96 | - | assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 97 | - | ||
| 98 | - | // Create on first push publishes RepoCreated once, then the push. | |
| 99 | - | wt := h.workTree("main", "a.txt") | |
| 100 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") | |
| 101 | - | assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 102 | - | assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 103 | - | // Nothing else came in between: the next event is the sentinel. | |
| 104 | - | h.sentinel(t, w, "s-life") | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // AC5: resume from an id replays what came after, then goes live. | |
| 108 | - | func TestAC5Resume(t *testing.T) { | |
| 109 | - | h := newHarness(t, harnessOpts{}) | |
| 110 | - | h.seed("pub", domain.VisibilityPublic) | |
| 111 | - | api := h.api("") | |
| 112 | - | w := h.watch(api, "") | |
| 113 | - | ||
| 114 | - | wt := h.workTree("main", "a.txt") | |
| 115 | - | var ids []string | |
| 116 | - | for i := range 4 { | |
| 117 | - | if i > 0 { | |
| 118 | - | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 119 | - | } | |
| 120 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 121 | - | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 122 | - | } | |
| 123 | - | w.cancel() | |
| 124 | - | ||
| 125 | - | // The client lost its connection after ids[1]: it gets 2 and 3, then live. | |
| 126 | - | r := h.watch(api, ids[1]) | |
| 127 | - | for _, want := range ids[2:] { | |
| 128 | - | ev := r.next(t, watchWait) | |
| 129 | - | assert.Equal(t, want, ev.GetId()) | |
| 130 | - | assert.NotNil(t, ev.GetPushed()) | |
| 131 | - | } | |
| 132 | - | // Nothing was replayed twice: the next event is the live one, below. | |
| 133 | - | h.writeFile(wt, "live.txt", []byte("x\n")) | |
| 134 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 135 | - | live := r.next(t, watchWait) | |
| 136 | - | assert.NotNil(t, live.GetPushed()) | |
| 137 | - | assert.NotContains(t, ids, live.GetId()) | |
| 138 | - | ||
| 139 | - | // An id nobody issued is a gap, followed by the oldest buffered event: the | |
| 140 | - | // creation of the repository, which came before the first push. | |
| 141 | - | g := h.watch(api, "not-an-id") | |
| 142 | - | assert.NotNil(t, g.next(t, watchWait).GetGap()) | |
| 143 | - | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 144 | - | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // AC5: a gap when the id fell out of a small ring. | |
| 148 | - | func TestAC5GapAfterOverflow(t *testing.T) { | |
| 149 | - | h := newHarness(t, harnessOpts{eventBuffer: 4}) | |
| 150 | - | h.seed("pub", domain.VisibilityPublic) | |
| 151 | - | api := h.api(adminToken) | |
| 152 | - | w := h.watch(api, "") | |
| 153 | - | wt := h.workTree("main", "a.txt") | |
| 154 | - | ||
| 155 | - | var ids []string | |
| 156 | - | for i := range 8 { | |
| 157 | - | if i > 0 { | |
| 158 | - | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 159 | - | } | |
| 160 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 161 | - | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // ids[2] is out of the ring (the last four are ids[4:]). | |
| 165 | - | r := h.watch(api, ids[2]) | |
| 166 | - | gap := r.next(t, watchWait) | |
| 167 | - | assert.NotNil(t, gap.GetGap()) | |
| 168 | - | assert.Empty(t, gap.GetId()) | |
| 169 | - | for _, want := range ids[4:] { | |
| 170 | - | assert.Equal(t, want, r.next(t, watchWait).GetId()) | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | // The last buffered id still resumes without a gap. | |
| 174 | - | ok := h.watch(api, ids[4]) | |
| 175 | - | assert.Equal(t, ids[5], ok.next(t, watchWait).GetId()) | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // AC5: private events never reach anonymous callers, nor tokens that may not | |
| 179 | - | // read the repository; repo topics follow the same rule. | |
| 180 | - | func TestAC5NoPrivateEventsToAnonymous(t *testing.T) { | |
| 181 | - | h := newHarness(t, harnessOpts{}) | |
| 182 | - | h.seed("pub", domain.VisibilityPublic) | |
| 183 | - | h.seed("priv", domain.VisibilityPrivate) | |
| 184 | - | other := h.token(scopes(domain.ScopeRepoRead), "pub") | |
| 185 | - | reader := h.token(scopes(domain.ScopeRepoRead), "priv") | |
| 186 | - | ||
| 187 | - | anon := h.watch(h.api(""), "") | |
| 188 | - | scoped := h.watch(h.api(other), "") | |
| 189 | - | rd := h.watch(h.api(reader), "") | |
| 190 | - | root := h.watch(h.api(adminToken), "") | |
| 191 | - | ||
| 192 | - | // A private repository's topic is not found for those who cannot read it. | |
| 193 | - | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |
| 194 | - | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(other), "repo:priv"))) | |
| 195 | - | assert.Equal(t, codeName(watchErr(h.ctx, h.api(""), "repo:nothing")), codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |
| 196 | - | ||
| 197 | - | wt := h.workTree("main", "a.txt") | |
| 198 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 199 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 200 | - | // Metadata events too. | |
| 201 | - | _, err := h.api(adminToken).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 202 | - | Repo: "priv", Description: "secret plans", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 203 | - | })) | |
| 204 | - | require.NoError(t, err) | |
| 205 | - | ||
| 206 | - | // The reader of priv sees its push and its update, and pub's push. | |
| 207 | - | assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo()) | |
| 208 | - | assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public") | |
| 209 | - | assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription()) | |
| 210 | - | // Root sees everything in order. | |
| 211 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 212 | - | assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 213 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName()) | |
| 214 | - | ||
| 215 | - | // Deleting reaches those who could read the repository before, not others. | |
| 216 | - | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | |
| 217 | - | require.NoError(t, err) | |
| 218 | - | assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 219 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 220 | - | ||
| 221 | - | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 222 | - | ev := w.next(t, watchWait) | |
| 223 | - | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | |
| 224 | - | } | |
| 225 | - | // Nothing about priv came before the sentinel, for either of them. | |
| 226 | - | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic}) | |
| 227 | - | require.NoError(t, err) | |
| 228 | - | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 229 | - | assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | // Resuming does not replay private events to anonymous either: after a | |
| 233 | - | // gap, the creation and the push of pub, the sentinels the watchers above | |
| 234 | - | // saw, and nothing about priv. | |
| 235 | - | r := h.watch(h.api(""), "bogus") | |
| 236 | - | assert.NotNil(t, r.next(t, watchWait).GetGap()) | |
| 237 | - | assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 238 | - | assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo()) | |
| 239 | - | assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 240 | - | h.sentinel(t, r, "s-resumed") | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | // AC5: a repo topic only carries that repository; a revoked token cannot | |
| 244 | - | // start a new watch. | |
| 245 | - | func TestAC5RepoTopic(t *testing.T) { | |
| 246 | - | h := newHarness(t, harnessOpts{}) | |
| 247 | - | h.seed("a", domain.VisibilityPublic) | |
| 248 | - | h.seed("b", domain.VisibilityPublic) | |
| 249 | - | w := h.watch(h.api(""), "", "repo:a") | |
| 250 | - | ||
| 251 | - | wt := h.workTree("main", "x.txt") | |
| 252 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") | |
| 253 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 254 | - | assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 255 | - | // The push to b was not delivered: the next event is the next push to a. | |
| 256 | - | h.writeFile(wt, "y.txt", []byte("y\n")) | |
| 257 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 258 | - | assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId()) | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. | |
| 262 | - | func TestWatchEndsOnCancel(t *testing.T) { | |
| 263 | - | h := newHarness(t, harnessOpts{}) | |
| 264 | - | w := h.watch(h.api(""), "") | |
| 265 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 266 | - | w.cancel() | |
| 267 | - | select { | |
| 268 | - | case <-w.done: | |
| 269 | - | case <-time.After(5 * watchWait): | |
| 270 | - | t.Fatal("the stream did not end") | |
| 271 | - | } | |
| 272 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | // Shutdown ends the streams cleanly: the client sees the stream end without | |
| 276 | - | // an error and the server exits 0 well before the shutdown timeout. | |
| 277 | - | func TestServeShutdownEndsWatchStreams(t *testing.T) { | |
| 278 | - | requireGit(t) | |
| 279 | - | data := t.TempDir() | |
| 280 | - | addr := freeAddr(t) | |
| 281 | - | cmd := exec.Command(binary(t), "serve") | |
| 282 | - | cmd.Env = append(baseEnv(), | |
| 283 | - | "ERMITE_ADDR="+addr, "ERMITE_DATA_DIR="+data, "ERMITE_PUBLIC_URL=http://"+addr, | |
| 284 | - | "ERMITE_ADMIN_TOKEN="+adminToken, "ERMITE_LOG_LEVEL=debug", "ERMITE_LOG_FORMAT=json", | |
| 285 | - | ) | |
| 286 | - | var logs syncBuffer | |
| 287 | - | cmd.Stdout, cmd.Stderr = &logs, &logs | |
| 288 | - | require.NoError(t, cmd.Start()) | |
| 289 | - | exited := make(chan error, 1) | |
| 290 | - | go func() { exited <- cmd.Wait() }() | |
| 291 | - | t.Cleanup(func() { _ = cmd.Process.Kill() }) | |
| 292 | - | ||
| 293 | - | base := "http://" + addr | |
| 294 | - | require.Eventually(t, func() bool { | |
| 295 | - | resp, err := http.Get(base + "/-/health") | |
| 296 | - | if err != nil { | |
| 297 | - | return false | |
| 298 | - | } | |
| 299 | - | _ = resp.Body.Close() | |
| 300 | - | return resp.StatusCode == 200 | |
| 301 | - | }, 15*time.Second, 50*time.Millisecond, "%s", logs.String()) | |
| 302 | - | ||
| 303 | - | c := newClients(base+"/-/api", adminToken) | |
| 304 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 305 | - | defer cancel() | |
| 306 | - | st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |
| 307 | - | require.NoError(t, err) | |
| 308 | - | ended := make(chan error, 1) | |
| 309 | - | go func() { | |
| 310 | - | for st.Receive() { | |
| 311 | - | } | |
| 312 | - | ended <- st.Err() | |
| 313 | - | }() | |
| 314 | - | ||
| 315 | - | // The server version comes from the real binary. | |
| 316 | - | v, err := c.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 317 | - | require.NoError(t, err) | |
| 318 | - | assert.NotEmpty(t, v.Msg.GetVersion()) | |
| 319 | - | ||
| 320 | - | start := time.Now() | |
| 321 | - | require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) | |
| 322 | - | select { | |
| 323 | - | case err := <-ended: | |
| 324 | - | assert.NoError(t, err, "the stream ends cleanly") | |
| 325 | - | case <-time.After(15 * watchWait): | |
| 326 | - | t.Fatalf("the stream did not end\n%s", logs.String()) | |
| 327 | - | } | |
| 328 | - | select { | |
| 329 | - | case err := <-exited: | |
| 330 | - | require.NoError(t, err, "%s", logs.String()) | |
| 331 | - | case <-time.After(15 * watchWait): | |
| 332 | - | t.Fatalf("the server did not exit\n%s", logs.String()) | |
| 333 | - | } | |
| 334 | - | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | |
| 335 | - | assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String()) | |
| 336 | - | assert.False(t, errors.Is(err, context.Canceled)) | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | // A token that is revoked while its Watch is open ends the stream with | |
| 340 | - | // unauthenticated, and the private events that follow never reach it. | |
| 341 | - | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |
| 342 | - | h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond}) | |
| 343 | - | h.seed("priv", domain.VisibilityPrivate) | |
| 344 | - | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{ | |
| 345 | - | Label: "watcher", Scopes: scopes(domain.ScopeRepoRead), | |
| 346 | - | }) | |
| 347 | - | require.NoError(t, err) | |
| 348 | - | w := h.watch(h.api(created.Secret), "") | |
| 349 | - | wt := h.workTree("main", "a.txt") | |
| 350 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 351 | - | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") | |
| 352 | - | ||
| 353 | - | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) | |
| 354 | - | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 355 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 356 | - | ||
| 357 | - | select { | |
| 358 | - | case <-w.done: | |
| 359 | - | case <-time.After(5 * watchWait): | |
| 360 | - | t.Fatal("the stream of a revoked token did not end") | |
| 361 | - | } | |
| 362 | - | assert.Equal(t, "unauthenticated", codeName(w.err)) | |
| 363 | - | for ev := range w.events { | |
| 364 | - | t.Fatalf("an event reached the revoked token: %v", ev) | |
| 365 | - | } | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | // Watch refuses too many topics, and too many streams from one client. | |
| 369 | - | func TestWatchLimits(t *testing.T) { | |
| 370 | - | h := newHarness(t, harnessOpts{}) | |
| 371 | - | api := h.api(adminToken) | |
| 372 | - | ||
| 373 | - | topics := make([]string, app.MaxWatchTopics+1) | |
| 374 | - | for i := range topics { | |
| 375 | - | topics[i] = "home" | |
| 376 | - | } | |
| 377 | - | assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...))) | |
| 378 | - | ||
| 379 | - | for range app.MaxWatchersPerClient { | |
| 380 | - | h.watch(api, "") | |
| 381 | - | } | |
| 382 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |
| 383 | - | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | |
| 384 | - | // A stream of another client address is not affected (the harness trusts | |
| 385 | - | // the loopback proxy only when configured: use the in-process caller). | |
| 386 | - | inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | |
| 387 | - | ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root())) | |
| 388 | - | defer cancel() | |
| 389 | - | st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |
| 390 | - | require.NoError(t, err) | |
| 391 | - | _ = st.Close() | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | // sentinel proves that nothing reached the stream since the last event it | |
| 395 | - | // returned: it creates a public repository, which every watcher of "home" may | |
| 396 | - | // see, and requires its RepoCreated to be the next event. Events arrive in the | |
| 397 | - | // order they were published. | |
| 398 | - | func (h *harness) sentinel(t *testing.T, s *stream, name string) { | |
| 399 | - | t.Helper() | |
| 400 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 401 | - | require.NoError(t, err) | |
| 402 | - | ev := s.next(t, watchWait) | |
| 403 | - | assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev) | |
| 404 | - | } | |
M cmd/ermite/main.go +4 -23
| @@ -6,32 +6,13 @@ package main | |||
| 6 | 6 | import ( | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | - | "runtime/debug" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/app" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func main() { | |
| 13 | - | if err := newRootCmd().Execute(); err != nil { | |
| 14 | + | if err := app.Run(os.Args[1:]); err != nil { | |
| 14 | 15 | fmt.Fprintln(os.Stderr, "ermite:", err) | |
| 15 | - | os.Exit(1) | |
| 16 | + | os.Exit(app.ExitCode(err)) | |
| 16 | 17 | } | |
| 17 | 18 | } | |
| 18 | - | ||
| 19 | - | // stamped is the release version set at link time by the image build | |
| 20 | - | // (-ldflags "-X main.stamped=v0.1.0"). Empty for a plain go build, which then | |
| 21 | - | // reports the module version recorded by the Go toolchain. | |
| 22 | - | var stamped string | |
| 23 | - | ||
| 24 | - | // buildVersion is the version stamped into the binary: the link-time version | |
| 25 | - | // when set, else the module version of the build. | |
| 26 | - | func buildVersion() string { | |
| 27 | - | if stamped != "" { | |
| 28 | - | return stamped | |
| 29 | - | } | |
| 30 | - | info, ok := debug.ReadBuildInfo() | |
| 31 | - | if !ok || info.Main.Version == "" { | |
| 32 | - | return "(devel)" | |
| 33 | - | } | |
| 34 | - | return info.Main.Version | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func version() string { return "ermite " + buildVersion() } | |
M internal/adapters/rpc/api.go +1 -1
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with | |
| 4 | 4 | // 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 | |
| 6 | 6 | // mapping. The same handlers serve remote clients over HTTP and the web in | |
| 7 | 7 | // process (see InProcessClient). | |
| 8 | 8 | package rpc | |
M internal/adapters/web/ASSETS.md +1 -1
| @@ -18,7 +18,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 18 | 18 | ||
| 19 | 19 | The three htmx files are the exact files the approved mockup was built with. | |
| 20 | 20 | To upgrade, replace the file, update the checksum here, and run the web tests | |
| 21 | - | (`go test ./cmd/ermite -run TestWeb`). | |
| 21 | + | (`go test ./internal/app -run TestWeb`). | |
| 22 | 22 | ||
| 23 | 23 | `*_templ.go` files are generated by `go tool templ generate` (templ is pinned | |
| 24 | 24 | in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale. | |
M internal/adapters/web/web.go +2 -2
| @@ -4,10 +4,10 @@ | |||
| 4 | 4 | // showcase, rendered with templ from the data of the ermite API. | |
| 5 | 5 | // | |
| 6 | 6 | // 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 | |
| 8 | 8 | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | |
| 9 | 9 | // 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 | |
| 11 | 11 | // API then shows public repositories only; a private repository is | |
| 12 | 12 | // indistinguishable from a missing one, here as everywhere. | |
| 13 | 13 | // | |
R cmd/ermite/api_helpers_test.go → internal/app/api_helpers_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/api_test.go → internal/app/api_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
A internal/app/app.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package app is the composition root and the command line of ermite: it | |
| 4 | + | // parses the CLI, reads the environment, builds the adapters and wires them to | |
| 5 | + | // the use cases. cmd/ermite is only its entry point. | |
| 6 | + | package app | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "runtime/debug" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Run executes the ermite command line with args, the command-line arguments | |
| 13 | + | // without the program name. It returns the error of the command, which the | |
| 14 | + | // caller prints once. | |
| 15 | + | func Run(args []string) error { | |
| 16 | + | root := newRootCmd() | |
| 17 | + | if args == nil { | |
| 18 | + | args = []string{} | |
| 19 | + | } | |
| 20 | + | root.SetArgs(args) | |
| 21 | + | return root.Execute() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ExitCode is the process exit status for the error Run returned. | |
| 25 | + | func ExitCode(err error) int { | |
| 26 | + | if err == nil { | |
| 27 | + | return 0 | |
| 28 | + | } | |
| 29 | + | return 1 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // stamped is the release version set at link time by the image build | |
| 33 | + | // (-ldflags "-X bnema.dev/ermite/internal/app.stamped=v0.1.0"). Empty for a | |
| 34 | + | // plain go build, which then reports the module version recorded by the Go | |
| 35 | + | // toolchain. | |
| 36 | + | var stamped string | |
| 37 | + | ||
| 38 | + | // buildVersion is the version stamped into the binary: the link-time version | |
| 39 | + | // when set, else the module version of the build. | |
| 40 | + | func buildVersion() string { | |
| 41 | + | if stamped != "" { | |
| 42 | + | return stamped | |
| 43 | + | } | |
| 44 | + | info, ok := debug.ReadBuildInfo() | |
| 45 | + | if !ok || info.Main.Version == "" { | |
| 46 | + | return "(devel)" | |
| 47 | + | } | |
| 48 | + | return info.Main.Version | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func version() string { return "ermite " + buildVersion() } | |
R cmd/ermite/cli.go → internal/app/cli.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/cli_binary_test.go → internal/app/cli_binary_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
R cmd/ermite/cli_e2e_test.go → internal/app/cli_e2e_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
R cmd/ermite/cli_fake_test.go → internal/app/cli_fake_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
R cmd/ermite/cli_mr.go → internal/app/cli_mr.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/cli_mr_e2e_test.go → internal/app/cli_mr_e2e_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
R cmd/ermite/cli_mr_test.go → internal/app/cli_mr_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "strings" | |
R cmd/ermite/cli_repo.go → internal/app/cli_repo.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
R cmd/ermite/cli_repo_test.go → internal/app/cli_repo_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
R cmd/ermite/cli_signing.go → internal/app/cli_signing.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/cli_token.go → internal/app/cli_token.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/cli_token_test.go → internal/app/cli_token_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "testing" | |
R cmd/ermite/cli_version.go → internal/app/cli_version.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/cli_version_test.go → internal/app/cli_version_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
R cmd/ermite/cli_watch.go → internal/app/cli_watch.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/cli_watch_test.go → internal/app/cli_watch_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
R cmd/ermite/cli_web.go → internal/app/cli_web.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
R cmd/ermite/clock.go → internal/app/clock.go +1 -1 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import "time" | |
| 6 | 6 | ||
R cmd/ermite/datadir.go → internal/app/datadir.go +1 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/e2e_helpers_test.go → internal/app/e2e_helpers_test.go +2 -2 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -69,7 +69,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness { | |||
| 69 | 69 | if o.trustedProxies != "" { | |
| 70 | 70 | env[config.EnvTrustedProxies] = o.trustedProxies | |
| 71 | 71 | } | |
| 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 | |
| 73 | 73 | if r := os.Getenv(config.EnvGitReader); r != "" { | |
| 74 | 74 | env[config.EnvGitReader] = r | |
| 75 | 75 | } | |
R cmd/ermite/e2e_test.go → internal/app/e2e_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
M internal/app/events_test.go +346 -825
| @@ -1,883 +1,404 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | - | "sync" | |
| 10 | - | "sync/atomic" | |
| 9 | + | "net/http" | |
| 10 | + | "os/exec" | |
| 11 | + | "syscall" | |
| 11 | 12 | "testing" | |
| 12 | - | "testing/synctest" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "connectrpc.com/connect" | |
| 15 | 16 | "github.com/stretchr/testify/assert" | |
| 16 | - | "github.com/stretchr/testify/mock" | |
| 17 | 17 | "github.com/stretchr/testify/require" | |
| 18 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 18 | 19 | ||
| 19 | - | "bnema.dev/ermite/internal/app" | |
| 20 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | + | "bnema.dev/ermite/internal/adapters/rpc" | |
| 20 | 22 | "bnema.dev/ermite/internal/domain" | |
| 21 | 23 | "bnema.dev/ermite/internal/ports" | |
| 22 | - | "bnema.dev/ermite/internal/ports/mocks" | |
| 24 | + | "bnema.dev/ermite/internal/usecase" | |
| 23 | 25 | ) | |
| 24 | 26 | ||
| 25 | - | // eventRig is an Events use case fed by a channel the test writes to. | |
| 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()) | |
| 35 | 69 | } | |
| 36 | 70 | ||
| 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") | |
| 40 | 105 | } | |
| 41 | 106 | ||
| 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")) | |
| 56 | 119 | } | |
| 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()) | |
| 89 | 122 | } | |
| 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()) | |
| 123 | 131 | } | |
| 124 | - | return ports.WatchEvent{} | |
| 132 | + | // Nothing was replayed twice: the next event is the live one, below. | |
| 133 | + | h.writeFile(wt, "live.txt", []byte("x\n")) | |
| 134 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 135 | + | live := r.next(t, watchWait) | |
| 136 | + | assert.NotNil(t, live.GetPushed()) | |
| 137 | + | assert.NotContains(t, ids, live.GetId()) | |
| 138 | + | ||
| 139 | + | // An id nobody issued is a gap, followed by the oldest buffered event: the | |
| 140 | + | // creation of the repository, which came before the first push. | |
| 141 | + | g := h.watch(api, "not-an-id") | |
| 142 | + | assert.NotNil(t, g.next(t, watchWait).GetGap()) | |
| 143 | + | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 144 | + | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) | |
| 125 | 145 | } | |
| 126 | 146 | ||
| 127 | - | // sentinelOID marks the push events that only prove the ones before them 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()) | |
| 141 | 162 | } | |
| 142 | - | } | |
| 143 | 163 | ||
| 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 | + | } | |
| 179 | 172 | ||
| 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()) | |
| 207 | 176 | } | |
| 208 | 177 | ||
| 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) | |
| 230 | 230 | } | |
| 231 | 231 | ||
| 232 | - | t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) { | |
| 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") | |
| 297 | 241 | } | |
| 298 | 242 | ||
| 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()) | |
| 319 | 259 | } | |
| 320 | 260 | ||
| 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) | |
| 378 | 273 | } | |
| 379 | 274 | ||
| 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 | |
| 424 | 298 | } | |
| 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() { | |
| 434 | 311 | } | |
| 435 | - | assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got) | |
| 436 | - | }) | |
| 437 | - | } | |
| 312 | + | ended <- st.Err() | |
| 313 | + | }() | |
| 438 | 314 | ||
| 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()) | |
| 471 | 319 | ||
| 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()) | |
| 481 | 333 | } | |
| 482 | - | return domain.TokenPrincipal(tok) | |
| 334 | + | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | |
| 335 | + | assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String()) | |
| 336 | + | assert.False(t, errors.Is(err, context.Canceled)) | |
| 483 | 337 | } | |
| 484 | 338 | ||
| 339 | + | // A token that is revoked while its Watch is open ends the stream with | |
| 340 | + | // unauthenticated, and the private events that follow never reach it. | |
| 485 | 341 | 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), | |
| 565 | 346 | }) | |
| 347 | + | require.NoError(t, err) | |
| 348 | + | w := h.watch(h.api(created.Secret), "") | |
| 349 | + | wt := h.workTree("main", "a.txt") | |
| 350 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 351 | + | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") | |
| 566 | 352 | ||
| 567 | - | t.Run("the answer is kept between checks", func(t *testing.T) { | |
| 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") | |
| 589 | 356 | ||
| 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 | + | } | |
| 605 | 366 | } | |
| 606 | 367 | ||
| 368 | + | // Watch refuses too many topics, and too many streams from one client. | |
| 607 | 369 | 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) | |
| 651 | 372 | ||
| 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" | |
| 836 | 376 | } | |
| 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, "") | |
| 867 | 381 | } | |
| 382 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == usecase.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |
| 383 | + | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | |
| 384 | + | // A stream of another client address is not affected (the harness trusts | |
| 385 | + | // the loopback proxy only when configured: use the in-process caller). | |
| 386 | + | inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | |
| 387 | + | ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root())) | |
| 388 | + | defer cancel() | |
| 389 | + | st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |
| 390 | + | require.NoError(t, err) | |
| 391 | + | _ = st.Close() | |
| 392 | + | } | |
| 868 | 393 | ||
| 869 | - | t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) { | |
| 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) | |
| 883 | 404 | } | |
R cmd/ermite/forge.go → internal/app/forge.go +25 -25 renamed, 88% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -24,11 +24,11 @@ import ( | |||
| 24 | 24 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 25 | 25 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 26 | 26 | "bnema.dev/ermite/internal/adapters/web" | |
| 27 | - | "bnema.dev/ermite/internal/app" | |
| 28 | 27 | "bnema.dev/ermite/internal/clientlimit" | |
| 29 | 28 | "bnema.dev/ermite/internal/config" | |
| 30 | 29 | "bnema.dev/ermite/internal/domain" | |
| 31 | 30 | "bnema.dev/ermite/internal/ports" | |
| 31 | + | "bnema.dev/ermite/internal/usecase" | |
| 32 | 32 | ) | |
| 33 | 33 | ||
| 34 | 34 | // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in | |
| @@ -40,13 +40,13 @@ type options struct { | |||
| 40 | 40 | // hookExe is the executable the hook scripts exec; empty means this | |
| 41 | 41 | // process's own executable. | |
| 42 | 42 | hookExe string | |
| 43 | - | // maintenanceDelay overrides app.MaintenanceDelay when positive. | |
| 43 | + | // maintenanceDelay overrides usecase.MaintenanceDelay when positive. | |
| 44 | 44 | maintenanceDelay time.Duration | |
| 45 | 45 | // maxBodyBytes overrides the limit of a push body when positive. | |
| 46 | 46 | maxBodyBytes int64 | |
| 47 | - | // eventBuffer overrides app.DefaultEventBuffer when positive. | |
| 47 | + | // eventBuffer overrides usecase.DefaultEventBuffer when positive. | |
| 48 | 48 | eventBuffer int | |
| 49 | - | // revalidateEvery overrides app.DefaultRevalidateEvery when not zero. | |
| 49 | + | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | |
| 50 | 50 | revalidateEvery time.Duration | |
| 51 | 51 | } | |
| 52 | 52 | ||
| @@ -56,24 +56,24 @@ type forge struct { | |||
| 56 | 56 | store *kvstore.Store | |
| 57 | 57 | server *httpserver.Server | |
| 58 | 58 | hooks *githttp.HookServer | |
| 59 | - | maint *app.Maintainer | |
| 59 | + | maint *usecase.Maintainer | |
| 60 | 60 | git *gitcli.Git | |
| 61 | 61 | // reader is the ermite-git reader when ERMITE_GIT_READER selects it. | |
| 62 | 62 | reader *ermitegit.Adapter | |
| 63 | 63 | ||
| 64 | 64 | // The use cases and the API are exposed for tests and for the phases | |
| 65 | 65 | // 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 | |
| 71 | 71 | // MergeRequests and SigningKey serve MergeRequestService and | |
| 72 | 72 | // 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 | |
| 77 | 77 | API *rpc.API | |
| 78 | 78 | Bus ports.EventBus | |
| 79 | 79 | } | |
| @@ -139,7 +139,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 139 | 139 | ||
| 140 | 140 | clock := systemClock{} | |
| 141 | 141 | f.Bus = eventbus.New() | |
| 142 | - | f.Repos = app.NewRepos(store, git, f.Bus, clock) | |
| 142 | + | f.Repos = usecase.NewRepos(store, git, f.Bus, clock) | |
| 143 | 143 | report, err := f.Repos.Reconcile(ctx) | |
| 144 | 144 | if err != nil { | |
| 145 | 145 | return nil, fmt.Errorf("reconcile repositories: %w", err) | |
| @@ -150,10 +150,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 150 | 150 | Int("invalid_dirs", len(report.InvalidDirs)). | |
| 151 | 151 | Msg("repositories reconciled") | |
| 152 | 152 | ||
| 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) | |
| 157 | 157 | ||
| 158 | 158 | exe := opts.hookExe | |
| 159 | 159 | if exe == "" { | |
| @@ -181,8 +181,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 181 | 181 | reader = f.reader | |
| 182 | 182 | } | |
| 183 | 183 | 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{ | |
| 186 | 186 | Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery, | |
| 187 | 187 | }) | |
| 188 | 188 | ||
| @@ -196,14 +196,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 196 | 196 | return nil, err | |
| 197 | 197 | } | |
| 198 | 198 | 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{ | |
| 201 | 201 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 202 | 202 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 203 | 203 | }) | |
| 204 | 204 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 205 | 205 | ||
| 206 | - | f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | |
| 206 | + | f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | |
| 207 | 207 | if err != nil { | |
| 208 | 208 | return nil, err | |
| 209 | 209 | } | |
R cmd/ermite/gitreader_test.go → internal/app/gitreader_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
R cmd/ermite/hook.go → internal/app/hook.go +1 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
R cmd/ermite/main_test.go → internal/app/main_test.go +2 -2 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -43,7 +43,7 @@ func binary(t *testing.T) string { | |||
| 43 | 43 | return | |
| 44 | 44 | } | |
| 45 | 45 | binPath = filepath.Join(dir, "ermite") | |
| 46 | - | out, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput() | |
| 46 | + | out, err := exec.Command("go", "build", "-o", binPath, "bnema.dev/ermite/cmd/ermite").CombinedOutput() | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | buildErr = errors.New("go build: " + err.Error() + "\n" + string(out)) | |
| 49 | 49 | } | |
R cmd/ermite/mr_api_test.go → internal/app/mr_api_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
R cmd/ermite/root.go → internal/app/root.go +2 -2 renamed, 93% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "github.com/spf13/cobra" | |
| @@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 21 | 21 | "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" + | |
| 22 | 22 | "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.", | |
| 23 | 23 | SilenceUsage: true, | |
| 24 | - | SilenceErrors: true, // main prints the error once | |
| 24 | + | SilenceErrors: true, // cmd/ermite prints the error once | |
| 25 | 25 | } | |
| 26 | 26 | root.AddCommand( | |
| 27 | 27 | newServeCmd(), newHookCmd(), | |
R cmd/ermite/serve.go → internal/app/serve.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/testdata/web/blob-binary.html → internal/app/testdata/web/blob-binary.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-code.html → internal/app/testdata/web/blob-code.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-empty.html → internal/app/testdata/web/blob-empty.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-image.html → internal/app/testdata/web/blob-image.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-markdown.html → internal/app/testdata/web/blob-markdown.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-svg.html → internal/app/testdata/web/blob-svg.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-text.html → internal/app/testdata/web/blob-text.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-too-large.html → internal/app/testdata/web/blob-too-large.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/blob-trojan.html → internal/app/testdata/web/blob-trojan.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-big.html → internal/app/testdata/web/commit-big.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-binary.html → internal/app/testdata/web/commit-binary.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-merge.html → internal/app/testdata/web/commit-merge.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-modify-split.html → internal/app/testdata/web/commit-modify-split.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-modify.html → internal/app/testdata/web/commit-modify.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-rename.html → internal/app/testdata/web/commit-rename.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-root.html → internal/app/testdata/web/commit-root.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commit-trojan.html → internal/app/testdata/web/commit-trojan.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commits-page2.html → internal/app/testdata/web/commits-page2.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commits-path.html → internal/app/testdata/web/commits-path.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commits-root.html → internal/app/testdata/web/commits-root.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/commits-small.html → internal/app/testdata/web/commits-small.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/empty.html → internal/app/testdata/web/empty.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/home.html → internal/app/testdata/web/home.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/not-found-page.html → internal/app/testdata/web/not-found-page.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/not-found.html → internal/app/testdata/web/not-found.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/refs.html → internal/app/testdata/web/refs.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/summary.html → internal/app/testdata/web/summary.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/tree-nested.html → internal/app/testdata/web/tree-nested.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/tree-root.html → internal/app/testdata/web/tree-root.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/tree-slash-ref.html → internal/app/testdata/web/tree-slash-ref.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/testdata/web/tree-subdir.html → internal/app/testdata/web/tree-subdir.html +0 -0 renamed, 100% similar
renamed, 100% similar
R cmd/ermite/web.go → internal/app/web.go +6 -6 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -17,11 +17,11 @@ import ( | |||
| 17 | 17 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 18 | 18 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 19 | 19 | "bnema.dev/ermite/internal/adapters/web" | |
| 20 | - | "bnema.dev/ermite/internal/app" | |
| 21 | 20 | "bnema.dev/ermite/internal/clientlimit" | |
| 22 | 21 | "bnema.dev/ermite/internal/config" | |
| 23 | 22 | "bnema.dev/ermite/internal/domain" | |
| 24 | 23 | "bnema.dev/ermite/internal/ports" | |
| 24 | + | "bnema.dev/ermite/internal/usecase" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 27 | // newWeb builds the web pages on the API of this process: the generated | |
| @@ -29,7 +29,7 @@ import ( | |||
| 29 | 29 | // | |
| 30 | 30 | // The owner's session cookie turns a request into the owner's (web.Server | |
| 31 | 31 | // checks it with sessions); every other request is anonymous. | |
| 32 | - | func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) { | |
| 32 | + | func newWeb(cfg config.Config, api *rpc.API, sessions *usecase.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) { | |
| 33 | 33 | hc := api.InProcessClient() | |
| 34 | 34 | srv, err := web.New(web.Config{ | |
| 35 | 35 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | |
| @@ -100,7 +100,7 @@ func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClie | |||
| 100 | 100 | // webSessions adapts the session use cases to ports.WebSessionService: a valid | |
| 101 | 101 | // token gives the context of the owner, whose principal the API calls of the | |
| 102 | 102 | // page carry (rpc.API.InProcessClient). | |
| 103 | - | type webSessions struct{ *app.Sessions } | |
| 103 | + | type webSessions struct{ *usecase.Sessions } | |
| 104 | 104 | ||
| 105 | 105 | var _ ports.WebSessionService = webSessions{} | |
| 106 | 106 | ||
| @@ -116,8 +116,8 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura | |||
| 116 | 116 | // that made the link may cut short. The session token carries its own | |
| 117 | 117 | // expiry, so a cookie that outlives it by under a second is harmless. | |
| 118 | 118 | // 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 { | |
| 121 | 121 | lifetime = d.Round(time.Second) | |
| 122 | 122 | } | |
| 123 | 123 | if lifetime < time.Second { | |
R cmd/ermite/web_commit_test.go → internal/app/web_commit_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/web_commits_test.go → internal/app/web_commits_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/web_events_test.go → internal/app/web_events_test.go +4 -4 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
| @@ -15,9 +15,9 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/assert" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | - | "bnema.dev/ermite/internal/app" | |
| 19 | 18 | "bnema.dev/ermite/internal/domain" | |
| 20 | 19 | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // sseEvent is one event read from /-/events. | |
| @@ -381,7 +381,7 @@ func TestWebEventsEndOnShutdown(t *testing.T) { | |||
| 381 | 381 | // one address cannot take every watch slot of the forge. | |
| 382 | 382 | func TestWebEventsLimitPerVisitor(t *testing.T) { | |
| 383 | 383 | h := newHarness(t, harnessOpts{}) | |
| 384 | - | for range app.MaxWatchersPerClient { | |
| 384 | + | for range usecase.MaxWatchersPerClient { | |
| 385 | 385 | h.sse("topic=home", "") | |
| 386 | 386 | } | |
| 387 | 387 | req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil) | |
| @@ -391,7 +391,7 @@ func TestWebEventsLimitPerVisitor(t *testing.T) { | |||
| 391 | 391 | defer func() { _ = resp.Body.Close() }() | |
| 392 | 392 | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | |
| 393 | 393 | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | |
| 394 | - | assert.Equal(t, app.MaxWatchersPerClient, h.forge.EventLog.Watchers()) | |
| 394 | + | assert.Equal(t, usecase.MaxWatchersPerClient, h.forge.EventLog.Watchers()) | |
| 395 | 395 | } | |
| 396 | 396 | ||
| 397 | 397 | // A visitor that goes away frees its watch. | |
R cmd/ermite/web_files_test.go → internal/app/web_files_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
R cmd/ermite/web_mr_test.go → internal/app/web_mr_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "html" | |
R cmd/ermite/web_session_test.go → internal/app/web_session_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/web_test.go → internal/app/web_test.go +2 -2 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "flag" | |
| @@ -100,7 +100,7 @@ func golden(t *testing.T, name, got string) { | |||
| 100 | 100 | return | |
| 101 | 101 | } | |
| 102 | 102 | want, err := os.ReadFile(path) | |
| 103 | - | require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update") | |
| 103 | + | require.NoError(t, err, "run: go test ./internal/app -run TestWebGolden -update") | |
| 104 | 104 | assert.Equal(t, string(want), got, name) | |
| 105 | 105 | } | |
| 106 | 106 | ||
A internal/boundary/boundary_test.go +281 -0
| @@ -0,0 +1,281 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package boundary_test enforces the import rules between the layers of | |
| 4 | + | // ermite. The module root holds no Go package (the binary lives in cmd/ermite), | |
| 5 | + | // so the check lives in this test-only package and walks the module from its | |
| 6 | + | // root. It parses import clauses only; it never loads packages. | |
| 7 | + | package boundary_test | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "fmt" | |
| 11 | + | "go/parser" | |
| 12 | + | "go/token" | |
| 13 | + | "io/fs" | |
| 14 | + | "path/filepath" | |
| 15 | + | "sort" | |
| 16 | + | "strconv" | |
| 17 | + | "strings" | |
| 18 | + | "testing" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const modulePath = "bnema.dev/ermite" | |
| 22 | + | ||
| 23 | + | // moduleRoot is the module root relative to this package's directory. | |
| 24 | + | const moduleRoot = "../.." | |
| 25 | + | ||
| 26 | + | type packageLayer string | |
| 27 | + | ||
| 28 | + | const ( | |
| 29 | + | layerCommand packageLayer = "command" | |
| 30 | + | layerApp packageLayer = "app" | |
| 31 | + | layerUsecase packageLayer = "usecase" | |
| 32 | + | layerAdapter packageLayer = "adapter" | |
| 33 | + | layerClientLimit packageLayer = "clientlimit" | |
| 34 | + | layerConfig packageLayer = "config" | |
| 35 | + | layerPorts packageLayer = "ports" | |
| 36 | + | layerDomain packageLayer = "domain" | |
| 37 | + | layerGenerated packageLayer = "generated" | |
| 38 | + | layerScript packageLayer = "script" | |
| 39 | + | layerBoundary packageLayer = "boundary" | |
| 40 | + | layerTestSupport packageLayer = "test-support" | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | // productionDependencies lists, per layer, the layers its production files | |
| 44 | + | // may import. | |
| 45 | + | // | |
| 46 | + | // - cmd/ermite is the entry point: it only calls internal/app. | |
| 47 | + | // - internal/app is the composition root and the CLI: it wires everything. | |
| 48 | + | // - internal/usecase holds the use cases: domain and ports only. | |
| 49 | + | // - adapters implement ports and never import use cases or app; the | |
| 50 | + | // per-package rule in packageImportDenied also keeps them from importing | |
| 51 | + | // each other. | |
| 52 | + | // - internal/clientlimit is infrastructure shared by the HTTP adapters. | |
| 53 | + | // - internal/config parses the environment into plain values. | |
| 54 | + | var productionDependencies = map[packageLayer]map[packageLayer]bool{ | |
| 55 | + | layerCommand: {layerApp: true}, | |
| 56 | + | layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 57 | + | layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true}, | |
| 58 | + | layerAdapter: {layerAdapter: true, layerClientLimit: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 59 | + | layerClientLimit: {layerPorts: true}, | |
| 60 | + | layerConfig: {}, | |
| 61 | + | layerPorts: {layerDomain: true}, | |
| 62 | + | layerDomain: {layerDomain: true}, | |
| 63 | + | layerGenerated: {layerGenerated: true}, | |
| 64 | + | layerScript: {}, | |
| 65 | + | layerBoundary: {}, | |
| 66 | + | layerTestSupport: {layerPorts: true, layerDomain: true}, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestImportBoundaries(t *testing.T) { | |
| 70 | + | violations, packages := inspectRepositoryDependencies(t) | |
| 71 | + | if len(violations) != 0 { | |
| 72 | + | t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n")) | |
| 73 | + | } | |
| 74 | + | for _, want := range []string{"cmd/ermite", "internal/app", "internal/usecase", "internal/ports", "internal/domain"} { | |
| 75 | + | if _, ok := packages[modulePath+"/"+want]; !ok { | |
| 76 | + | t.Fatalf("package inventory misses %s: the walk is not reading the module", want) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestImportBoundaryFixtures(t *testing.T) { | |
| 82 | + | p := func(s string) string { return modulePath + "/" + s } | |
| 83 | + | tests := []struct { | |
| 84 | + | name, source, target string | |
| 85 | + | testFile bool | |
| 86 | + | want bool | |
| 87 | + | }{ | |
| 88 | + | {"command accepts app", p("cmd/ermite"), p("internal/app"), false, true}, | |
| 89 | + | {"command rejects usecase", p("cmd/ermite"), p("internal/usecase"), false, false}, | |
| 90 | + | {"command rejects adapter", p("cmd/ermite"), p("internal/adapters/web"), false, false}, | |
| 91 | + | {"command rejects domain", p("cmd/ermite"), p("internal/domain"), false, false}, | |
| 92 | + | {"app accepts usecase", p("internal/app"), p("internal/usecase"), false, true}, | |
| 93 | + | {"app accepts adapter", p("internal/app"), p("internal/adapters/gitcli"), false, true}, | |
| 94 | + | {"app accepts config", p("internal/app"), p("internal/config"), false, true}, | |
| 95 | + | {"app accepts clientlimit", p("internal/app"), p("internal/clientlimit"), false, true}, | |
| 96 | + | {"app rejects command", p("internal/app"), p("cmd/ermite"), false, false}, | |
| 97 | + | {"app test may import command", p("internal/app"), p("cmd/ermite"), true, true}, | |
| 98 | + | {"usecase accepts domain", p("internal/usecase"), p("internal/domain"), false, true}, | |
| 99 | + | {"usecase accepts ports", p("internal/usecase"), p("internal/ports"), false, true}, | |
| 100 | + | {"usecase rejects adapter", p("internal/usecase"), p("internal/adapters/kvstore"), false, false}, | |
| 101 | + | {"usecase rejects app", p("internal/usecase"), p("internal/app"), false, false}, | |
| 102 | + | {"usecase rejects config", p("internal/usecase"), p("internal/config"), false, false}, | |
| 103 | + | {"usecase rejects clientlimit", p("internal/usecase"), p("internal/clientlimit"), false, false}, | |
| 104 | + | {"usecase test may import adapter", p("internal/usecase"), p("internal/adapters/kvstore"), true, true}, | |
| 105 | + | {"adapter accepts ports", p("internal/adapters/rpc"), p("internal/ports"), false, true}, | |
| 106 | + | {"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true}, | |
| 107 | + | {"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true}, | |
| 108 | + | {"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true}, | |
| 109 | + | {"adapter production rejects generated mocks", p("internal/adapters/web"), p("internal/adapters/web/mocks"), false, false}, | |
| 110 | + | {"adapter test may import generated mocks", p("internal/adapters/web"), p("internal/adapters/web/mocks"), true, true}, | |
| 111 | + | {"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false}, | |
| 112 | + | {"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false}, | |
| 113 | + | {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false}, | |
| 114 | + | {"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false}, | |
| 115 | + | {"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true}, | |
| 116 | + | {"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true}, | |
| 117 | + | {"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false}, | |
| 118 | + | {"config rejects domain", p("internal/config"), p("internal/domain"), false, false}, | |
| 119 | + | {"ports accepts domain", p("internal/ports"), p("internal/domain"), false, true}, | |
| 120 | + | {"ports rejects usecase", p("internal/ports"), p("internal/usecase"), false, false}, | |
| 121 | + | {"ports rejects adapter", p("internal/ports"), p("internal/adapters/web"), false, false}, | |
| 122 | + | {"ports mocks accept ports", p("internal/ports/mocks"), p("internal/ports"), false, true}, | |
| 123 | + | {"ports mocks reject usecase", p("internal/ports/mocks"), p("internal/usecase"), false, false}, | |
| 124 | + | {"reader conformance accepts ports", p("internal/readertest"), p("internal/ports"), false, true}, | |
| 125 | + | {"reader conformance rejects adapter", p("internal/readertest"), p("internal/adapters/gitcli"), false, false}, | |
| 126 | + | {"domain accepts domain", p("internal/domain"), p("internal/domain/x"), false, true}, | |
| 127 | + | {"domain rejects ports", p("internal/domain"), p("internal/ports"), false, false}, | |
| 128 | + | {"domain test may import anything", p("internal/domain"), p("internal/ports"), true, true}, | |
| 129 | + | } | |
| 130 | + | for _, tt := range tests { | |
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |
| 132 | + | got, err := dependencyAllowed(tt.source, tt.target, tt.testFile) | |
| 133 | + | if err != nil { | |
| 134 | + | t.Fatalf("dependencyAllowed: %v", err) | |
| 135 | + | } | |
| 136 | + | if got != tt.want { | |
| 137 | + | t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want) | |
| 138 | + | } | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) { | |
| 144 | + | t.Helper() | |
| 145 | + | var violations []string | |
| 146 | + | packages := make(map[string]struct{}) | |
| 147 | + | fset := token.NewFileSet() | |
| 148 | + | err := filepath.WalkDir(moduleRoot, func(path string, entry fs.DirEntry, walkErr error) error { | |
| 149 | + | if walkErr != nil { | |
| 150 | + | return walkErr | |
| 151 | + | } | |
| 152 | + | if entry.IsDir() { | |
| 153 | + | name := entry.Name() | |
| 154 | + | if name == ".git" || name == ".worktrees" || name == "vendor" || name == "testdata" || name == "node_modules" { | |
| 155 | + | return filepath.SkipDir | |
| 156 | + | } | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | if !strings.HasSuffix(path, ".go") { | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | rel, err := filepath.Rel(moduleRoot, path) | |
| 163 | + | if err != nil { | |
| 164 | + | return err | |
| 165 | + | } | |
| 166 | + | source := importPathForFile(rel) | |
| 167 | + | packages[source] = struct{}{} | |
| 168 | + | if _, err := classifyPackage(source); err != nil { | |
| 169 | + | violations = append(violations, rel+": "+err.Error()) | |
| 170 | + | return nil | |
| 171 | + | } | |
| 172 | + | file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) | |
| 173 | + | if err != nil { | |
| 174 | + | return err | |
| 175 | + | } | |
| 176 | + | testFile := strings.HasSuffix(path, "_test.go") | |
| 177 | + | for _, spec := range file.Imports { | |
| 178 | + | target, err := strconv.Unquote(spec.Path.Value) | |
| 179 | + | if err != nil || (target != modulePath && !strings.HasPrefix(target, modulePath+"/")) { | |
| 180 | + | continue | |
| 181 | + | } | |
| 182 | + | allowed, err := dependencyAllowed(source, target, testFile) | |
| 183 | + | if err != nil { | |
| 184 | + | violations = append(violations, fmt.Sprintf("%s: %v", rel, err)) | |
| 185 | + | continue | |
| 186 | + | } | |
| 187 | + | if !allowed { | |
| 188 | + | violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target)) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | return nil | |
| 192 | + | }) | |
| 193 | + | if err != nil { | |
| 194 | + | t.Fatalf("walking module: %v", err) | |
| 195 | + | } | |
| 196 | + | sort.Strings(violations) | |
| 197 | + | return violations, packages | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // dependencyAllowed reports whether a file of package source may import | |
| 201 | + | // target. Test files are exempt, so that composition tests can wire adapters | |
| 202 | + | // and use cases together; their packages must still be classified. | |
| 203 | + | func dependencyAllowed(source, target string, testFile bool) (bool, error) { | |
| 204 | + | sourceLayer, err := classifyPackage(source) | |
| 205 | + | if err != nil { | |
| 206 | + | return false, err | |
| 207 | + | } | |
| 208 | + | targetLayer, err := classifyPackage(target) | |
| 209 | + | if err != nil { | |
| 210 | + | return false, err | |
| 211 | + | } | |
| 212 | + | if testFile { | |
| 213 | + | return true, nil | |
| 214 | + | } | |
| 215 | + | if packageImportDenied(source, target) { | |
| 216 | + | return false, nil | |
| 217 | + | } | |
| 218 | + | return productionDependencies[sourceLayer][targetLayer], nil | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // packageImportDenied narrows the layer rules to packages: an adapter may | |
| 222 | + | // import only packages under its own directory (its mocks, for instance), | |
| 223 | + | // never a sibling adapter. | |
| 224 | + | func packageImportDenied(source, target string) bool { | |
| 225 | + | const adapters = modulePath + "/internal/adapters/" | |
| 226 | + | if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) { | |
| 227 | + | return adapterRoot(source) != adapterRoot(target) | |
| 228 | + | } | |
| 229 | + | return false | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // adapterRoot is the first path element under internal/adapters. | |
| 233 | + | func adapterRoot(path string) string { | |
| 234 | + | rest := strings.TrimPrefix(path, modulePath+"/internal/adapters/") | |
| 235 | + | root, _, _ := strings.Cut(rest, "/") | |
| 236 | + | return root | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func under(path, root string) bool { | |
| 240 | + | return path == root || strings.HasPrefix(path, root+"/") | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func classifyPackage(path string) (packageLayer, error) { | |
| 244 | + | p := func(s string) string { return modulePath + "/" + s } | |
| 245 | + | switch { | |
| 246 | + | case under(path, p("cmd")): | |
| 247 | + | return layerCommand, nil | |
| 248 | + | case under(path, p("scripts")): | |
| 249 | + | return layerScript, nil | |
| 250 | + | case under(path, p("internal/boundary")): | |
| 251 | + | return layerBoundary, nil | |
| 252 | + | case under(path, p("internal/app")): | |
| 253 | + | return layerApp, nil | |
| 254 | + | case under(path, p("internal/usecase")): | |
| 255 | + | return layerUsecase, nil | |
| 256 | + | case path == p("internal/ports/mocks"), under(path, p("internal/adapters/web/mocks")), under(path, p("internal/readertest")): | |
| 257 | + | return layerTestSupport, nil | |
| 258 | + | case under(path, p("internal/adapters")): | |
| 259 | + | return layerAdapter, nil | |
| 260 | + | case under(path, p("internal/clientlimit")): | |
| 261 | + | return layerClientLimit, nil | |
| 262 | + | case under(path, p("internal/config")): | |
| 263 | + | return layerConfig, nil | |
| 264 | + | case path == p("internal/ports"): | |
| 265 | + | return layerPorts, nil | |
| 266 | + | case under(path, p("internal/domain")): | |
| 267 | + | return layerDomain, nil | |
| 268 | + | case under(path, p("gen")): | |
| 269 | + | return layerGenerated, nil | |
| 270 | + | default: | |
| 271 | + | return "", fmt.Errorf("unclassified package %q", path) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func importPathForFile(rel string) string { | |
| 276 | + | dir := filepath.ToSlash(filepath.Dir(rel)) | |
| 277 | + | if dir == "." { | |
| 278 | + | return modulePath | |
| 279 | + | } | |
| 280 | + | return modulePath + "/" + dir | |
| 281 | + | } | |
M internal/config/config.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | 3 | // 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 | |
| 5 | 5 | // reads the process environment itself, the caller passes a lookup function. | |
| 6 | 6 | package config | |
| 7 | 7 | ||
M internal/ports/ports.go +3 -3
| @@ -5,9 +5,9 @@ | |||
| 5 | 5 | // | |
| 6 | 6 | // Outbound ports (ports.go) are what the use cases need from the outside | |
| 7 | 7 | // 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 | |
| 11 | 11 | // ports/mocks. | |
| 12 | 12 | package ports | |
| 13 | 13 | ||
M internal/ports/services.go +16 -16
| @@ -12,31 +12,31 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // The interfaces below are the use cases as the inbound adapters (the API, | |
| 15 | - | // Git over HTTP, the web) and the entrypoint see them. The types of 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 | |
| 17 | 17 | // ports/mocks stand in for them in the tests of the adapters. | |
| 18 | 18 | ||
| 19 | 19 | // 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 | |
| 21 | 21 | // implements it. | |
| 22 | 22 | type AuthService interface { | |
| 23 | 23 | Authenticate(ctx context.Context, secret string) (domain.Principal, error) | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | // Revalidator confirms that a principal obtained earlier is still | |
| 27 | - | // authorized. *app.Auth implements it. | |
| 27 | + | // authorized. *usecase.Auth implements it. | |
| 28 | 28 | type Revalidator interface { | |
| 29 | 29 | Revalidate(ctx context.Context, p domain.Principal) error | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | // 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 | |
| 34 | 34 | // part of RepoService. | |
| 35 | 35 | type RepoLookup interface { | |
| 36 | 36 | Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | - | // RepoService is the repository use cases the API serves. *app.Repos | |
| 39 | + | // RepoService is the repository use cases the API serves. *usecase.Repos | |
| 40 | 40 | // implements it. | |
| 41 | 41 | type RepoService interface { | |
| 42 | 42 | RepoLookup | |
| @@ -47,7 +47,7 @@ type RepoService interface { | |||
| 47 | 47 | Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | - | // TokenService is the access token use cases the API serves. *app.Tokens | |
| 50 | + | // TokenService is the access token use cases the API serves. *usecase.Tokens | |
| 51 | 51 | // implements it. | |
| 52 | 52 | type TokenService interface { | |
| 53 | 53 | Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error) | |
| @@ -55,7 +55,7 @@ type TokenService interface { | |||
| 55 | 55 | Revoke(ctx context.Context, p domain.Principal, id string) error | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | // BrowseService is the read side of a repository's content. *app.Browse | |
| 58 | + | // BrowseService is the read side of a repository's content. *usecase.Browse | |
| 59 | 59 | // implements it. | |
| 60 | 60 | type BrowseService interface { | |
| 61 | 61 | ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error) | |
| @@ -66,12 +66,12 @@ type BrowseService interface { | |||
| 66 | 66 | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | - | // EventService streams the events of the forge. *app.Events implements it. | |
| 69 | + | // EventService streams the events of the forge. *usecase.Events implements it. | |
| 70 | 70 | type EventService interface { | |
| 71 | 71 | Watch(ctx context.Context, p domain.Principal, params WatchParams, send func(WatchEvent) error) error | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | // MergeRequestService is the merge request use cases. *app.MergeRequests | |
| 74 | + | // MergeRequestService is the merge request use cases. *usecase.MergeRequests | |
| 75 | 75 | // implements it. | |
| 76 | 76 | type MergeRequestService interface { | |
| 77 | 77 | Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) | |
| @@ -85,13 +85,13 @@ type MergeRequestService interface { | |||
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | // SigningKeyService tells which key signs the commits of the forge. | |
| 88 | - | // *app.SigningKey implements it. | |
| 88 | + | // *usecase.SigningKey implements it. | |
| 89 | 89 | type SigningKeyService interface { | |
| 90 | 90 | Info() domain.SigningKeyInfo | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | 93 | // SessionService is the owner's web session use cases the API serves. | |
| 94 | - | // *app.Sessions implements it. | |
| 94 | + | // *usecase.Sessions implements it. | |
| 95 | 95 | type SessionService interface { | |
| 96 | 96 | CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error) | |
| 97 | 97 | SignOutEverywhere(ctx context.Context, p domain.Principal) error | |
| @@ -102,7 +102,7 @@ type SessionService interface { | |||
| 102 | 102 | var ErrLinkRejected = errors.New("login link rejected") | |
| 103 | 103 | ||
| 104 | 104 | // WebSessionService is the session side of the use cases, as the web needs | |
| 105 | - | // it. The entrypoint implements it over *app.Sessions. | |
| 105 | + | // it. The composition root implements it over *usecase.Sessions. | |
| 106 | 106 | type WebSessionService interface { | |
| 107 | 107 | // LoginLinkValid reports whether the link is pending, without using it. | |
| 108 | 108 | LoginLinkValid(nonce string) bool | |
| @@ -120,14 +120,14 @@ type WebSessionService interface { | |||
| 120 | 120 | // for an anonymous caller, domain.ErrNotFound for a repository the caller may | |
| 121 | 121 | // not write and cannot see (or that is missing and may not be created), | |
| 122 | 122 | // 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 | |
| 124 | 124 | // implements it. | |
| 125 | 125 | type PushService interface { | |
| 126 | 126 | Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | 129 | // PushHookService is what the hook socket asks of the use cases. | |
| 130 | - | // *app.Pushes implements it. | |
| 130 | + | // *usecase.Pushes implements it. | |
| 131 | 131 | type PushHookService interface { | |
| 132 | 132 | // Check decides a push before Git applies it; an error matching | |
| 133 | 133 | // domain.ErrRejected refuses it, any other error refuses it as a failure. | |
| @@ -136,7 +136,7 @@ type PushHookService interface { | |||
| 136 | 136 | Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | - | // MaintenanceScheduler queues repository maintenance. *app.Maintainer | |
| 139 | + | // MaintenanceScheduler queues repository maintenance. *usecase.Maintainer | |
| 140 | 140 | // implements it. | |
| 141 | 141 | type MaintenanceScheduler interface { | |
| 142 | 142 | Schedule(ctx context.Context, name domain.RepoName) | |
R internal/app/access.go → internal/usecase/access.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R internal/app/browse.go → internal/usecase/browse.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/browse_test.go → internal/usecase/browse_test.go +20 -20 renamed, 91% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -15,22 +15,22 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/mock" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | - | "bnema.dev/ermite/internal/app" | |
| 19 | 18 | "bnema.dev/ermite/internal/domain" | |
| 20 | 19 | "bnema.dev/ermite/internal/ports" | |
| 21 | 20 | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | "bnema.dev/ermite/internal/usecase" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | type browseFixture struct { | |
| 25 | 25 | meta *mocks.RepoMetaStore | |
| 26 | 26 | reader *mocks.RepoReader | |
| 27 | - | browse *app.Browse | |
| 27 | + | browse *usecase.Browse | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | func newBrowseFixture(t *testing.T) *browseFixture { | |
| 31 | 31 | t.Helper() | |
| 32 | 32 | f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)} | |
| 33 | - | f.browse = app.NewBrowse(f.meta, f.reader) | |
| 33 | + | f.browse = usecase.NewBrowse(f.meta, f.reader) | |
| 34 | 34 | return f | |
| 35 | 35 | } | |
| 36 | 36 | ||
| @@ -46,7 +46,7 @@ func oid(c string) domain.ObjectID { | |||
| 46 | 46 | // The reader expectation of each is what must (not) run. | |
| 47 | 47 | type browseOp struct { | |
| 48 | 48 | 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 | |
| 50 | 50 | // expect registers the reader call a permitted caller makes. | |
| 51 | 51 | expect func(r *mocks.RepoReader) | |
| 52 | 52 | } | |
| @@ -54,29 +54,29 @@ type browseOp struct { | |||
| 54 | 54 | func browseOps() []browseOp { | |
| 55 | 55 | root := domain.TreePath{} | |
| 56 | 56 | return []browseOp{ | |
| 57 | - | {"ListRefs", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 57 | + | {"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 58 | 58 | _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{}) | |
| 59 | 59 | return err | |
| 60 | 60 | }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }}, | |
| 61 | - | {"ResolveRevision", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 61 | + | {"ResolveRevision", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 62 | 62 | _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{}) | |
| 63 | 63 | return err | |
| 64 | 64 | }, func(r *mocks.RepoReader) { | |
| 65 | 65 | r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil) | |
| 66 | 66 | }}, | |
| 67 | - | {"ResolveRefPath", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 67 | + | {"ResolveRefPath", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 68 | 68 | _, err := b.ResolveRefPath(ctx, p, repo, "main/x") | |
| 69 | 69 | return err | |
| 70 | 70 | }, func(r *mocks.RepoReader) { | |
| 71 | 71 | r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil) | |
| 72 | 72 | }}, | |
| 73 | - | {"GetTree", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 73 | + | {"GetTree", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 74 | 74 | _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true) | |
| 75 | 75 | return err | |
| 76 | 76 | }, func(r *mocks.RepoReader) { | |
| 77 | 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | |
| 78 | 78 | }}, | |
| 79 | - | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 79 | + | {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 80 | 80 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | |
| 81 | 81 | if rc != nil { | |
| 82 | 82 | _ = rc.Close() | |
| @@ -86,13 +86,13 @@ func browseOps() []browseOp { | |||
| 86 | 86 | r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 87 | 87 | Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil) | |
| 88 | 88 | }}, | |
| 89 | - | {"ListCommits", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 89 | + | {"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 90 | 90 | _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{}) | |
| 91 | 91 | return err | |
| 92 | 92 | }, func(r *mocks.RepoReader) { | |
| 93 | 93 | r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil) | |
| 94 | 94 | }}, | |
| 95 | - | {"GetCommit", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 95 | + | {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 96 | 96 | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}) | |
| 97 | 97 | return err | |
| 98 | 98 | }, func(r *mocks.RepoReader) { | |
| @@ -201,7 +201,7 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||
| 201 | 201 | } | |
| 202 | 202 | return out | |
| 203 | 203 | } | |
| 204 | - | setup := func(t *testing.T, refs []domain.Ref) *app.Browse { | |
| 204 | + | setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse { | |
| 205 | 205 | f := newBrowseFixture(t) | |
| 206 | 206 | f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil) | |
| 207 | 207 | f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil) | |
| @@ -247,19 +247,19 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||
| 247 | 247 | t.Run("default and maximum page size", func(t *testing.T) { | |
| 248 | 248 | var many []domain.Ref | |
| 249 | 249 | many = append(many, ref(domain.RefKindHead, "HEAD")) | |
| 250 | - | for i := range app.MaxRefPageSize + 100 { | |
| 250 | + | for i := range usecase.MaxRefPageSize + 100 { | |
| 251 | 251 | many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i))) | |
| 252 | 252 | } | |
| 253 | 253 | b := setup(t, many) | |
| 254 | 254 | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{}) | |
| 255 | 255 | require.NoError(t, err) | |
| 256 | - | assert.Len(t, page.Refs, app.DefaultPageSize) | |
| 256 | + | assert.Len(t, page.Refs, usecase.DefaultPageSize) | |
| 257 | 257 | assert.NotEmpty(t, page.NextPageToken) | |
| 258 | 258 | ||
| 259 | 259 | b = setup(t, many) | |
| 260 | 260 | page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000}) | |
| 261 | 261 | require.NoError(t, err) | |
| 262 | - | assert.Len(t, page.Refs, app.MaxRefPageSize, "a page holds up to MaxRefPageSize refs") | |
| 262 | + | assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs") | |
| 263 | 263 | assert.NotEmpty(t, page.NextPageToken) | |
| 264 | 264 | }) | |
| 265 | 265 | ||
| @@ -354,11 +354,11 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 354 | 354 | return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":"))) | |
| 355 | 355 | } | |
| 356 | 356 | ||
| 357 | - | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == app.MaxCommitSkip })). | |
| 357 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })). | |
| 358 | 358 | Return(ports.CommitPage{Head: oid("a")}, nil).Once() | |
| 359 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip)}) | |
| 359 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)}) | |
| 360 | 360 | require.NoError(t, err, "the cap itself is allowed") | |
| 361 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip + 1)}) | |
| 361 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)}) | |
| 362 | 362 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |
| 363 | 363 | }) | |
| 364 | 364 | ||
| @@ -366,7 +366,7 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 366 | 366 | t.Parallel() | |
| 367 | 367 | f := newBrowseFixture(t) | |
| 368 | 368 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2) | |
| 369 | - | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: app.DefaultPageSize}).Return(ports.CommitPage{}, nil) | |
| 369 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil) | |
| 370 | 370 | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil) | |
| 371 | 371 | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | |
| 372 | 372 | require.NoError(t, err) | |
R internal/app/browsecache.go → internal/usecase/browsecache.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "container/list" | |
R internal/app/browsecache_test.go → internal/usecase/browsecache_test.go +5 -5 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -13,10 +13,10 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | - | "bnema.dev/ermite/internal/app" | |
| 17 | 16 | "bnema.dev/ermite/internal/domain" | |
| 18 | 17 | "bnema.dev/ermite/internal/ports" | |
| 19 | 18 | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | + | "bnema.dev/ermite/internal/usecase" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | func treePath(t *testing.T, s string) domain.TreePath { | |
| @@ -118,19 +118,19 @@ func TestHistoryCacheNotServedAcrossRecreate(t *testing.T) { | |||
| 118 | 118 | ||
| 119 | 119 | tests := []struct { | |
| 120 | 120 | name string | |
| 121 | - | call func(b *app.Browse) error | |
| 121 | + | call func(b *usecase.Browse) error | |
| 122 | 122 | // expect registers the reader call of the old repository (answers) | |
| 123 | 123 | // and of the new one (not found). | |
| 124 | 124 | expect func(r *mocks.RepoReader) | |
| 125 | 125 | }{ | |
| 126 | - | {"tree with last commits", func(b *app.Browse) error { | |
| 126 | + | {"tree with last commits", func(b *usecase.Browse) error { | |
| 127 | 127 | _, err := b.GetTree(ctx, domain.Anonymous(), n, rev, dir, true) | |
| 128 | 128 | return err | |
| 129 | 129 | }, func(r *mocks.RepoReader) { | |
| 130 | 130 | r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(treeWithLast("b"), nil).Once() | |
| 131 | 131 | r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(domain.Tree{}, domain.ErrNotFound).Once() | |
| 132 | 132 | }}, | |
| 133 | - | {"last change of a path", func(b *app.Browse) error { | |
| 133 | + | {"last change of a path", func(b *usecase.Browse) error { | |
| 134 | 134 | _, err := b.ListCommits(ctx, domain.Anonymous(), n, ports.ListCommitsParams{Revision: rev, Path: dir, PageSize: 1}) | |
| 135 | 135 | return err | |
| 136 | 136 | }, func(r *mocks.RepoReader) { | |
R internal/app/events.go → internal/usecase/events.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
A internal/usecase/events_test.go +883 -0
| @@ -0,0 +1,883 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "sync" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "testing" | |
| 12 | + | "testing/synctest" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | "bnema.dev/ermite/internal/usecase" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // eventRig is an Events use case fed by a channel the test writes to. | |
| 26 | + | type eventRig struct { | |
| 27 | + | t *testing.T | |
| 28 | + | meta *mocks.RepoMetaStore | |
| 29 | + | git *mocks.GitRepos | |
| 30 | + | feed chan domain.Event | |
| 31 | + | auth *mocks.Revalidator | |
| 32 | + | revoked sync.Map // token IDs revoked by the test | |
| 33 | + | revalidations atomic.Int32 // calls to auth.Revalidate | |
| 34 | + | events *usecase.Events | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newEventRig(t *testing.T, size int) *eventRig { | |
| 38 | + | t.Helper() | |
| 39 | + | return newEventRigConfig(t, usecase.EventsConfig{Size: size}) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func newEventRigConfig(t *testing.T, cfg usecase.EventsConfig) *eventRig { | |
| 43 | + | t.Helper() | |
| 44 | + | r := &eventRig{ | |
| 45 | + | t: t, | |
| 46 | + | meta: mocks.NewRepoMetaStore(t), | |
| 47 | + | git: mocks.NewGitRepos(t), | |
| 48 | + | feed: make(chan domain.Event, 4096), | |
| 49 | + | auth: mocks.NewRevalidator(t), | |
| 50 | + | } | |
| 51 | + | // Tokens are valid until the test revokes them. | |
| 52 | + | r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error { | |
| 53 | + | r.revalidations.Add(1) | |
| 54 | + | if _, gone := r.revoked.Load(p.TokenID()); gone { | |
| 55 | + | return domain.ErrUnauthenticated | |
| 56 | + | } | |
| 57 | + | return nil | |
| 58 | + | }).Maybe() | |
| 59 | + | cfg.Auth = r.auth | |
| 60 | + | bus := mocks.NewEventBus(t) | |
| 61 | + | bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event { | |
| 62 | + | go func() { <-ctx.Done(); close(r.feed) }() | |
| 63 | + | return r.feed | |
| 64 | + | }) | |
| 65 | + | clock := mocks.NewClock(t) | |
| 66 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 67 | + | r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe() | |
| 68 | + | repos := usecase.NewRepos(r.meta, r.git, bus, clock) | |
| 69 | + | r.events = usecase.NewEvents(context.Background(), repos, bus, clock, cfg) | |
| 70 | + | t.Cleanup(func() { r.events.Close(context.Background()) }) | |
| 71 | + | return r | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // repo declares a repository the rig knows. | |
| 75 | + | func (r *eventRig) repo(s string, vis domain.Visibility) domain.RepoName { | |
| 76 | + | n := name(r.t, s) | |
| 77 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil).Maybe() | |
| 78 | + | return n | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // pushEvent builds the PushEvent the Pushes use case would publish now: it | |
| 82 | + | // carries the repository as the store answers at this moment. | |
| 83 | + | func (r *eventRig) pushEvent(n domain.RepoName, oid, ref string) domain.PushEvent { | |
| 84 | + | repo, err := r.meta.Get(context.Background(), n) | |
| 85 | + | require.NoError(r.t, err) | |
| 86 | + | return domain.PushEvent{ | |
| 87 | + | Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: ref}}, At: now, | |
| 88 | + | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (r *eventRig) push(n domain.RepoName, i int) { | |
| 93 | + | r.feed <- r.pushEvent(n, fmt.Sprintf("%040x", i+1), "refs/heads/main") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // watcher is a running Watch. | |
| 97 | + | type watcher struct { | |
| 98 | + | got chan ports.WatchEvent | |
| 99 | + | done chan error | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (r *eventRig) watch(ctx context.Context, p domain.Principal, params ports.WatchParams) *watcher { | |
| 103 | + | w := &watcher{got: make(chan ports.WatchEvent, 4096), done: make(chan error, 1)} | |
| 104 | + | go func() { | |
| 105 | + | w.done <- r.events.Watch(ctx, p, params, func(e ports.WatchEvent) error { | |
| 106 | + | w.got <- e | |
| 107 | + | return nil | |
| 108 | + | }) | |
| 109 | + | }() | |
| 110 | + | return w | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (w *watcher) next(t *testing.T) ports.WatchEvent { | |
| 114 | + | t.Helper() | |
| 115 | + | synctest.Wait() // everything the event can reach has run | |
| 116 | + | select { | |
| 117 | + | case e := <-w.got: | |
| 118 | + | return e | |
| 119 | + | case err := <-w.done: | |
| 120 | + | t.Fatalf("watch ended: %v", err) | |
| 121 | + | default: | |
| 122 | + | t.Fatal("no event") | |
| 123 | + | } | |
| 124 | + | return ports.WatchEvent{} | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // sentinelOID marks the push events that only prove the ones before them were | |
| 128 | + | // handled. | |
| 129 | + | var sentinelOID = fmt.Sprintf("%040x", 0xfeed) | |
| 130 | + | ||
| 131 | + | // quiet proves that the watcher got nothing since its last read: it pushes an | |
| 132 | + | // event on n, which the watcher may see, and requires it to be the next one. | |
| 133 | + | // Events reach a watcher in order, so anything sent before would show up first. | |
| 134 | + | func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) { | |
| 135 | + | t.Helper() | |
| 136 | + | r.feed <- r.pushEvent(n, sentinelOID, "refs/heads/sentinel") | |
| 137 | + | e := w.next(t) | |
| 138 | + | push, ok := e.Event.(domain.PushEvent) | |
| 139 | + | if !ok || len(push.Updates) != 1 || push.Updates[0].New != sentinelOID { | |
| 140 | + | t.Fatalf("expected nothing before the sentinel, got %+v", e) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestWatchLiveAndFiltered(t *testing.T) { | |
| 145 | + | t.Parallel() | |
| 146 | + | synctest.Test(t, func(t *testing.T) { | |
| 147 | + | r := newEventRig(t, 0) | |
| 148 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 149 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 150 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 151 | + | defer cancel() | |
| 152 | + | ||
| 153 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |
| 154 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"home"}}) | |
| 155 | + | scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), ports.WatchParams{}) | |
| 156 | + | onlyPriv := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}}) | |
| 157 | + | synctest.Wait() | |
| 158 | + | require.Equal(t, 4, r.events.Watchers()) | |
| 159 | + | ||
| 160 | + | r.push(priv, 1) | |
| 161 | + | r.push(pub, 2) | |
| 162 | + | ||
| 163 | + | // Anonymous and a token limited to another repository never see priv. | |
| 164 | + | for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} { | |
| 165 | + | e := w.next(t) | |
| 166 | + | assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name) | |
| 167 | + | w.quiet(t, r, pub) | |
| 168 | + | } | |
| 169 | + | first, second := root.next(t), root.next(t) | |
| 170 | + | assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo) | |
| 171 | + | assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo) | |
| 172 | + | assert.NotEmpty(t, first.ID) | |
| 173 | + | assert.NotEqual(t, first.ID, second.ID) | |
| 174 | + | ||
| 175 | + | assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo) | |
| 176 | + | onlyPriv.quiet(t, r, priv) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestWatchRechecksAccessPerEvent(t *testing.T) { | |
| 181 | + | t.Parallel() | |
| 182 | + | synctest.Test(t, func(t *testing.T) { | |
| 183 | + | r := newEventRig(t, 0) | |
| 184 | + | n := name(t, "flip") | |
| 185 | + | vis := domain.VisibilityPublic | |
| 186 | + | var mu sync.Mutex | |
| 187 | + | r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |
| 188 | + | mu.Lock() | |
| 189 | + | defer mu.Unlock() | |
| 190 | + | return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil | |
| 191 | + | }) | |
| 192 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 193 | + | defer cancel() | |
| 194 | + | w := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |
| 195 | + | synctest.Wait() | |
| 196 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 197 | + | ||
| 198 | + | r.push(n, 1) | |
| 199 | + | w.next(t) | |
| 200 | + | ||
| 201 | + | mu.Lock() | |
| 202 | + | vis = domain.VisibilityPrivate | |
| 203 | + | mu.Unlock() | |
| 204 | + | r.push(n, 2) | |
| 205 | + | w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous | |
| 206 | + | }) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestWatchResumeAndGap(t *testing.T) { | |
| 210 | + | t.Parallel() | |
| 211 | + | const size = 8 | |
| 212 | + | // Each subtest gets its own bubble and a fresh run of 20 events recorded | |
| 213 | + | // through a first watcher; the ring then holds the last 8. | |
| 214 | + | setup := func(t *testing.T) (r *eventRig, n domain.RepoName, ctx context.Context, ids []string) { | |
| 215 | + | r = newEventRig(t, size) | |
| 216 | + | n = r.repo("pub", domain.VisibilityPublic) | |
| 217 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 218 | + | t.Cleanup(cancel) | |
| 219 | + | ||
| 220 | + | first := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 221 | + | synctest.Wait() | |
| 222 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 223 | + | // One at a time: this watcher must not fall behind the small ring. | |
| 224 | + | for i := range 20 { | |
| 225 | + | r.push(n, i) | |
| 226 | + | ids = append(ids, first.next(t).ID) | |
| 227 | + | } | |
| 228 | + | require.Len(t, ids, 20) | |
| 229 | + | return r, n, ctx, ids | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) { | |
| 233 | + | t.Parallel() | |
| 234 | + | synctest.Test(t, func(t *testing.T) { | |
| 235 | + | r, _, ctx, ids := setup(t) | |
| 236 | + | // 20 events were taken, the buffer holds the last 8: ids[12] is the | |
| 237 | + | // oldest still present; ids[0] is long gone. | |
| 238 | + | w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[0]}) | |
| 239 | + | gap := w.next(t) | |
| 240 | + | assert.True(t, gap.Gap) | |
| 241 | + | assert.Empty(t, gap.ID) | |
| 242 | + | oldest := w.next(t) | |
| 243 | + | assert.False(t, oldest.Gap) | |
| 244 | + | assert.Equal(t, ids[12], oldest.ID) | |
| 245 | + | }) | |
| 246 | + | }) | |
| 247 | + | ||
| 248 | + | t.Run("an unknown id gives a gap", func(t *testing.T) { | |
| 249 | + | t.Parallel() | |
| 250 | + | synctest.Test(t, func(t *testing.T) { | |
| 251 | + | r, _, ctx, _ := setup(t) | |
| 252 | + | for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} { | |
| 253 | + | w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: id}) | |
| 254 | + | assert.True(t, w.next(t).Gap, id) | |
| 255 | + | } | |
| 256 | + | }) | |
| 257 | + | }) | |
| 258 | + | ||
| 259 | + | t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) { | |
| 260 | + | t.Parallel() | |
| 261 | + | synctest.Test(t, func(t *testing.T) { | |
| 262 | + | r, _, ctx, _ := setup(t) | |
| 263 | + | w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: "otherboot-999"}) | |
| 264 | + | assert.True(t, w.next(t).Gap) | |
| 265 | + | }) | |
| 266 | + | }) | |
| 267 | + | ||
| 268 | + | t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) { | |
| 269 | + | t.Parallel() | |
| 270 | + | synctest.Test(t, func(t *testing.T) { | |
| 271 | + | r, n, ctx, ids := setup(t) | |
| 272 | + | w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[16]}) | |
| 273 | + | for _, want := range ids[17:] { | |
| 274 | + | e := w.next(t) | |
| 275 | + | assert.False(t, e.Gap) | |
| 276 | + | assert.Equal(t, want, e.ID) | |
| 277 | + | } | |
| 278 | + | // Nothing else was replayed: the next event is the one pushed now. | |
| 279 | + | r.push(n, 98) | |
| 280 | + | live := w.next(t) | |
| 281 | + | assert.False(t, live.Gap) | |
| 282 | + | assert.NotContains(t, ids, live.ID) | |
| 283 | + | }) | |
| 284 | + | }) | |
| 285 | + | ||
| 286 | + | t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) { | |
| 287 | + | t.Parallel() | |
| 288 | + | synctest.Test(t, func(t *testing.T) { | |
| 289 | + | r, n, ctx, ids := setup(t) | |
| 290 | + | w := r.watch(ctx, domain.Root(), ports.WatchParams{ResumeAfter: ids[len(ids)-1]}) | |
| 291 | + | r.push(n, 99) | |
| 292 | + | e := w.next(t) | |
| 293 | + | assert.False(t, e.Gap) | |
| 294 | + | assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed") | |
| 295 | + | }) | |
| 296 | + | }) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func TestWatchTopicErrors(t *testing.T) { | |
| 300 | + | t.Parallel() | |
| 301 | + | r := newEventRig(t, 0) | |
| 302 | + | r.repo("pub", domain.VisibilityPublic) | |
| 303 | + | r.repo("priv", domain.VisibilityPrivate) | |
| 304 | + | r.meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound).Maybe() | |
| 305 | + | noop := func(ports.WatchEvent) error { return nil } | |
| 306 | + | ctx := context.Background() | |
| 307 | + | ||
| 308 | + | for topic, want := range map[string]error{ | |
| 309 | + | "nope": domain.ErrInvalid, | |
| 310 | + | "repo:Bad": domain.ErrInvalidName, | |
| 311 | + | "repo:": domain.ErrInvalidName, | |
| 312 | + | "repo:gone": domain.ErrNotFound, | |
| 313 | + | "repo:priv": domain.ErrNotFound, // hidden from anonymous | |
| 314 | + | "repo:pub:foo": domain.ErrInvalidName, | |
| 315 | + | } { | |
| 316 | + | err := r.events.Watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{topic}}, noop) | |
| 317 | + | assert.ErrorIs(t, err, want, topic) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestWatchEndsCleanly(t *testing.T) { | |
| 322 | + | t.Parallel() | |
| 323 | + | t.Run("on context cancellation", func(t *testing.T) { | |
| 324 | + | t.Parallel() | |
| 325 | + | synctest.Test(t, func(t *testing.T) { | |
| 326 | + | r := newEventRig(t, 0) | |
| 327 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 328 | + | w := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 329 | + | synctest.Wait() | |
| 330 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 331 | + | cancel() | |
| 332 | + | synctest.Wait() | |
| 333 | + | select { | |
| 334 | + | case err := <-w.done: | |
| 335 | + | assert.NoError(t, err) | |
| 336 | + | default: | |
| 337 | + | t.Fatal("watch did not end") | |
| 338 | + | } | |
| 339 | + | assert.Zero(t, r.events.Watchers()) | |
| 340 | + | }) | |
| 341 | + | }) | |
| 342 | + | ||
| 343 | + | t.Run("on Close", func(t *testing.T) { | |
| 344 | + | t.Parallel() | |
| 345 | + | synctest.Test(t, func(t *testing.T) { | |
| 346 | + | r := newEventRig(t, 0) | |
| 347 | + | w := r.watch(context.Background(), domain.Root(), ports.WatchParams{}) | |
| 348 | + | synctest.Wait() | |
| 349 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 350 | + | r.events.Close(context.Background()) | |
| 351 | + | r.events.Close(context.Background()) // twice is fine | |
| 352 | + | synctest.Wait() | |
| 353 | + | select { | |
| 354 | + | case err := <-w.done: | |
| 355 | + | assert.NoError(t, err) | |
| 356 | + | default: | |
| 357 | + | t.Fatal("watch did not end") | |
| 358 | + | } | |
| 359 | + | }) | |
| 360 | + | }) | |
| 361 | + | ||
| 362 | + | t.Run("a failing send ends the watch with its error", func(t *testing.T) { | |
| 363 | + | t.Parallel() | |
| 364 | + | synctest.Test(t, func(t *testing.T) { | |
| 365 | + | r := newEventRig(t, 0) | |
| 366 | + | n := r.repo("pub", domain.VisibilityPublic) | |
| 367 | + | boom := errors.New("client went away") | |
| 368 | + | done := make(chan error, 1) | |
| 369 | + | go func() { | |
| 370 | + | done <- r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{}, func(ports.WatchEvent) error { return boom }) | |
| 371 | + | }() | |
| 372 | + | synctest.Wait() | |
| 373 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 374 | + | r.push(n, 1) | |
| 375 | + | assert.ErrorIs(t, <-done, boom) | |
| 376 | + | }) | |
| 377 | + | }) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestWatchSlowWatcherGetsAGap(t *testing.T) { | |
| 381 | + | t.Parallel() | |
| 382 | + | synctest.Test(t, func(t *testing.T) { | |
| 383 | + | const size = 4 | |
| 384 | + | r := newEventRig(t, size) | |
| 385 | + | n := r.repo("pub", domain.VisibilityPublic) | |
| 386 | + | ||
| 387 | + | release := make(chan struct{}) | |
| 388 | + | var mu sync.Mutex | |
| 389 | + | var got []ports.WatchEvent | |
| 390 | + | done := make(chan error, 1) | |
| 391 | + | blocked := make(chan struct{}) | |
| 392 | + | first := true | |
| 393 | + | go func() { | |
| 394 | + | done <- r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{}, func(e ports.WatchEvent) error { | |
| 395 | + | if first { // block on the first event while the ring turns over | |
| 396 | + | first = false | |
| 397 | + | close(blocked) | |
| 398 | + | <-release | |
| 399 | + | } | |
| 400 | + | mu.Lock() | |
| 401 | + | got = append(got, e) | |
| 402 | + | mu.Unlock() | |
| 403 | + | return nil | |
| 404 | + | }) | |
| 405 | + | }() | |
| 406 | + | // A second, fast watcher tells when the use case has taken every event: | |
| 407 | + | // it receives them in order. | |
| 408 | + | fast := r.watch(context.Background(), domain.Root(), ports.WatchParams{}) | |
| 409 | + | synctest.Wait() | |
| 410 | + | require.Equal(t, 2, r.events.Watchers()) | |
| 411 | + | r.push(n, 0) | |
| 412 | + | <-blocked | |
| 413 | + | for i := 1; i < 30; i++ { | |
| 414 | + | r.push(n, i) | |
| 415 | + | } | |
| 416 | + | // The ring holds four events: the fast watcher may get a gap, but it ends | |
| 417 | + | // on the last push, which proves the use case took all of them. | |
| 418 | + | last := fmt.Sprintf("%040x", 29+1) | |
| 419 | + | for { | |
| 420 | + | e := fast.next(t) | |
| 421 | + | if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last { | |
| 422 | + | break | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | close(release) | |
| 426 | + | ||
| 427 | + | synctest.Wait() | |
| 428 | + | mu.Lock() | |
| 429 | + | defer mu.Unlock() | |
| 430 | + | require.GreaterOrEqual(t, len(got), 2) | |
| 431 | + | hasGap := false | |
| 432 | + | for _, e := range got[1:] { | |
| 433 | + | hasGap = hasGap || e.Gap | |
| 434 | + | } | |
| 435 | + | assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got) | |
| 436 | + | }) | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func TestEventsRepoPayloads(t *testing.T) { | |
| 440 | + | t.Parallel() | |
| 441 | + | synctest.Test(t, func(t *testing.T) { | |
| 442 | + | r := newEventRig(t, 0) | |
| 443 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 444 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 445 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 446 | + | defer cancel() | |
| 447 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |
| 448 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 449 | + | synctest.Wait() | |
| 450 | + | require.Equal(t, 2, r.events.Watchers()) | |
| 451 | + | ||
| 452 | + | r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now} | |
| 453 | + | r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now} | |
| 454 | + | // A deleted repository cannot be read any more: its visibility decides. | |
| 455 | + | r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now} | |
| 456 | + | r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now} | |
| 457 | + | ||
| 458 | + | for _, w := range []*watcher{anon, root} { | |
| 459 | + | created := w.next(t) | |
| 460 | + | require.NotNil(t, created.Repo) | |
| 461 | + | assert.Equal(t, "main", created.Repo.DefaultBranch) | |
| 462 | + | assert.Equal(t, pub, created.Repo.Name) | |
| 463 | + | require.NotNil(t, w.next(t).Repo) | |
| 464 | + | } | |
| 465 | + | assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo) | |
| 466 | + | assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo) | |
| 467 | + | assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped") | |
| 468 | + | anon.quiet(t, r, pub) | |
| 469 | + | }) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // tokenWith builds the principal of a stored token with an id and an expiry. | |
| 473 | + | func tokenWith(id string, expires time.Time, repos ...string) domain.Principal { | |
| 474 | + | tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires} | |
| 475 | + | for _, r := range repos { | |
| 476 | + | n, err := domain.ParseRepoName(r) | |
| 477 | + | if err != nil { | |
| 478 | + | panic(err) | |
| 479 | + | } | |
| 480 | + | tok.Repos = append(tok.Repos, n) | |
| 481 | + | } | |
| 482 | + | return domain.TokenPrincipal(tok) | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |
| 486 | + | t.Parallel() | |
| 487 | + | synctest.Test(t, func(t *testing.T) { | |
| 488 | + | // Asking at every turn keeps the test independent of the clock. | |
| 489 | + | r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1}) | |
| 490 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 491 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 492 | + | p := tokenWith("tok", time.Time{}) | |
| 493 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 494 | + | defer cancel() | |
| 495 | + | ||
| 496 | + | w := r.watch(ctx, p, ports.WatchParams{}) | |
| 497 | + | synctest.Wait() | |
| 498 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 499 | + | r.push(pub, 1) | |
| 500 | + | w.next(t) | |
| 501 | + | ||
| 502 | + | r.revoked.Store("tok", true) | |
| 503 | + | r.push(priv, 2) // would be delivered to a token that may read priv | |
| 504 | + | synctest.Wait() | |
| 505 | + | select { | |
| 506 | + | case err := <-w.done: | |
| 507 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 508 | + | default: | |
| 509 | + | t.Fatal("the watch of a revoked token did not end") | |
| 510 | + | } | |
| 511 | + | select { | |
| 512 | + | case e := <-w.got: | |
| 513 | + | t.Fatalf("an event was sent after the revocation: %+v", e) | |
| 514 | + | default: | |
| 515 | + | } | |
| 516 | + | }) | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func TestWatchEndsWhenTheTokenExpires(t *testing.T) { | |
| 520 | + | t.Parallel() | |
| 521 | + | synctest.Test(t, func(t *testing.T) { | |
| 522 | + | r := newEventRig(t, 0) | |
| 523 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 524 | + | // The rig's clock stands at now: this token expired at now. | |
| 525 | + | p := tokenWith("tok", now) | |
| 526 | + | w := r.watch(context.Background(), p, ports.WatchParams{}) | |
| 527 | + | r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated | |
| 528 | + | synctest.Wait() | |
| 529 | + | select { | |
| 530 | + | case err := <-w.done: | |
| 531 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 532 | + | default: | |
| 533 | + | t.Fatal("the watch of an expired token did not end") | |
| 534 | + | } | |
| 535 | + | assert.Empty(t, w.got) | |
| 536 | + | }) | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |
| 540 | + | t.Parallel() | |
| 541 | + | t.Run("a quiet forge still notices a revocation", func(t *testing.T) { | |
| 542 | + | t.Parallel() | |
| 543 | + | synctest.Test(t, func(t *testing.T) { | |
| 544 | + | r := newEventRigConfig(t, usecase.EventsConfig{}) // the production default interval | |
| 545 | + | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), ports.WatchParams{}) | |
| 546 | + | synctest.Wait() | |
| 547 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 548 | + | r.revoked.Store("tok", true) | |
| 549 | + | synctest.Sleep(usecase.DefaultRevalidateEvery - time.Nanosecond) | |
| 550 | + | select { | |
| 551 | + | case err := <-w.done: | |
| 552 | + | t.Fatalf("the watch ended before the interval: %v", err) | |
| 553 | + | default: | |
| 554 | + | } | |
| 555 | + | assert.Zero(t, r.revalidations.Load(), "no store lookup before the interval") | |
| 556 | + | synctest.Sleep(time.Nanosecond) | |
| 557 | + | select { | |
| 558 | + | case err := <-w.done: | |
| 559 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 560 | + | default: | |
| 561 | + | t.Fatal("an idle watch of a revoked token did not end at the interval") | |
| 562 | + | } | |
| 563 | + | assert.Equal(t, int32(1), r.revalidations.Load()) | |
| 564 | + | }) | |
| 565 | + | }) | |
| 566 | + | ||
| 567 | + | t.Run("the answer is kept between checks", func(t *testing.T) { | |
| 568 | + | t.Parallel() | |
| 569 | + | synctest.Test(t, func(t *testing.T) { | |
| 570 | + | r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: time.Hour}) | |
| 571 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 572 | + | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), ports.WatchParams{}) | |
| 573 | + | synctest.Wait() | |
| 574 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 575 | + | for i := range 20 { | |
| 576 | + | r.push(pub, i) | |
| 577 | + | } | |
| 578 | + | for range 20 { | |
| 579 | + | w.next(t) | |
| 580 | + | } | |
| 581 | + | // Idle, the watcher wakes up every few seconds but keeps its answer | |
| 582 | + | // until the interval is over. | |
| 583 | + | synctest.Sleep(time.Hour - time.Nanosecond) | |
| 584 | + | assert.Zero(t, r.revalidations.Load(), "no store lookup within the interval") | |
| 585 | + | synctest.Sleep(time.Nanosecond) | |
| 586 | + | assert.Equal(t, int32(1), r.revalidations.Load(), "one lookup when the interval is over") | |
| 587 | + | }) | |
| 588 | + | }) | |
| 589 | + | ||
| 590 | + | t.Run("root and anonymous never ask", func(t *testing.T) { | |
| 591 | + | t.Parallel() | |
| 592 | + | synctest.Test(t, func(t *testing.T) { | |
| 593 | + | r := newEventRigConfig(t, usecase.EventsConfig{RevalidateEvery: -1}) | |
| 594 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 595 | + | a := r.watch(context.Background(), domain.Anonymous(), ports.WatchParams{}) | |
| 596 | + | b := r.watch(context.Background(), domain.Root(), ports.WatchParams{}) | |
| 597 | + | synctest.Wait() | |
| 598 | + | require.Equal(t, 2, r.events.Watchers()) | |
| 599 | + | r.push(pub, 1) | |
| 600 | + | a.next(t) | |
| 601 | + | b.next(t) | |
| 602 | + | r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) | |
| 603 | + | }) | |
| 604 | + | }) | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func TestWatchLimits(t *testing.T) { | |
| 608 | + | t.Parallel() | |
| 609 | + | noop := func(ports.WatchEvent) error { return nil } | |
| 610 | + | ||
| 611 | + | t.Run("too many topics", func(t *testing.T) { | |
| 612 | + | t.Parallel() | |
| 613 | + | synctest.Test(t, func(t *testing.T) { | |
| 614 | + | r := newEventRig(t, 0) | |
| 615 | + | r.repo("pub", domain.VisibilityPublic) | |
| 616 | + | ok := make([]string, usecase.MaxWatchTopics) | |
| 617 | + | for i := range ok { | |
| 618 | + | ok[i] = "home" | |
| 619 | + | } | |
| 620 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 621 | + | cancel() // returns at once once the topics are accepted | |
| 622 | + | assert.NoError(t, r.events.Watch(ctx, domain.Root(), ports.WatchParams{Topics: ok}, noop)) | |
| 623 | + | err := r.events.Watch(context.Background(), domain.Root(), ports.WatchParams{Topics: append(ok, "home")}, noop) | |
| 624 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 625 | + | }) | |
| 626 | + | }) | |
| 627 | + | ||
| 628 | + | t.Run("total watchers", func(t *testing.T) { | |
| 629 | + | t.Parallel() | |
| 630 | + | synctest.Test(t, func(t *testing.T) { | |
| 631 | + | r := newEventRig(t, 0) | |
| 632 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 633 | + | defer cancel() | |
| 634 | + | for range usecase.MaxWatchers { | |
| 635 | + | r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 636 | + | } | |
| 637 | + | synctest.Wait() | |
| 638 | + | require.Equal(t, usecase.MaxWatchers, r.events.Watchers()) | |
| 639 | + | err := r.events.Watch(ctx, domain.Root(), ports.WatchParams{}, noop) | |
| 640 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 641 | + | assert.Equal(t, usecase.MaxWatchers, r.events.Watchers(), "a refused watch is not counted") | |
| 642 | + | ||
| 643 | + | cancel() // all end; a new one is accepted again | |
| 644 | + | synctest.Wait() | |
| 645 | + | require.Equal(t, 0, r.events.Watchers()) | |
| 646 | + | again, stop := context.WithCancel(context.Background()) | |
| 647 | + | stop() | |
| 648 | + | assert.NoError(t, r.events.Watch(again, domain.Root(), ports.WatchParams{}, noop)) | |
| 649 | + | }) | |
| 650 | + | }) | |
| 651 | + | ||
| 652 | + | t.Run("watchers per client", func(t *testing.T) { | |
| 653 | + | t.Parallel() | |
| 654 | + | synctest.Test(t, func(t *testing.T) { | |
| 655 | + | r := newEventRig(t, 0) | |
| 656 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 657 | + | defer cancel() | |
| 658 | + | for range usecase.MaxWatchersPerClient { | |
| 659 | + | r.watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.1"}) | |
| 660 | + | } | |
| 661 | + | synctest.Wait() | |
| 662 | + | require.Equal(t, usecase.MaxWatchersPerClient, r.events.Watchers()) | |
| 663 | + | ||
| 664 | + | err := r.events.Watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.1"}, noop) | |
| 665 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 666 | + | ||
| 667 | + | // Another client, and callers in process, are not affected. | |
| 668 | + | r.watch(ctx, domain.Root(), ports.WatchParams{Client: "198.51.100.2"}) | |
| 669 | + | r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 670 | + | synctest.Wait() | |
| 671 | + | require.Equal(t, usecase.MaxWatchersPerClient+2, r.events.Watchers()) | |
| 672 | + | ||
| 673 | + | cancel() | |
| 674 | + | synctest.Wait() | |
| 675 | + | require.Equal(t, 0, r.events.Watchers()) | |
| 676 | + | again, stop := context.WithCancel(context.Background()) | |
| 677 | + | stop() | |
| 678 | + | assert.NoError(t, r.events.Watch(again, domain.Root(), ports.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released") | |
| 679 | + | }) | |
| 680 | + | }) | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | // repoAt declares a repository whose metadata the test changes. | |
| 684 | + | type liveRepo struct { | |
| 685 | + | mu sync.Mutex | |
| 686 | + | repo domain.Repo | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | func (l *liveRepo) set(f func(*domain.Repo)) { | |
| 690 | + | l.mu.Lock() | |
| 691 | + | defer l.mu.Unlock() | |
| 692 | + | f(&l.repo) | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time) (*liveRepo, domain.RepoName) { | |
| 696 | + | n := name(r.t, s) | |
| 697 | + | l := &liveRepo{repo: domain.Repo{Name: n, Visibility: vis, CreatedAt: created}} | |
| 698 | + | r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |
| 699 | + | l.mu.Lock() | |
| 700 | + | defer l.mu.Unlock() | |
| 701 | + | return l.repo, nil | |
| 702 | + | }).Maybe() | |
| 703 | + | return l, n | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) { | |
| 707 | + | t.Parallel() | |
| 708 | + | t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) { | |
| 709 | + | t.Parallel() | |
| 710 | + | synctest.Test(t, func(t *testing.T) { | |
| 711 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 712 | + | defer cancel() | |
| 713 | + | r := newEventRig(t, 0) | |
| 714 | + | l, n := r.liveRepo("secret", domain.VisibilityPrivate, now) | |
| 715 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 716 | + | synctest.Wait() | |
| 717 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 718 | + | r.push(n, 1) | |
| 719 | + | r.push(n, 2) | |
| 720 | + | root.next(t) | |
| 721 | + | root.next(t) // both are buffered now | |
| 722 | + | ||
| 723 | + | l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }) | |
| 724 | + | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now} | |
| 725 | + | root.next(t) | |
| 726 | + | r.push(n, 3) // public era | |
| 727 | + | ||
| 728 | + | // An anonymous caller that resumes with an id nobody issued gets what | |
| 729 | + | // is buffered, filtered: the public update and the public push, not the | |
| 730 | + | // pushes of the private era. | |
| 731 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"}) | |
| 732 | + | assert.True(t, anon.next(t).Gap) | |
| 733 | + | upd := anon.next(t) | |
| 734 | + | require.IsType(t, domain.RepoUpdated{}, upd.Event) | |
| 735 | + | push := anon.next(t) | |
| 736 | + | require.IsType(t, domain.PushEvent{}, push.Event) | |
| 737 | + | assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New) | |
| 738 | + | anon.quiet(t, r, n) | |
| 739 | + | }) | |
| 740 | + | }) | |
| 741 | + | ||
| 742 | + | t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) { | |
| 743 | + | t.Parallel() | |
| 744 | + | synctest.Test(t, func(t *testing.T) { | |
| 745 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 746 | + | defer cancel() | |
| 747 | + | r := newEventRig(t, 0) | |
| 748 | + | first := now.Add(-time.Hour) | |
| 749 | + | l, n := r.liveRepo("again", domain.VisibilityPublic, first) | |
| 750 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 751 | + | synctest.Wait() | |
| 752 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 753 | + | r.push(n, 1) | |
| 754 | + | root.next(t) | |
| 755 | + | ||
| 756 | + | l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again | |
| 757 | + | r.push(n, 2) | |
| 758 | + | root.next(t) | |
| 759 | + | ||
| 760 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"}) | |
| 761 | + | assert.True(t, anon.next(t).Gap) | |
| 762 | + | e := anon.next(t) | |
| 763 | + | require.IsType(t, domain.PushEvent{}, e.Event) | |
| 764 | + | assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository") | |
| 765 | + | anon.quiet(t, r, n) | |
| 766 | + | }) | |
| 767 | + | }) | |
| 768 | + | ||
| 769 | + | t.Run("a repository made private tells those who could read it, once", func(t *testing.T) { | |
| 770 | + | t.Parallel() | |
| 771 | + | synctest.Test(t, func(t *testing.T) { | |
| 772 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 773 | + | defer cancel() | |
| 774 | + | r := newEventRig(t, 0) | |
| 775 | + | l, n := r.liveRepo("hide", domain.VisibilityPublic, now) | |
| 776 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |
| 777 | + | reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), ports.WatchParams{}) | |
| 778 | + | other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), ports.WatchParams{}) | |
| 779 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 780 | + | synctest.Wait() | |
| 781 | + | require.Equal(t, 4, r.events.Watchers()) | |
| 782 | + | ||
| 783 | + | l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate }) | |
| 784 | + | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now} | |
| 785 | + | ||
| 786 | + | for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} { | |
| 787 | + | e := w.next(t) | |
| 788 | + | up, ok := e.Event.(domain.RepoUpdated) | |
| 789 | + | require.True(t, ok, name) | |
| 790 | + | assert.Equal(t, n, up.Repo, name) | |
| 791 | + | require.NotNil(t, e.Repo, name) | |
| 792 | + | assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name) | |
| 793 | + | assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name) | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | // Later events of the repository reach those who may read it now, only. | |
| 797 | + | r.push(n, 1) | |
| 798 | + | assert.IsType(t, domain.PushEvent{}, reader.next(t).Event) | |
| 799 | + | assert.IsType(t, domain.PushEvent{}, root.next(t).Event) | |
| 800 | + | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |
| 801 | + | other.quiet(t, r, r.repo("open2", domain.VisibilityPublic)) | |
| 802 | + | }) | |
| 803 | + | }) | |
| 804 | + | ||
| 805 | + | t.Run("a private repository that never was public stays hidden", func(t *testing.T) { | |
| 806 | + | t.Parallel() | |
| 807 | + | synctest.Test(t, func(t *testing.T) { | |
| 808 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 809 | + | defer cancel() | |
| 810 | + | r := newEventRig(t, 0) | |
| 811 | + | _, n := r.liveRepo("closed", domain.VisibilityPrivate, now) | |
| 812 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |
| 813 | + | synctest.Wait() | |
| 814 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 815 | + | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now} | |
| 816 | + | r.push(n, 1) | |
| 817 | + | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |
| 818 | + | }) | |
| 819 | + | }) | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | // TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was | |
| 823 | + | // when published, not as the store says when the feeder gets to it. | |
| 824 | + | func TestPushEventsKeepTheirSnapshot(t *testing.T) { | |
| 825 | + | t.Parallel() | |
| 826 | + | tests := []struct { | |
| 827 | + | name string | |
| 828 | + | swap func(repo *domain.Repo) | |
| 829 | + | rootSees bool // the repository is still the one the push is about | |
| 830 | + | }{ | |
| 831 | + | {"made public before the feeder appends", func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }, true}, | |
| 832 | + | {"recreated before the feeder appends", func(repo *domain.Repo) { | |
| 833 | + | repo.Visibility = domain.VisibilityPublic | |
| 834 | + | repo.CreatedAt = now.Add(time.Hour) | |
| 835 | + | }, false}, | |
| 836 | + | } | |
| 837 | + | for _, tt := range tests { | |
| 838 | + | t.Run(tt.name, func(t *testing.T) { | |
| 839 | + | t.Parallel() | |
| 840 | + | synctest.Test(t, func(t *testing.T) { | |
| 841 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 842 | + | defer cancel() | |
| 843 | + | r := newEventRig(t, 0) | |
| 844 | + | l, n := r.liveRepo("secret", domain.VisibilityPrivate, now) | |
| 845 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{}) | |
| 846 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 847 | + | synctest.Wait() | |
| 848 | + | require.Equal(t, 2, r.events.Watchers()) | |
| 849 | + | ||
| 850 | + | // Published while private; the store changes before the feeder | |
| 851 | + | // can have looked at it: the event is built, then the state is | |
| 852 | + | // swapped, and only then is it handed to the feeder. | |
| 853 | + | ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main") | |
| 854 | + | l.set(tt.swap) | |
| 855 | + | r.feed <- ev | |
| 856 | + | if tt.rootSees { | |
| 857 | + | root.next(t) | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |
| 861 | + | ||
| 862 | + | late := r.watch(ctx, domain.Anonymous(), ports.WatchParams{ResumeAfter: "bogus"}) | |
| 863 | + | assert.True(t, late.next(t).Gap) | |
| 864 | + | late.quiet(t, r, r.repo("open2", domain.VisibilityPublic)) | |
| 865 | + | }) | |
| 866 | + | }) | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) { | |
| 870 | + | t.Parallel() | |
| 871 | + | synctest.Test(t, func(t *testing.T) { | |
| 872 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 873 | + | defer cancel() | |
| 874 | + | r := newEventRig(t, 0) | |
| 875 | + | n := r.repo("ghost", domain.VisibilityPublic) | |
| 876 | + | root := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 877 | + | synctest.Wait() | |
| 878 | + | require.Equal(t, 1, r.events.Watchers()) | |
| 879 | + | r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now} | |
| 880 | + | root.quiet(t, r, n) | |
| 881 | + | }) | |
| 882 | + | }) | |
| 883 | + | } | |
R internal/app/export_test.go → internal/usecase/export_test.go +1 -1 renamed, 92% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import "bnema.dev/ermite/internal/domain" | |
| 6 | 6 | ||
R internal/app/maintenance.go → internal/usecase/maintenance.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/maintenance_test.go → internal/usecase/maintenance_test.go +16 -16 renamed, 86% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -15,9 +15,9 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/assert" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | - | "bnema.dev/ermite/internal/app" | |
| 19 | 18 | "bnema.dev/ermite/internal/domain" | |
| 20 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // Every test runs in a synctest bubble with a fake clock: the delays are the | |
| @@ -35,13 +35,13 @@ func TestMaintainerDebounces(t *testing.T) { | |||
| 35 | 35 | runs.Add(1) | |
| 36 | 36 | return nil | |
| 37 | 37 | }) | |
| 38 | - | m := app.NewMaintainer(ctx, git, app.MaintenanceDelay) | |
| 38 | + | m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay) | |
| 39 | 39 | defer m.Close(ctx) | |
| 40 | 40 | ||
| 41 | 41 | for range 10 { | |
| 42 | 42 | m.Schedule(ctx, n) | |
| 43 | 43 | } | |
| 44 | - | synctest.Sleep(app.MaintenanceDelay - time.Nanosecond) | |
| 44 | + | synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond) | |
| 45 | 45 | assert.Equal(t, int32(0), runs.Load(), "waits for the full delay") | |
| 46 | 46 | synctest.Sleep(time.Nanosecond) | |
| 47 | 47 | assert.Equal(t, int32(1), runs.Load(), "a burst makes one run") | |
| @@ -49,7 +49,7 @@ func TestMaintainerDebounces(t *testing.T) { | |||
| 49 | 49 | assert.Equal(t, int32(1), runs.Load(), "no second run is coming") | |
| 50 | 50 | ||
| 51 | 51 | m.Schedule(ctx, n) | |
| 52 | - | synctest.Sleep(app.MaintenanceDelay - time.Nanosecond) | |
| 52 | + | synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond) | |
| 53 | 53 | assert.Equal(t, int32(1), runs.Load(), "a second burst waits for its own delay") | |
| 54 | 54 | synctest.Sleep(time.Nanosecond) | |
| 55 | 55 | assert.Equal(t, int32(2), runs.Load(), "a second burst, a second run") | |
| @@ -82,8 +82,8 @@ func TestMaintainerOneRunAtATimePerRepo(t *testing.T) { | |||
| 82 | 82 | otherRan.Store(true) | |
| 83 | 83 | return nil | |
| 84 | 84 | }) | |
| 85 | - | const d = app.MaintenanceDelay | |
| 86 | - | m := app.NewMaintainer(ctx, git, d) | |
| 85 | + | const d = usecase.MaintenanceDelay | |
| 86 | + | m := usecase.NewMaintainer(ctx, git, d) | |
| 87 | 87 | defer m.Close(ctx) | |
| 88 | 88 | ||
| 89 | 89 | m.Schedule(ctx, n) | |
| @@ -121,9 +121,9 @@ func TestMaintainerTellsTheReaderAfterARun(t *testing.T) { | |||
| 121 | 121 | told := make(chan struct{}, 2) | |
| 122 | 122 | fresh := mocks.NewRepoFreshener(t) | |
| 123 | 123 | fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once() | |
| 124 | - | m := app.NewMaintainer(ctx, git, app.MaintenanceDelay).WithFreshener(fresh) | |
| 124 | + | m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay).WithFreshener(fresh) | |
| 125 | 125 | m.Schedule(ctx, n) | |
| 126 | - | synctest.Sleep(app.MaintenanceDelay - time.Nanosecond) | |
| 126 | + | synctest.Sleep(usecase.MaintenanceDelay - time.Nanosecond) | |
| 127 | 127 | assert.Empty(t, told, "not before the run") | |
| 128 | 128 | synctest.Sleep(time.Nanosecond) | |
| 129 | 129 | assert.Len(t, told, 1, "the reader is told when the run ends") | |
| @@ -146,13 +146,13 @@ func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) { | |||
| 146 | 146 | runs.Add(1) | |
| 147 | 147 | return errors.New("boom") | |
| 148 | 148 | }) | |
| 149 | - | m := app.NewMaintainer(ctx, git, app.MaintenanceDelay) | |
| 149 | + | m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay) | |
| 150 | 150 | defer m.Close(ctx) | |
| 151 | 151 | m.Schedule(ctx, n) | |
| 152 | - | synctest.Sleep(app.MaintenanceDelay) | |
| 152 | + | synctest.Sleep(usecase.MaintenanceDelay) | |
| 153 | 153 | assert.Equal(t, int32(1), runs.Load()) | |
| 154 | 154 | m.Schedule(ctx, n) | |
| 155 | - | synctest.Sleep(app.MaintenanceDelay) | |
| 155 | + | synctest.Sleep(usecase.MaintenanceDelay) | |
| 156 | 156 | assert.Equal(t, int32(2), runs.Load(), "later runs still happen") | |
| 157 | 157 | }) | |
| 158 | 158 | } | |
| @@ -166,7 +166,7 @@ func TestMaintainerClose(t *testing.T) { | |||
| 166 | 166 | t.Parallel() | |
| 167 | 167 | synctest.Test(t, func(t *testing.T) { | |
| 168 | 168 | git := mocks.NewGitRepos(t) // no Maintain expected | |
| 169 | - | m := app.NewMaintainer(ctx, git, app.MaintenanceDelay) | |
| 169 | + | m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay) | |
| 170 | 170 | m.Schedule(ctx, n) | |
| 171 | 171 | m.Close(ctx) | |
| 172 | 172 | m.Schedule(ctx, n) // after Close: ignored | |
| @@ -185,9 +185,9 @@ func TestMaintainerClose(t *testing.T) { | |||
| 185 | 185 | cancelled.Store(true) | |
| 186 | 186 | return ctx.Err() | |
| 187 | 187 | }) | |
| 188 | - | m := app.NewMaintainer(ctx, git, app.MaintenanceDelay) | |
| 188 | + | m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay) | |
| 189 | 189 | 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 | |
| 191 | 191 | assert.False(t, cancelled.Load()) | |
| 192 | 192 | m.Close(ctx) // a hang is a bubble deadlock: reported, no timeout needed | |
| 193 | 193 | assert.True(t, cancelled.Load()) | |
| @@ -201,7 +201,7 @@ func TestMaintainerConcurrentSchedule(t *testing.T) { | |||
| 201 | 201 | ctx := context.Background() | |
| 202 | 202 | git := mocks.NewGitRepos(t) | |
| 203 | 203 | git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe() | |
| 204 | - | m := app.NewMaintainer(ctx, git, app.MaintenanceDelay) | |
| 204 | + | m := usecase.NewMaintainer(ctx, git, usecase.MaintenanceDelay) | |
| 205 | 205 | var wg sync.WaitGroup | |
| 206 | 206 | for i := range 8 { | |
| 207 | 207 | wg.Add(1) | |
R internal/app/mergerequests.go → internal/usecase/mergerequests.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/mergerequests_delete_test.go → internal/usecase/mergerequests_delete_test.go +3 -3 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -10,9 +10,9 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 13 | - | "bnema.dev/ermite/internal/app" | |
| 14 | 13 | "bnema.dev/ermite/internal/domain" | |
| 15 | 14 | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "bnema.dev/ermite/internal/usecase" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | // A merge can delete its source branch; the push event carries both refs. | |
| @@ -181,7 +181,7 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) { | |||
| 181 | 181 | head := r.feature("feat") | |
| 182 | 182 | mr := r.open("feat") | |
| 183 | 183 | var moved domain.ObjectID | |
| 184 | - | mrs := app.NewMergeRequests(app.MergeRequestsConfig{ | |
| 184 | + | mrs := usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 185 | 185 | Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, | |
| 186 | 186 | Git: movingGit{Git: r.git, move: func() { | |
| 187 | 187 | moved = r.commit(author, "late.txt", "late\n", "pushed during the merge") | |
R internal/app/mergerequests_integration_test.go → internal/usecase/mergerequests_integration_test.go +12 -12 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -22,10 +22,10 @@ import ( | |||
| 22 | 22 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 23 | 23 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 24 | 24 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 25 | - | "bnema.dev/ermite/internal/app" | |
| 26 | 25 | "bnema.dev/ermite/internal/domain" | |
| 27 | 26 | "bnema.dev/ermite/internal/ports" | |
| 28 | 27 | "bnema.dev/ermite/internal/ports/mocks" | |
| 28 | + | "bnema.dev/ermite/internal/usecase" | |
| 29 | 29 | ) | |
| 30 | 30 | ||
| 31 | 31 | // queuedRepos records the repositories whose maintenance was queued. | |
| @@ -53,9 +53,9 @@ type mrRig struct { | |||
| 53 | 53 | t *testing.T | |
| 54 | 54 | ctx context.Context | |
| 55 | 55 | 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 | |
| 59 | 59 | store *kvstore.Store | |
| 60 | 60 | git *gitcli.Git | |
| 61 | 61 | bus *eventbus.Bus | |
| @@ -101,12 +101,12 @@ func newMRRig(t *testing.T) *mrRig { | |||
| 101 | 101 | r := &mrRig{ | |
| 102 | 102 | sched: sched, queued: queued, | |
| 103 | 103 | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx), | |
| 104 | - | repos: app.NewRepos(store, git, bus, fixedClock{}), | |
| 105 | - | key: app.NewSigningKey(signer, committer), | |
| 104 | + | repos: usecase.NewRepos(store, git, bus, fixedClock{}), | |
| 105 | + | key: usecase.NewSigningKey(signer, committer), | |
| 106 | 106 | work: t.TempDir(), | |
| 107 | 107 | repo: name(t, "proj"), | |
| 108 | 108 | } | |
| 109 | - | r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | |
| 109 | + | r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 110 | 110 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 111 | 111 | Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched, | |
| 112 | 112 | }) | |
| @@ -543,14 +543,14 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) { | |||
| 543 | 543 | r := newMRRig(t) | |
| 544 | 544 | r.seed() | |
| 545 | 545 | r.sh("checkout", "-q", "-B", "long", "main") | |
| 546 | - | for i := range app.MaxMergeRequestCommits + 5 { | |
| 546 | + | for i := range usecase.MaxMergeRequestCommits + 5 { | |
| 547 | 547 | r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i)) | |
| 548 | 548 | } | |
| 549 | 549 | r.push("long") | |
| 550 | 550 | mr := r.open("long") | |
| 551 | 551 | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 552 | 552 | require.NoError(t, err) | |
| 553 | - | assert.Len(t, d.Commits, app.MaxMergeRequestCommits) | |
| 553 | + | assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits) | |
| 554 | 554 | assert.True(t, d.CommitsTruncated) | |
| 555 | 555 | assert.Equal(t, "c104", d.Commits[0].Summary) | |
| 556 | 556 | } | |
| @@ -1285,7 +1285,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) { | |||
| 1285 | 1285 | r.seed() | |
| 1286 | 1286 | r.feature("feat") | |
| 1287 | 1287 | mr := r.open("feat") | |
| 1288 | - | for i := range app.MaxCommentsPerMergeRequest { | |
| 1288 | + | for i := range usecase.MaxCommentsPerMergeRequest { | |
| 1289 | 1289 | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)}) | |
| 1290 | 1290 | require.NoError(t, err, "comment %d", i+1) | |
| 1291 | 1291 | } | |
| @@ -1293,7 +1293,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) { | |||
| 1293 | 1293 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 1294 | 1294 | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 1295 | 1295 | require.NoError(t, err) | |
| 1296 | - | assert.Len(t, d.Comments, app.MaxCommentsPerMergeRequest) | |
| 1296 | + | assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest) | |
| 1297 | 1297 | ||
| 1298 | 1298 | // Resolving still works, and another request has its own budget. | |
| 1299 | 1299 | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 1, true) | |
R internal/app/mergerequests_test.go → internal/usecase/mergerequests_test.go +4 -4 renamed, 96% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -11,10 +11,10 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/mock" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | - | "bnema.dev/ermite/internal/app" | |
| 15 | 14 | "bnema.dev/ermite/internal/domain" | |
| 16 | 15 | "bnema.dev/ermite/internal/ports" | |
| 17 | 16 | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | type mrUnit struct { | |
| @@ -22,7 +22,7 @@ type mrUnit struct { | |||
| 22 | 22 | store *mocks.MergeRequestStore | |
| 23 | 23 | repos *mocks.GitRepos | |
| 24 | 24 | git *mocks.MergeGit | |
| 25 | - | mrs *app.MergeRequests | |
| 25 | + | mrs *usecase.MergeRequests | |
| 26 | 26 | repo domain.Repo | |
| 27 | 27 | } | |
| 28 | 28 | ||
| @@ -39,7 +39,7 @@ func newMRUnit(t *testing.T) *mrUnit { | |||
| 39 | 39 | clock.EXPECT().Now().Return(now).Maybe() | |
| 40 | 40 | sched := mocks.NewMaintenanceScheduler(t) | |
| 41 | 41 | sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe() | |
| 42 | - | u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | |
| 42 | + | u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 43 | 43 | Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git, | |
| 44 | 44 | Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched, | |
| 45 | 45 | }) | |
R internal/app/paging.go → internal/usecase/paging.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/pushes.go → internal/usecase/pushes.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/pushes_test.go → internal/usecase/pushes_test.go +6 -6 renamed, 97% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -12,10 +12,10 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | - | "bnema.dev/ermite/internal/app" | |
| 16 | 15 | "bnema.dev/ermite/internal/domain" | |
| 17 | 16 | "bnema.dev/ermite/internal/ports" | |
| 18 | 17 | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | type pushFixture struct { | |
| @@ -25,7 +25,7 @@ type pushFixture struct { | |||
| 25 | 25 | clock *mocks.Clock | |
| 26 | 26 | tokens *mocks.TokenStore | |
| 27 | 27 | sched *mocks.MaintenanceScheduler | |
| 28 | - | pushes *app.Pushes | |
| 28 | + | pushes *usecase.Pushes | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | func newPushFixture(t *testing.T) *pushFixture { | |
| @@ -39,9 +39,9 @@ func newPushFixture(t *testing.T) *pushFixture { | |||
| 39 | 39 | sched: mocks.NewMaintenanceScheduler(t), | |
| 40 | 40 | } | |
| 41 | 41 | 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) | |
| 45 | 45 | return f | |
| 46 | 46 | } | |
| 47 | 47 | ||
R internal/app/repos.go → internal/usecase/repos.go +2 -2 renamed, 98% similar
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | // Package app holds the use cases of ermite. It depends on the domain and on | |
| 3 | + | // Package usecase holds the use cases of ermite. It depends on the domain and on | |
| 4 | 4 | // the ports only. | |
| 5 | - | package app | |
| 5 | + | package usecase | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
R internal/app/repos_integration_test.go → internal/usecase/repos_integration_test.go +3 -3 renamed, 95% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -18,9 +18,9 @@ import ( | |||
| 18 | 18 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 19 | 19 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 20 | 20 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 21 | - | "bnema.dev/ermite/internal/app" | |
| 22 | 21 | "bnema.dev/ermite/internal/domain" | |
| 23 | 22 | "bnema.dev/ermite/internal/ports" | |
| 23 | + | "bnema.dev/ermite/internal/usecase" | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | 26 | type fixedClock struct{} | |
| @@ -47,7 +47,7 @@ func TestCreateConcurrentSameNameRealAdapters(t *testing.T) { | |||
| 47 | 47 | require.NoError(t, err) | |
| 48 | 48 | t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) }) | |
| 49 | 49 | ||
| 50 | - | repos := app.NewRepos(store, git, eventbus.New(), fixedClock{}) | |
| 50 | + | repos := usecase.NewRepos(store, git, eventbus.New(), fixedClock{}) | |
| 51 | 51 | n := name(t, "tools") | |
| 52 | 52 | ||
| 53 | 53 | const workers = 8 | |
R internal/app/repos_test.go → internal/usecase/repos_test.go +4 -4 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -12,10 +12,10 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | - | "bnema.dev/ermite/internal/app" | |
| 16 | 15 | "bnema.dev/ermite/internal/domain" | |
| 17 | 16 | "bnema.dev/ermite/internal/ports" | |
| 18 | 17 | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) | |
| @@ -25,7 +25,7 @@ type fixture struct { | |||
| 25 | 25 | git *mocks.GitRepos | |
| 26 | 26 | clock *mocks.Clock | |
| 27 | 27 | bus *mocks.EventBus | |
| 28 | - | repos *app.Repos | |
| 28 | + | repos *usecase.Repos | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | func newFixture(t *testing.T) *fixture { | |
| @@ -37,7 +37,7 @@ func newFixture(t *testing.T) *fixture { | |||
| 37 | 37 | bus: mocks.NewEventBus(t), | |
| 38 | 38 | } | |
| 39 | 39 | f.clock.EXPECT().Now().Return(now).Maybe() | |
| 40 | - | f.repos = app.NewRepos(f.meta, f.git, f.bus, f.clock) | |
| 40 | + | f.repos = usecase.NewRepos(f.meta, f.git, f.bus, f.clock) | |
| 41 | 41 | return f | |
| 42 | 42 | } | |
| 43 | 43 | ||
R internal/app/repos_write.go → internal/usecase/repos_write.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/repos_write_test.go → internal/usecase/repos_write_test.go +7 -7 renamed, 96% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -13,9 +13,9 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | - | "bnema.dev/ermite/internal/app" | |
| 17 | 16 | "bnema.dev/ermite/internal/domain" | |
| 18 | 17 | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/usecase" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | func ptr[T any](v T) *T { return &v } | |
| @@ -128,7 +128,7 @@ func TestUpdate(t *testing.T) { | |||
| 128 | 128 | "empty": {}, | |
| 129 | 129 | "empty branch": {DefaultBranch: ptr("")}, | |
| 130 | 130 | "multi-line": {Description: ptr("a\nb")}, | |
| 131 | - | "too long": {Description: ptr(strings.Repeat("x", app.MaxDescriptionLen+1))}, | |
| 131 | + | "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))}, | |
| 132 | 132 | "bad utf-8": {Description: ptr("\xff")}, | |
| 133 | 133 | "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))}, | |
| 134 | 134 | } { | |
| @@ -232,7 +232,7 @@ func TestListPageSizes(t *testing.T) { | |||
| 232 | 232 | t.Parallel() | |
| 233 | 233 | ctx := context.Background() | |
| 234 | 234 | var all []domain.Repo | |
| 235 | - | for i := range app.MaxRepoPageSize + 10 { | |
| 235 | + | for i := range usecase.MaxRepoPageSize + 10 { | |
| 236 | 236 | all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic}) | |
| 237 | 237 | } | |
| 238 | 238 | for _, tt := range []struct { | |
| @@ -241,10 +241,10 @@ func TestListPageSizes(t *testing.T) { | |||
| 241 | 241 | want int | |
| 242 | 242 | more bool | |
| 243 | 243 | }{ | |
| 244 | - | {"default", 0, app.DefaultPageSize, true}, | |
| 244 | + | {"default", 0, usecase.DefaultPageSize, true}, | |
| 245 | 245 | {"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}, | |
| 248 | 248 | } { | |
| 249 | 249 | t.Run(tt.name, func(t *testing.T) { | |
| 250 | 250 | t.Parallel() | |
R internal/app/sessions.go → internal/usecase/sessions.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/sessions_test.go → internal/usecase/sessions_test.go +18 -18 renamed, 91% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -15,16 +15,16 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/mock" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | - | "bnema.dev/ermite/internal/app" | |
| 19 | 18 | "bnema.dev/ermite/internal/domain" | |
| 20 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | type sessionFixture struct { | |
| 24 | 24 | store *mocks.SessionStore | |
| 25 | 25 | clock *mocks.Clock | |
| 26 | 26 | at atomic.Pointer[time.Time] // what the clock reads; the test moves it | |
| 27 | - | sessions *app.Sessions | |
| 27 | + | sessions *usecase.Sessions | |
| 28 | 28 | key []byte | |
| 29 | 29 | } | |
| 30 | 30 | ||
| @@ -50,7 +50,7 @@ func newSessionFixture(t *testing.T) *sessionFixture { | |||
| 50 | 50 | f.store.EXPECT().Epoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Load(), nil }).Maybe() | |
| 51 | 51 | f.store.EXPECT().BumpEpoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Add(1), nil }).Maybe() | |
| 52 | 52 | ||
| 53 | - | s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example") | |
| 53 | + | s, err := usecase.NewSessions(context.Background(), f.store, f.clock, "https://forge.example") | |
| 54 | 54 | require.NoError(t, err) | |
| 55 | 55 | f.sessions = s | |
| 56 | 56 | return f | |
| @@ -120,7 +120,7 @@ func TestLoginLinkLifecycle(t *testing.T) { | |||
| 120 | 120 | ||
| 121 | 121 | link, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 122 | 122 | require.NoError(t, err) | |
| 123 | - | assert.Equal(t, f.clock.Now().Add(app.LoginLinkTTL), link.ExpiresAt) | |
| 123 | + | assert.Equal(t, f.clock.Now().Add(usecase.LoginLinkTTL), link.ExpiresAt) | |
| 124 | 124 | nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/") | |
| 125 | 125 | assert.Len(t, nonce, 43, "256 bits") | |
| 126 | 126 | ||
| @@ -130,14 +130,14 @@ func TestLoginLinkLifecycle(t *testing.T) { | |||
| 130 | 130 | token, exp, err := f.sessions.Login(ctx, nonce) | |
| 131 | 131 | require.NoError(t, err) | |
| 132 | 132 | assert.NotEmpty(t, token) | |
| 133 | - | assert.Equal(t, f.clock.Now().Add(app.SessionTTL), exp) | |
| 133 | + | assert.Equal(t, f.clock.Now().Add(usecase.SessionTTL), exp) | |
| 134 | 134 | assert.False(t, f.sessions.LoginLinkValid(nonce)) | |
| 135 | 135 | _, _, err = f.sessions.Login(ctx, nonce) | |
| 136 | 136 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay") | |
| 137 | 137 | ||
| 138 | 138 | // Expired. | |
| 139 | 139 | nonce = f.nonce(t) | |
| 140 | - | f.advance(app.LoginLinkTTL) | |
| 140 | + | f.advance(usecase.LoginLinkTTL) | |
| 141 | 141 | assert.False(t, f.sessions.LoginLinkValid(nonce)) | |
| 142 | 142 | _, _, err = f.sessions.Login(ctx, nonce) | |
| 143 | 143 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired") | |
| @@ -174,12 +174,12 @@ func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) { | |||
| 174 | 174 | p domain.Principal | |
| 175 | 175 | want time.Time | |
| 176 | 176 | }{ | |
| 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)}, | |
| 180 | 180 | "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)}, | |
| 183 | 183 | } { | |
| 184 | 184 | tok, exp, err := login(tc.p) | |
| 185 | 185 | require.NoError(t, err, name) | |
| @@ -238,13 +238,13 @@ func TestLoginLinksAreBounded(t *testing.T) { | |||
| 238 | 238 | t.Parallel() | |
| 239 | 239 | ctx := context.Background() | |
| 240 | 240 | f := newSessionFixture(t) | |
| 241 | - | for range app.MaxPendingLoginLinks { | |
| 241 | + | for range usecase.MaxPendingLoginLinks { | |
| 242 | 242 | f.nonce(t) | |
| 243 | 243 | } | |
| 244 | 244 | _, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 245 | 245 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 246 | 246 | ||
| 247 | - | f.advance(app.LoginLinkTTL) | |
| 247 | + | f.advance(usecase.LoginLinkTTL) | |
| 248 | 248 | _, err = f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 249 | 249 | assert.NoError(t, err, "expired links make room") | |
| 250 | 250 | } | |
| @@ -308,7 +308,7 @@ func TestSessionTokenVerification(t *testing.T) { | |||
| 308 | 308 | assert.NoError(t, err) | |
| 309 | 309 | ||
| 310 | 310 | // Expiry after the session lifetime; and the epoch ends every session. | |
| 311 | - | f.advance(app.SessionTTL) | |
| 311 | + | f.advance(usecase.SessionTTL) | |
| 312 | 312 | _, err = f.sessions.Verify(ctx, token) | |
| 313 | 313 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h") | |
| 314 | 314 | } | |
| @@ -339,7 +339,7 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) { | |||
| 339 | 339 | // signs as "root" although both hold every right. | |
| 340 | 340 | func TestAuthorOfOwnerSession(t *testing.T) { | |
| 341 | 341 | 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()}))) | |
| 345 | 345 | } | |
R internal/app/signing.go → internal/usecase/signing.go +1 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bnema.dev/ermite/internal/domain" | |
R internal/app/tokens.go → internal/usecase/tokens.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R internal/app/tokens_test.go → internal/usecase/tokens_test.go +6 -6 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -14,18 +14,18 @@ import ( | |||
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | - | "bnema.dev/ermite/internal/app" | |
| 18 | 17 | "bnema.dev/ermite/internal/domain" | |
| 19 | 18 | "bnema.dev/ermite/internal/ports" | |
| 20 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | type tokenFixture struct { | |
| 24 | 24 | store *mocks.TokenStore | |
| 25 | 25 | clock *mocks.Clock | |
| 26 | 26 | sessions *mocks.SessionStore | |
| 27 | - | tokens *app.Tokens | |
| 28 | - | auth *app.Auth | |
| 27 | + | tokens *usecase.Tokens | |
| 28 | + | auth *usecase.Auth | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | var rootSecret = "root-secret-0123456789abcdef0123456789" | |
| @@ -34,8 +34,8 @@ func newTokenFixture(t *testing.T) *tokenFixture { | |||
| 34 | 34 | t.Helper() | |
| 35 | 35 | f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t), sessions: mocks.NewSessionStore(t)} | |
| 36 | 36 | 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) | |
| 39 | 39 | return f | |
| 40 | 40 | } | |
| 41 | 41 | ||