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