test: replace hand-written fakes with mockery mocks and map hexcheck roles
- mockery covers ports, githttp and app consumer interfaces with all: true - app and githttp tests use generated mocks instead of 10 hand-written fakes - token labels and repo list CLI tests are table-driven - hexcheck maps clientlimit and readertest and enables recommended rules
- commit
- 4fbe895af54e605fa8f842ac9ba3315b70d444b0
- author
- brice · · 2026-10-11 18:43 UTC
- parent
- c33e43c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +1278 -372
M .hexcheck.yaml +34 -0
| @@ -16,6 +16,11 @@ components: | |||
| 16 | 16 | role: adapter | |
| 17 | 17 | paths: | |
| 18 | 18 | - internal/adapters/** | |
| 19 | + | clientlimit: | |
| 20 | + | # Per-client HTTP rate limiting: infrastructure shared by the HTTP adapters. | |
| 21 | + | role: adapter | |
| 22 | + | paths: | |
| 23 | + | - internal/clientlimit/** | |
| 19 | 24 | entrypoints: | |
| 20 | 25 | role: entrypoint | |
| 21 | 26 | paths: | |
| @@ -30,8 +35,37 @@ components: | |||
| 30 | 35 | - gen/** | |
| 31 | 36 | - '**/mocks/**' | |
| 32 | 37 | - '**/*_templ.go' | |
| 38 | + | readertest: | |
| 39 | + | # Test-only fixtures and conformance suite for ports.RepoReader. | |
| 40 | + | role: ignore | |
| 41 | + | paths: | |
| 42 | + | - internal/readertest/** | |
| 43 | + | rules: | |
| 44 | + | no-adapter-imports-in-core: error | |
| 45 | + | no-infra-imports-in-usecase: error | |
| 46 | + | no-framework-types-in-core: error | |
| 47 | + | no-infra-types-in-ports: error | |
| 48 | + | no-adapter-to-adapter-imports: warn | |
| 49 | + | suspicious-business-logic-in-adapter: warn | |
| 50 | + | no-local-fakes-for-ports: warn | |
| 51 | + | missing-generated-mock-for-port: warn | |
| 52 | + | prefer-generated-mocks: warn | |
| 33 | 53 | mocking: | |
| 34 | 54 | generatedMockPaths: | |
| 35 | 55 | - internal/ports/mocks/** | |
| 56 | + | - internal/app/mocks/** | |
| 57 | + | - internal/adapters/*/mocks/** | |
| 36 | 58 | generatedMockNamePatterns: | |
| 37 | 59 | - '{{Interface}}' | |
| 60 | + | allow: | |
| 61 | + | # clientlimit is a shared infrastructure library, not a sibling adapter: the | |
| 62 | + | # HTTP adapters that rate-limit clients import it by design. | |
| 63 | + | - rule: no-adapter-to-adapter-imports | |
| 64 | + | path: internal/adapters/githttp/** | |
| 65 | + | reason: rate-limits Git clients with internal/clientlimit | |
| 66 | + | - rule: no-adapter-to-adapter-imports | |
| 67 | + | path: internal/adapters/rpc/** | |
| 68 | + | reason: rate-limits API clients with internal/clientlimit | |
| 69 | + | - rule: no-adapter-to-adapter-imports | |
| 70 | + | path: internal/adapters/web/** | |
| 71 | + | reason: rate-limits login attempts with internal/clientlimit | |
| @@ -16,6 +16,11 @@ components: | |||||
| 16 | role: adapter | 16 | role: adapter | ||
| 17 | paths: | 17 | paths: | ||
| 18 | - internal/adapters/** | 18 | - internal/adapters/** | ||
| 19 | + | clientlimit: | |||
| 20 | + | # Per-client HTTP rate limiting: infrastructure shared by the HTTP adapters. | |||
| 21 | + | role: adapter | |||
| 22 | + | paths: | |||
| 23 | + | - internal/clientlimit/** | |||
| 19 | entrypoints: | 24 | entrypoints: | ||
| 20 | role: entrypoint | 25 | role: entrypoint | ||
| 21 | paths: | 26 | paths: | ||
| @@ -30,8 +35,37 @@ components: | |||||
| 30 | - gen/** | 35 | - gen/** | ||
| 31 | - '**/mocks/**' | 36 | - '**/mocks/**' | ||
| 32 | - '**/*_templ.go' | 37 | - '**/*_templ.go' | ||
| 38 | + | readertest: | |||
| 39 | + | # Test-only fixtures and conformance suite for ports.RepoReader. | |||
| 40 | + | role: ignore | |||
| 41 | + | paths: | |||
| 42 | + | - internal/readertest/** | |||
| 43 | + | rules: | |||
| 44 | + | no-adapter-imports-in-core: error | |||
| 45 | + | no-infra-imports-in-usecase: error | |||
| 46 | + | no-framework-types-in-core: error | |||
| 47 | + | no-infra-types-in-ports: error | |||
| 48 | + | no-adapter-to-adapter-imports: warn | |||
| 49 | + | suspicious-business-logic-in-adapter: warn | |||
| 50 | + | no-local-fakes-for-ports: warn | |||
| 51 | + | missing-generated-mock-for-port: warn | |||
| 52 | + | prefer-generated-mocks: warn | |||
| 33 | mocking: | 53 | mocking: | ||
| 34 | generatedMockPaths: | 54 | generatedMockPaths: | ||
| 35 | - internal/ports/mocks/** | 55 | - internal/ports/mocks/** | ||
| 56 | + | - internal/app/mocks/** | |||
| 57 | + | - internal/adapters/*/mocks/** | |||
| 36 | generatedMockNamePatterns: | 58 | generatedMockNamePatterns: | ||
| 37 | - '{{Interface}}' | 59 | - '{{Interface}}' | ||
| 60 | + | allow: | |||
| 61 | + | # clientlimit is a shared infrastructure library, not a sibling adapter: the | |||
| 62 | + | # HTTP adapters that rate-limit clients import it by design. | |||
| 63 | + | - rule: no-adapter-to-adapter-imports | |||
| 64 | + | path: internal/adapters/githttp/** | |||
| 65 | + | reason: rate-limits Git clients with internal/clientlimit | |||
| 66 | + | - rule: no-adapter-to-adapter-imports | |||
| 67 | + | path: internal/adapters/rpc/** | |||
| 68 | + | reason: rate-limits API clients with internal/clientlimit | |||
| 69 | + | - rule: no-adapter-to-adapter-imports | |||
| 70 | + | path: internal/adapters/web/** | |||
| 71 | + | reason: rate-limits login attempts with internal/clientlimit | |||
M .mockery.yml +14 -19
| @@ -1,22 +1,17 @@ | |||
| 1 | - | # Mocks of the ports. Regenerate with: go generate ./internal/ports | |
| 1 | + | # Mocks of the ports and of the consumer-side interfaces of the adapters and | |
| 2 | + | # the use cases. Regenerate with: go generate ./internal/ports | |
| 2 | 3 | template: testify | |
| 4 | + | _anchors: | |
| 5 | + | mocks: &mocks | |
| 6 | + | dir: "{{.InterfaceDir}}/mocks" | |
| 7 | + | pkgname: mocks | |
| 8 | + | filename: "{{.InterfaceName}}.go" | |
| 9 | + | structname: "{{.InterfaceName}}" | |
| 10 | + | all: true | |
| 3 | 11 | packages: | |
| 4 | 12 | bnema.dev/ermite/internal/ports: | |
| 5 | - | config: | |
| 6 | - | dir: "{{.InterfaceDir}}/mocks" | |
| 7 | - | pkgname: mocks | |
| 8 | - | filename: "{{.InterfaceName}}.go" | |
| 9 | - | structname: "{{.InterfaceName}}" | |
| 10 | - | interfaces: | |
| 11 | - | RepoMetaStore: | |
| 12 | - | GitRepos: | |
| 13 | - | Clock: | |
| 14 | - | TokenStore: | |
| 15 | - | SessionStore: | |
| 16 | - | GitBackend: | |
| 17 | - | EventBus: | |
| 18 | - | RepoReader: | |
| 19 | - | MergeRequestStore: | |
| 20 | - | MergeGit: | |
| 21 | - | CommitSigner: | |
| 22 | - | RepoFreshener: | |
| 13 | + | config: *mocks | |
| 14 | + | bnema.dev/ermite/internal/adapters/githttp: | |
| 15 | + | config: *mocks | |
| 16 | + | bnema.dev/ermite/internal/app: | |
| 17 | + | config: *mocks | |
| @@ -1,22 +1,17 @@ | |||||
| 1 | - | # Mocks of the ports. Regenerate with: go generate ./internal/ports | 1 | + | # Mocks of the ports and of the consumer-side interfaces of the adapters and |
| 2 | + | # the use cases. Regenerate with: go generate ./internal/ports | |||
| 2 | template: testify | 3 | template: testify | ||
| 4 | + | _anchors: | |||
| 5 | + | mocks: &mocks | |||
| 6 | + | dir: "{{.InterfaceDir}}/mocks" | |||
| 7 | + | pkgname: mocks | |||
| 8 | + | filename: "{{.InterfaceName}}.go" | |||
| 9 | + | structname: "{{.InterfaceName}}" | |||
| 10 | + | all: true | |||
| 3 | packages: | 11 | packages: | ||
| 4 | bnema.dev/ermite/internal/ports: | 12 | bnema.dev/ermite/internal/ports: | ||
| 5 | - | config: | 13 | + | config: *mocks |
| 6 | - | dir: "{{.InterfaceDir}}/mocks" | 14 | + | bnema.dev/ermite/internal/adapters/githttp: |
| 7 | - | pkgname: mocks | 15 | + | config: *mocks |
| 8 | - | filename: "{{.InterfaceName}}.go" | 16 | + | bnema.dev/ermite/internal/app: |
| 9 | - | structname: "{{.InterfaceName}}" | 17 | + | config: *mocks |
| 10 | - | interfaces: | |||
| 11 | - | RepoMetaStore: | |||
| 12 | - | GitRepos: | |||
| 13 | - | Clock: | |||
| 14 | - | TokenStore: | |||
| 15 | - | SessionStore: | |||
| 16 | - | GitBackend: | |||
| 17 | - | EventBus: | |||
| 18 | - | RepoReader: | |||
| 19 | - | MergeRequestStore: | |||
| 20 | - | MergeGit: | |||
| 21 | - | CommitSigner: | |||
| 22 | - | RepoFreshener: | |||
M cmd/ermite/cli_repo_test.go +49 -35
| @@ -34,41 +34,55 @@ func envFor(url string) map[string]string { | |||
| 34 | 34 | return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"} | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | func TestRepoListTable(t *testing.T) { | |
| 38 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 39 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | |
| 40 | - | }} | |
| 41 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t) | |
| 42 | - | assert.Equal(t, ""+ | |
| 43 | - | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+ | |
| 44 | - | "alpha public main 2026-03-04 05:06 The first one\n"+ | |
| 45 | - | "beta private trunk never \n", res.stdout) | |
| 46 | - | assert.Empty(t, res.stderr) | |
| 47 | - | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | |
| 48 | - | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func TestRepoListJSON(t *testing.T) { | |
| 52 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 53 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | |
| 54 | - | }} | |
| 55 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t) | |
| 56 | - | assert.JSONEq(t, `[ | |
| 57 | - | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | |
| 58 | - | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | |
| 59 | - | ]`, res.stdout) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func TestRepoListEmpty(t *testing.T) { | |
| 63 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 64 | - | return &ermite.ListReposResponse{}, nil | |
| 65 | - | }} | |
| 66 | - | url := f.serve(t) | |
| 67 | - | res := runCLI(t, envFor(url), "", "repo", "list").ok(t) | |
| 68 | - | assert.Empty(t, res.stdout) | |
| 69 | - | assert.Equal(t, "ermite: no repositories\n", res.stderr) | |
| 70 | - | res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t) | |
| 71 | - | assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null") | |
| 37 | + | func TestRepoList(t *testing.T) { | |
| 38 | + | both := []*ermite.Repo{repoAlpha, repoBeta} | |
| 39 | + | tests := []struct { | |
| 40 | + | name string | |
| 41 | + | repos []*ermite.Repo | |
| 42 | + | args []string | |
| 43 | + | wantStdout string | |
| 44 | + | wantJSON bool // compare stdout as JSON rather than byte for byte | |
| 45 | + | wantStderr string | |
| 46 | + | }{ | |
| 47 | + | { | |
| 48 | + | name: "table", repos: both, args: []string{"repo", "list"}, | |
| 49 | + | wantStdout: "" + | |
| 50 | + | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" + | |
| 51 | + | "alpha public main 2026-03-04 05:06 The first one\n" + | |
| 52 | + | "beta private trunk never \n", | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | + | wantStdout: `[ | |
| 57 | + | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | |
| 58 | + | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | |
| 59 | + | ]`, | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | name: "empty, table", args: []string{"repo", "list"}, | |
| 63 | + | wantStderr: "ermite: no repositories\n", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "empty, json is [] not null", args: []string{"repo", "list", "--json"}, | |
| 67 | + | wantStdout: "[]\n", | |
| 68 | + | }, | |
| 69 | + | } | |
| 70 | + | for _, tt := range tests { | |
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |
| 72 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 73 | + | return &ermite.ListReposResponse{Repos: tt.repos}, nil | |
| 74 | + | }} | |
| 75 | + | res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t) | |
| 76 | + | if tt.wantJSON { | |
| 77 | + | assert.JSONEq(t, tt.wantStdout, res.stdout) | |
| 78 | + | } else { | |
| 79 | + | assert.Equal(t, tt.wantStdout, res.stdout) | |
| 80 | + | } | |
| 81 | + | assert.Equal(t, tt.wantStderr, res.stderr) | |
| 82 | + | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | |
| 83 | + | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | |
| 84 | + | }) | |
| 85 | + | } | |
| 72 | 86 | } | |
| 73 | 87 | ||
| 74 | 88 | func TestRepoListFollowsPages(t *testing.T) { | |
| @@ -34,41 +34,55 @@ func envFor(url string) map[string]string { | |||||
| 34 | return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"} | 34 | return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"} | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | func TestRepoListTable(t *testing.T) { | 37 | + | func TestRepoList(t *testing.T) { |
| 38 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | 38 | + | both := []*ermite.Repo{repoAlpha, repoBeta} |
| 39 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | 39 | + | tests := []struct { |
| 40 | - | }} | 40 | + | name string |
| 41 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t) | 41 | + | repos []*ermite.Repo |
| 42 | - | assert.Equal(t, ""+ | 42 | + | args []string |
| 43 | - | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+ | 43 | + | wantStdout string |
| 44 | - | "alpha public main 2026-03-04 05:06 The first one\n"+ | 44 | + | wantJSON bool // compare stdout as JSON rather than byte for byte |
| 45 | - | "beta private trunk never \n", res.stdout) | 45 | + | wantStderr string |
| 46 | - | assert.Empty(t, res.stderr) | 46 | + | }{ |
| 47 | - | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | 47 | + | { |
| 48 | - | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | 48 | + | name: "table", repos: both, args: []string{"repo", "list"}, |
| 49 | - | } | 49 | + | wantStdout: "" + |
| 50 | - | 50 | + | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" + | |
| 51 | - | func TestRepoListJSON(t *testing.T) { | 51 | + | "alpha public main 2026-03-04 05:06 The first one\n" + |
| 52 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | 52 | + | "beta private trunk never \n", |
| 53 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | 53 | + | }, |
| 54 | - | }} | 54 | + | { |
| 55 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t) | 55 | + | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, |
| 56 | - | assert.JSONEq(t, `[ | 56 | + | wantStdout: `[ |
| 57 | - | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | 57 | + | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, |
| 58 | - | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | 58 | + | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} |
| 59 | - | ]`, res.stdout) | 59 | + | ]`, |
| 60 | - | } | 60 | + | }, |
| 61 | - | 61 | + | { | |
| 62 | - | func TestRepoListEmpty(t *testing.T) { | 62 | + | name: "empty, table", args: []string{"repo", "list"}, |
| 63 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | 63 | + | wantStderr: "ermite: no repositories\n", |
| 64 | - | return &ermite.ListReposResponse{}, nil | 64 | + | }, |
| 65 | - | }} | 65 | + | { |
| 66 | - | url := f.serve(t) | 66 | + | name: "empty, json is [] not null", args: []string{"repo", "list", "--json"}, |
| 67 | - | res := runCLI(t, envFor(url), "", "repo", "list").ok(t) | 67 | + | wantStdout: "[]\n", |
| 68 | - | assert.Empty(t, res.stdout) | 68 | + | }, |
| 69 | - | assert.Equal(t, "ermite: no repositories\n", res.stderr) | 69 | + | } |
| 70 | - | res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t) | 70 | + | for _, tt := range tests { |
| 71 | - | assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null") | 71 | + | t.Run(tt.name, func(t *testing.T) { |
| 72 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |||
| 73 | + | return &ermite.ListReposResponse{Repos: tt.repos}, nil | |||
| 74 | + | }} | |||
| 75 | + | res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t) | |||
| 76 | + | if tt.wantJSON { | |||
| 77 | + | assert.JSONEq(t, tt.wantStdout, res.stdout) | |||
| 78 | + | } else { | |||
| 79 | + | assert.Equal(t, tt.wantStdout, res.stdout) | |||
| 80 | + | } | |||
| 81 | + | assert.Equal(t, tt.wantStderr, res.stderr) | |||
| 82 | + | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | |||
| 83 | + | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 72 | } | 86 | } | ||
| 73 | 87 | ||||
| 74 | func TestRepoListFollowsPages(t *testing.T) { | 88 | func TestRepoListFollowsPages(t *testing.T) { | ||
M internal/adapters/githttp/handler_test.go +114 -85
| @@ -20,74 +20,13 @@ import ( | |||
| 20 | 20 | "github.com/stretchr/testify/require" | |
| 21 | 21 | ||
| 22 | 22 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 23 | + | ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks" | |
| 23 | 24 | "bnema.dev/ermite/internal/clientlimit" | |
| 24 | 25 | "bnema.dev/ermite/internal/domain" | |
| 25 | 26 | "bnema.dev/ermite/internal/ports" | |
| 26 | 27 | "bnema.dev/ermite/internal/ports/mocks" | |
| 27 | 28 | ) | |
| 28 | 29 | ||
| 29 | - | type fakeAuth struct { | |
| 30 | - | mu sync.Mutex | |
| 31 | - | seen []string | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) { | |
| 35 | - | f.mu.Lock() | |
| 36 | - | f.seen = append(f.seen, secret) | |
| 37 | - | f.mu.Unlock() | |
| 38 | - | switch secret { | |
| 39 | - | case "root-secret": | |
| 40 | - | return domain.Root(), nil | |
| 41 | - | case "reader-secret": | |
| 42 | - | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | |
| 43 | - | } | |
| 44 | - | return domain.Principal{}, domain.ErrUnauthenticated | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | type fakeRepos struct{} | |
| 48 | - | ||
| 49 | - | func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { | |
| 50 | - | switch n.String() { | |
| 51 | - | case "pub": | |
| 52 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | |
| 53 | - | case "priv": | |
| 54 | - | if p.Allows(domain.ScopeRepoRead, n) { | |
| 55 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | |
| 56 | - | } | |
| 57 | - | } | |
| 58 | - | return domain.Repo{}, domain.ErrNotFound | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | type fakePushes struct { | |
| 62 | - | mu sync.Mutex | |
| 63 | - | calls []bool // the create flag of each Open | |
| 64 | - | err error | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | |
| 68 | - | f.mu.Lock() | |
| 69 | - | defer f.mu.Unlock() | |
| 70 | - | f.calls = append(f.calls, create) | |
| 71 | - | if p.IsAnonymous() { | |
| 72 | - | return domain.ErrUnauthenticated | |
| 73 | - | } | |
| 74 | - | return f.err | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | type fakeTokens struct { | |
| 78 | - | mu sync.Mutex | |
| 79 | - | released int | |
| 80 | - | repo domain.RepoName | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) { | |
| 84 | - | f.mu.Lock() | |
| 85 | - | defer f.mu.Unlock() | |
| 86 | - | f.repo = r | |
| 87 | - | return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() } | |
| 88 | - | } | |
| 89 | - | func (f *fakeTokens) Socket() string { return "/run/hook.sock" } | |
| 90 | - | ||
| 91 | 30 | // cgiScript is a stand-in for git-http-backend: it answers with the | |
| 92 | 31 | // environment and the body it received. Like Git, it reads the whole request | |
| 93 | 32 | // before it answers (net/http cannot read a request body once the response | |
| @@ -103,20 +42,115 @@ func cgiScript(t *testing.T) string { | |||
| 103 | 42 | type rig struct { | |
| 104 | 43 | h *githttp.Handler | |
| 105 | 44 | srv *httptest.Server | |
| 106 | - | pushes *fakePushes | |
| 45 | + | pushes *ghmocks.Pushes | |
| 107 | 46 | backend *mocks.GitBackend | |
| 108 | - | hooks *fakeTokens | |
| 109 | - | auth *fakeAuth | |
| 47 | + | hooks *ghmocks.HookTokens | |
| 48 | + | auth *ghmocks.Authenticator | |
| 110 | 49 | ||
| 111 | 50 | pushFlags []bool | |
| 112 | 51 | backendErr error | |
| 52 | + | ||
| 53 | + | mu sync.Mutex // guards what the mocks record while the server runs | |
| 54 | + | pushErr error // what Pushes.Open answers a named caller | |
| 55 | + | pushCalls []bool // the create flag of each Pushes.Open | |
| 56 | + | authCalls int // Authenticator.Authenticate calls | |
| 57 | + | released int // hook tokens released | |
| 58 | + | hookRepo domain.RepoName | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (r *rig) setPushErr(err error) { | |
| 62 | + | r.mu.Lock() | |
| 63 | + | defer r.mu.Unlock() | |
| 64 | + | r.pushErr = err | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (r *rig) takePushCalls() []bool { | |
| 68 | + | r.mu.Lock() | |
| 69 | + | defer r.mu.Unlock() | |
| 70 | + | c := r.pushCalls | |
| 71 | + | r.pushCalls = nil | |
| 72 | + | return c | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (r *rig) authenticateCalls() int { | |
| 76 | + | r.mu.Lock() | |
| 77 | + | defer r.mu.Unlock() | |
| 78 | + | return r.authCalls | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (r *rig) releasedTokens() int { | |
| 82 | + | r.mu.Lock() | |
| 83 | + | defer r.mu.Unlock() | |
| 84 | + | return r.released | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (r *rig) hookedRepo() domain.RepoName { | |
| 88 | + | r.mu.Lock() | |
| 89 | + | defer r.mu.Unlock() | |
| 90 | + | return r.hookRepo | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // stubUseCases answers like the use cases behind the handler: a root and a | |
| 94 | + | // reader token, a public repository "pub" and a private one "priv". | |
| 95 | + | func (r *rig) stubUseCases(repos *ghmocks.Repos) { | |
| 96 | + | r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn( | |
| 97 | + | func(_ context.Context, secret string) (domain.Principal, error) { | |
| 98 | + | r.mu.Lock() | |
| 99 | + | r.authCalls++ | |
| 100 | + | r.mu.Unlock() | |
| 101 | + | switch secret { | |
| 102 | + | case "root-secret": | |
| 103 | + | return domain.Root(), nil | |
| 104 | + | case "reader-secret": | |
| 105 | + | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | |
| 106 | + | } | |
| 107 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 108 | + | }).Maybe() | |
| 109 | + | ||
| 110 | + | repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 111 | + | func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { | |
| 112 | + | switch n.String() { | |
| 113 | + | case "pub": | |
| 114 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | |
| 115 | + | case "priv": | |
| 116 | + | if p.Allows(domain.ScopeRepoRead, n) { | |
| 117 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | return domain.Repo{}, domain.ErrNotFound | |
| 121 | + | }).Maybe() | |
| 122 | + | ||
| 123 | + | r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 124 | + | func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | |
| 125 | + | r.mu.Lock() | |
| 126 | + | defer r.mu.Unlock() | |
| 127 | + | r.pushCalls = append(r.pushCalls, create) | |
| 128 | + | if p.IsAnonymous() { | |
| 129 | + | return domain.ErrUnauthenticated | |
| 130 | + | } | |
| 131 | + | return r.pushErr | |
| 132 | + | }).Maybe() | |
| 133 | + | ||
| 134 | + | r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 135 | + | func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) { | |
| 136 | + | r.mu.Lock() | |
| 137 | + | defer r.mu.Unlock() | |
| 138 | + | r.hookRepo = n | |
| 139 | + | return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() } | |
| 140 | + | }).Maybe() | |
| 141 | + | r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe() | |
| 113 | 142 | } | |
| 114 | 143 | ||
| 115 | 144 | func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |
| 116 | 145 | t.Helper() | |
| 117 | - | r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}} | |
| 146 | + | r := &rig{ | |
| 147 | + | pushes: ghmocks.NewPushes(t), backend: mocks.NewGitBackend(t), | |
| 148 | + | hooks: ghmocks.NewHookTokens(t), auth: ghmocks.NewAuthenticator(t), | |
| 149 | + | } | |
| 150 | + | repos := ghmocks.NewRepos(t) | |
| 151 | + | r.stubUseCases(repos) | |
| 118 | 152 | cfg := githttp.Config{ | |
| 119 | - | Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | |
| 153 | + | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | |
| 120 | 154 | } | |
| 121 | 155 | script := cgiScript(t) | |
| 122 | 156 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | |
| @@ -222,25 +256,24 @@ func TestHandlerPushAuthorizationAnswers(t *testing.T) { | |||
| 222 | 256 | return code | |
| 223 | 257 | } | |
| 224 | 258 | assert.Equal(t, 200, get()) | |
| 225 | - | r.pushes.err = domain.ErrForbidden | |
| 259 | + | r.setPushErr(domain.ErrForbidden) | |
| 226 | 260 | assert.Equal(t, 403, get()) | |
| 227 | - | r.pushes.err = domain.ErrNotFound | |
| 261 | + | r.setPushErr(domain.ErrNotFound) | |
| 228 | 262 | assert.Equal(t, 404, get()) | |
| 229 | - | r.pushes.err = domain.ErrUnauthenticated | |
| 263 | + | r.setPushErr(domain.ErrUnauthenticated) | |
| 230 | 264 | assert.Equal(t, 401, get()) | |
| 231 | - | r.pushes.err = errBoom{} | |
| 265 | + | r.setPushErr(errBoom{}) | |
| 232 | 266 | assert.Equal(t, 500, get()) | |
| 233 | 267 | ||
| 234 | 268 | // Only the first request of a push may create. | |
| 235 | - | r.pushes.err = nil | |
| 236 | - | r.pushes.calls = nil | |
| 269 | + | r.setPushErr(nil) | |
| 270 | + | r.takePushCalls() | |
| 237 | 271 | r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | |
| 238 | 272 | r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"}) | |
| 239 | - | assert.Equal(t, []bool{true, false}, r.pushes.calls) | |
| 273 | + | assert.Equal(t, []bool{true, false}, r.takePushCalls()) | |
| 240 | 274 | // Reads never reach the push use case. | |
| 241 | - | r.pushes.calls = nil | |
| 242 | 275 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| 243 | - | assert.Empty(t, r.pushes.calls) | |
| 276 | + | assert.Empty(t, r.takePushCalls()) | |
| 244 | 277 | } | |
| 245 | 278 | ||
| 246 | 279 | type errBoom struct{} | |
| @@ -295,12 +328,12 @@ func TestHandlerBackendEnvironment(t *testing.T) { | |||
| 295 | 328 | assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks") | |
| 296 | 329 | assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN") | |
| 297 | 330 | assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR") | |
| 298 | - | assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends") | |
| 299 | - | assert.Equal(t, "pub", r.hooks.repo.String()) | |
| 331 | + | assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends") | |
| 332 | + | assert.Equal(t, "pub", r.hookedRepo().String()) | |
| 300 | 333 | ||
| 301 | 334 | // The info/refs advertisement of a push does not register a hook token. | |
| 302 | 335 | r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | |
| 303 | - | assert.Equal(t, 1, r.hooks.released) | |
| 336 | + | assert.Equal(t, 1, r.releasedTokens()) | |
| 304 | 337 | } | |
| 305 | 338 | ||
| 306 | 339 | func TestHandlerNormalizesRepositoryPath(t *testing.T) { | |
| @@ -420,13 +453,9 @@ func TestHandlerLimiter(t *testing.T) { | |||
| 420 | 453 | assert.Equal(t, 429, code) | |
| 421 | 454 | assert.NotEmpty(t, hdr.Get("Retry-After")) | |
| 422 | 455 | // A blocked request does not even reach the authenticator. | |
| 423 | - | r.auth.mu.Lock() | |
| 424 | - | n := len(r.auth.seen) | |
| 425 | - | r.auth.mu.Unlock() | |
| 456 | + | n := r.authenticateCalls() | |
| 426 | 457 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil) | |
| 427 | - | r.auth.mu.Lock() | |
| 428 | - | assert.Equal(t, n, len(r.auth.seen)) | |
| 429 | - | r.auth.mu.Unlock() | |
| 458 | + | assert.Equal(t, n, r.authenticateCalls()) | |
| 430 | 459 | } | |
| 431 | 460 | ||
| 432 | 461 | func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |
| @@ -20,74 +20,13 @@ import ( | |||||
| 20 | "github.com/stretchr/testify/require" | 20 | "github.com/stretchr/testify/require" | ||
| 21 | 21 | ||||
| 22 | "bnema.dev/ermite/internal/adapters/githttp" | 22 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 23 | + | ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks" | |||
| 23 | "bnema.dev/ermite/internal/clientlimit" | 24 | "bnema.dev/ermite/internal/clientlimit" | ||
| 24 | "bnema.dev/ermite/internal/domain" | 25 | "bnema.dev/ermite/internal/domain" | ||
| 25 | "bnema.dev/ermite/internal/ports" | 26 | "bnema.dev/ermite/internal/ports" | ||
| 26 | "bnema.dev/ermite/internal/ports/mocks" | 27 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 27 | ) | 28 | ) | ||
| 28 | 29 | ||||
| 29 | - | type fakeAuth struct { | |||
| 30 | - | mu sync.Mutex | |||
| 31 | - | seen []string | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) { | |||
| 35 | - | f.mu.Lock() | |||
| 36 | - | f.seen = append(f.seen, secret) | |||
| 37 | - | f.mu.Unlock() | |||
| 38 | - | switch secret { | |||
| 39 | - | case "root-secret": | |||
| 40 | - | return domain.Root(), nil | |||
| 41 | - | case "reader-secret": | |||
| 42 | - | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | |||
| 43 | - | } | |||
| 44 | - | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | type fakeRepos struct{} | |||
| 48 | - | ||||
| 49 | - | func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { | |||
| 50 | - | switch n.String() { | |||
| 51 | - | case "pub": | |||
| 52 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | |||
| 53 | - | case "priv": | |||
| 54 | - | if p.Allows(domain.ScopeRepoRead, n) { | |||
| 55 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | |||
| 56 | - | } | |||
| 57 | - | } | |||
| 58 | - | return domain.Repo{}, domain.ErrNotFound | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | type fakePushes struct { | |||
| 62 | - | mu sync.Mutex | |||
| 63 | - | calls []bool // the create flag of each Open | |||
| 64 | - | err error | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | |||
| 68 | - | f.mu.Lock() | |||
| 69 | - | defer f.mu.Unlock() | |||
| 70 | - | f.calls = append(f.calls, create) | |||
| 71 | - | if p.IsAnonymous() { | |||
| 72 | - | return domain.ErrUnauthenticated | |||
| 73 | - | } | |||
| 74 | - | return f.err | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | type fakeTokens struct { | |||
| 78 | - | mu sync.Mutex | |||
| 79 | - | released int | |||
| 80 | - | repo domain.RepoName | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) { | |||
| 84 | - | f.mu.Lock() | |||
| 85 | - | defer f.mu.Unlock() | |||
| 86 | - | f.repo = r | |||
| 87 | - | return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() } | |||
| 88 | - | } | |||
| 89 | - | func (f *fakeTokens) Socket() string { return "/run/hook.sock" } | |||
| 90 | - | ||||
| 91 | // cgiScript is a stand-in for git-http-backend: it answers with the | 30 | // cgiScript is a stand-in for git-http-backend: it answers with the | ||
| 92 | // environment and the body it received. Like Git, it reads the whole request | 31 | // environment and the body it received. Like Git, it reads the whole request | ||
| 93 | // before it answers (net/http cannot read a request body once the response | 32 | // before it answers (net/http cannot read a request body once the response | ||
| @@ -103,20 +42,115 @@ func cgiScript(t *testing.T) string { | |||||
| 103 | type rig struct { | 42 | type rig struct { | ||
| 104 | h *githttp.Handler | 43 | h *githttp.Handler | ||
| 105 | srv *httptest.Server | 44 | srv *httptest.Server | ||
| 106 | - | pushes *fakePushes | 45 | + | pushes *ghmocks.Pushes |
| 107 | backend *mocks.GitBackend | 46 | backend *mocks.GitBackend | ||
| 108 | - | hooks *fakeTokens | 47 | + | hooks *ghmocks.HookTokens |
| 109 | - | auth *fakeAuth | 48 | + | auth *ghmocks.Authenticator |
| 110 | 49 | ||||
| 111 | pushFlags []bool | 50 | pushFlags []bool | ||
| 112 | backendErr error | 51 | backendErr error | ||
| 52 | + | ||||
| 53 | + | mu sync.Mutex // guards what the mocks record while the server runs | |||
| 54 | + | pushErr error // what Pushes.Open answers a named caller | |||
| 55 | + | pushCalls []bool // the create flag of each Pushes.Open | |||
| 56 | + | authCalls int // Authenticator.Authenticate calls | |||
| 57 | + | released int // hook tokens released | |||
| 58 | + | hookRepo domain.RepoName | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (r *rig) setPushErr(err error) { | |||
| 62 | + | r.mu.Lock() | |||
| 63 | + | defer r.mu.Unlock() | |||
| 64 | + | r.pushErr = err | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (r *rig) takePushCalls() []bool { | |||
| 68 | + | r.mu.Lock() | |||
| 69 | + | defer r.mu.Unlock() | |||
| 70 | + | c := r.pushCalls | |||
| 71 | + | r.pushCalls = nil | |||
| 72 | + | return c | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (r *rig) authenticateCalls() int { | |||
| 76 | + | r.mu.Lock() | |||
| 77 | + | defer r.mu.Unlock() | |||
| 78 | + | return r.authCalls | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (r *rig) releasedTokens() int { | |||
| 82 | + | r.mu.Lock() | |||
| 83 | + | defer r.mu.Unlock() | |||
| 84 | + | return r.released | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (r *rig) hookedRepo() domain.RepoName { | |||
| 88 | + | r.mu.Lock() | |||
| 89 | + | defer r.mu.Unlock() | |||
| 90 | + | return r.hookRepo | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // stubUseCases answers like the use cases behind the handler: a root and a | |||
| 94 | + | // reader token, a public repository "pub" and a private one "priv". | |||
| 95 | + | func (r *rig) stubUseCases(repos *ghmocks.Repos) { | |||
| 96 | + | r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn( | |||
| 97 | + | func(_ context.Context, secret string) (domain.Principal, error) { | |||
| 98 | + | r.mu.Lock() | |||
| 99 | + | r.authCalls++ | |||
| 100 | + | r.mu.Unlock() | |||
| 101 | + | switch secret { | |||
| 102 | + | case "root-secret": | |||
| 103 | + | return domain.Root(), nil | |||
| 104 | + | case "reader-secret": | |||
| 105 | + | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | |||
| 106 | + | } | |||
| 107 | + | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 108 | + | }).Maybe() | |||
| 109 | + | ||||
| 110 | + | repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 111 | + | func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { | |||
| 112 | + | switch n.String() { | |||
| 113 | + | case "pub": | |||
| 114 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | |||
| 115 | + | case "priv": | |||
| 116 | + | if p.Allows(domain.ScopeRepoRead, n) { | |||
| 117 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 121 | + | }).Maybe() | |||
| 122 | + | ||||
| 123 | + | r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 124 | + | func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | |||
| 125 | + | r.mu.Lock() | |||
| 126 | + | defer r.mu.Unlock() | |||
| 127 | + | r.pushCalls = append(r.pushCalls, create) | |||
| 128 | + | if p.IsAnonymous() { | |||
| 129 | + | return domain.ErrUnauthenticated | |||
| 130 | + | } | |||
| 131 | + | return r.pushErr | |||
| 132 | + | }).Maybe() | |||
| 133 | + | ||||
| 134 | + | r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 135 | + | func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) { | |||
| 136 | + | r.mu.Lock() | |||
| 137 | + | defer r.mu.Unlock() | |||
| 138 | + | r.hookRepo = n | |||
| 139 | + | return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() } | |||
| 140 | + | }).Maybe() | |||
| 141 | + | r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe() | |||
| 113 | } | 142 | } | ||
| 114 | 143 | ||||
| 115 | func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | 144 | func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | ||
| 116 | t.Helper() | 145 | t.Helper() | ||
| 117 | - | r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}} | 146 | + | r := &rig{ |
| 147 | + | pushes: ghmocks.NewPushes(t), backend: mocks.NewGitBackend(t), | |||
| 148 | + | hooks: ghmocks.NewHookTokens(t), auth: ghmocks.NewAuthenticator(t), | |||
| 149 | + | } | |||
| 150 | + | repos := ghmocks.NewRepos(t) | |||
| 151 | + | r.stubUseCases(repos) | |||
| 118 | cfg := githttp.Config{ | 152 | cfg := githttp.Config{ | ||
| 119 | - | Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | 153 | + | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, |
| 120 | } | 154 | } | ||
| 121 | script := cgiScript(t) | 155 | script := cgiScript(t) | ||
| 122 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | 156 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | ||
| @@ -222,25 +256,24 @@ func TestHandlerPushAuthorizationAnswers(t *testing.T) { | |||||
| 222 | return code | 256 | return code | ||
| 223 | } | 257 | } | ||
| 224 | assert.Equal(t, 200, get()) | 258 | assert.Equal(t, 200, get()) | ||
| 225 | - | r.pushes.err = domain.ErrForbidden | 259 | + | r.setPushErr(domain.ErrForbidden) |
| 226 | assert.Equal(t, 403, get()) | 260 | assert.Equal(t, 403, get()) | ||
| 227 | - | r.pushes.err = domain.ErrNotFound | 261 | + | r.setPushErr(domain.ErrNotFound) |
| 228 | assert.Equal(t, 404, get()) | 262 | assert.Equal(t, 404, get()) | ||
| 229 | - | r.pushes.err = domain.ErrUnauthenticated | 263 | + | r.setPushErr(domain.ErrUnauthenticated) |
| 230 | assert.Equal(t, 401, get()) | 264 | assert.Equal(t, 401, get()) | ||
| 231 | - | r.pushes.err = errBoom{} | 265 | + | r.setPushErr(errBoom{}) |
| 232 | assert.Equal(t, 500, get()) | 266 | assert.Equal(t, 500, get()) | ||
| 233 | 267 | ||||
| 234 | // Only the first request of a push may create. | 268 | // Only the first request of a push may create. | ||
| 235 | - | r.pushes.err = nil | 269 | + | r.setPushErr(nil) |
| 236 | - | r.pushes.calls = nil | 270 | + | r.takePushCalls() |
| 237 | r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | 271 | r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | ||
| 238 | r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"}) | 272 | r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"}) | ||
| 239 | - | assert.Equal(t, []bool{true, false}, r.pushes.calls) | 273 | + | assert.Equal(t, []bool{true, false}, r.takePushCalls()) |
| 240 | // Reads never reach the push use case. | 274 | // Reads never reach the push use case. | ||
| 241 | - | r.pushes.calls = nil | |||
| 242 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | 275 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | ||
| 243 | - | assert.Empty(t, r.pushes.calls) | 276 | + | assert.Empty(t, r.takePushCalls()) |
| 244 | } | 277 | } | ||
| 245 | 278 | ||||
| 246 | type errBoom struct{} | 279 | type errBoom struct{} | ||
| @@ -295,12 +328,12 @@ func TestHandlerBackendEnvironment(t *testing.T) { | |||||
| 295 | assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks") | 328 | assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks") | ||
| 296 | assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN") | 329 | assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN") | ||
| 297 | assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR") | 330 | assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR") | ||
| 298 | - | assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends") | 331 | + | assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends") |
| 299 | - | assert.Equal(t, "pub", r.hooks.repo.String()) | 332 | + | assert.Equal(t, "pub", r.hookedRepo().String()) |
| 300 | 333 | ||||
| 301 | // The info/refs advertisement of a push does not register a hook token. | 334 | // The info/refs advertisement of a push does not register a hook token. | ||
| 302 | r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | 335 | r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | ||
| 303 | - | assert.Equal(t, 1, r.hooks.released) | 336 | + | assert.Equal(t, 1, r.releasedTokens()) |
| 304 | } | 337 | } | ||
| 305 | 338 | ||||
| 306 | func TestHandlerNormalizesRepositoryPath(t *testing.T) { | 339 | func TestHandlerNormalizesRepositoryPath(t *testing.T) { | ||
| @@ -420,13 +453,9 @@ func TestHandlerLimiter(t *testing.T) { | |||||
| 420 | assert.Equal(t, 429, code) | 453 | assert.Equal(t, 429, code) | ||
| 421 | assert.NotEmpty(t, hdr.Get("Retry-After")) | 454 | assert.NotEmpty(t, hdr.Get("Retry-After")) | ||
| 422 | // A blocked request does not even reach the authenticator. | 455 | // A blocked request does not even reach the authenticator. | ||
| 423 | - | r.auth.mu.Lock() | 456 | + | n := r.authenticateCalls() |
| 424 | - | n := len(r.auth.seen) | |||
| 425 | - | r.auth.mu.Unlock() | |||
| 426 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil) | 457 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil) | ||
| 427 | - | r.auth.mu.Lock() | 458 | + | assert.Equal(t, n, r.authenticateCalls()) |
| 428 | - | assert.Equal(t, n, len(r.auth.seen)) | |||
| 429 | - | r.auth.mu.Unlock() | |||
| 430 | } | 459 | } | ||
| 431 | 460 | ||||
| 432 | func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | 461 | func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | ||
M internal/adapters/githttp/hooks_test.go +49 -91
| @@ -18,9 +18,11 @@ import ( | |||
| 18 | 18 | "time" | |
| 19 | 19 | ||
| 20 | 20 | "github.com/stretchr/testify/assert" | |
| 21 | + | "github.com/stretchr/testify/mock" | |
| 21 | 22 | "github.com/stretchr/testify/require" | |
| 22 | 23 | ||
| 23 | 24 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 25 | + | ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks" | |
| 24 | 26 | "bnema.dev/ermite/internal/domain" | |
| 25 | 27 | ) | |
| 26 | 28 | ||
| @@ -29,46 +31,6 @@ const ( | |||
| 29 | 31 | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | |
| 30 | 32 | ) | |
| 31 | 33 | ||
| 32 | - | type call struct { | |
| 33 | - | hook string | |
| 34 | - | p domain.Principal | |
| 35 | - | repo domain.RepoName | |
| 36 | - | updates []domain.RefUpdate | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | type fakeHooks struct { | |
| 40 | - | mu sync.Mutex | |
| 41 | - | calls []call | |
| 42 | - | checkErr error | |
| 43 | - | recErr error | |
| 44 | - | // onRecord, when set, runs inside Record with its context. | |
| 45 | - | onRecord func(ctx context.Context) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |
| 49 | - | f.mu.Lock() | |
| 50 | - | defer f.mu.Unlock() | |
| 51 | - | f.calls = append(f.calls, call{"check", p, n, u}) | |
| 52 | - | return f.checkErr | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |
| 56 | - | f.mu.Lock() | |
| 57 | - | f.calls = append(f.calls, call{"record", p, n, u}) | |
| 58 | - | err, on := f.recErr, f.onRecord | |
| 59 | - | f.mu.Unlock() | |
| 60 | - | if on != nil { | |
| 61 | - | on(ctx) | |
| 62 | - | } | |
| 63 | - | return err | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func (f *fakeHooks) last() call { | |
| 67 | - | f.mu.Lock() | |
| 68 | - | defer f.mu.Unlock() | |
| 69 | - | return f.calls[len(f.calls)-1] | |
| 70 | - | } | |
| 71 | - | ||
| 72 | 34 | func repoName(t *testing.T, s string) domain.RepoName { | |
| 73 | 35 | t.Helper() | |
| 74 | 36 | n, err := domain.ParseRepoName(s) | |
| @@ -117,7 +79,7 @@ func raw(t *testing.T, sock, line string) githttp.HookReply { | |||
| 117 | 79 | } | |
| 118 | 80 | ||
| 119 | 81 | func TestHookSocketMode(t *testing.T) { | |
| 120 | - | _, sock := startHooks(t, &fakeHooks{}) | |
| 82 | + | _, sock := startHooks(t, ghmocks.NewPushHooks(t)) | |
| 121 | 83 | fi, err := os.Stat(sock) | |
| 122 | 84 | require.NoError(t, err) | |
| 123 | 85 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | |
| @@ -130,21 +92,21 @@ func TestHookServerReplacesStaleSocketFile(t *testing.T) { | |||
| 130 | 92 | defer func() { _ = os.RemoveAll(dir) }() | |
| 131 | 93 | sock := filepath.Join(dir, "hook.sock") | |
| 132 | 94 | require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600)) | |
| 133 | - | s := githttp.NewHookServer(sock, &fakeHooks{}) | |
| 95 | + | s := githttp.NewHookServer(sock, ghmocks.NewPushHooks(t)) | |
| 134 | 96 | require.NoError(t, s.Listen(context.Background())) | |
| 135 | 97 | s.Close(context.Background()) | |
| 136 | 98 | } | |
| 137 | 99 | ||
| 138 | 100 | func TestHookServerRejectsTooLongSocketPath(t *testing.T) { | |
| 139 | - | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{}) | |
| 101 | + | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", ghmocks.NewPushHooks(t)) | |
| 140 | 102 | err := s.Listen(context.Background()) | |
| 141 | 103 | require.Error(t, err) | |
| 142 | 104 | assert.Contains(t, err.Error(), "too long") | |
| 143 | 105 | } | |
| 144 | 106 | ||
| 145 | 107 | func TestHookProtocol(t *testing.T) { | |
| 146 | - | fh := &fakeHooks{} | |
| 147 | - | srv, sock := startHooks(t, fh) | |
| 108 | + | hooks := ghmocks.NewPushHooks(t) | |
| 109 | + | srv, sock := startHooks(t, hooks) | |
| 148 | 110 | p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | |
| 149 | 111 | tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p) | |
| 150 | 112 | tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root()) | |
| @@ -154,46 +116,42 @@ func TestHookProtocol(t *testing.T) { | |||
| 154 | 116 | upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]` | |
| 155 | 117 | ||
| 156 | 118 | t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) { | |
| 119 | + | updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}} | |
| 120 | + | hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }), | |
| 121 | + | repoName(t, "alpha"), updates).Return(nil).Once() | |
| 157 | 122 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 158 | 123 | assert.True(t, r.OK) | |
| 159 | - | c := fh.last() | |
| 160 | - | assert.Equal(t, "check", c.hook) | |
| 161 | - | assert.Equal(t, repoName(t, "alpha"), c.repo) | |
| 162 | - | assert.Equal(t, "tok1", c.p.TokenID()) | |
| 163 | - | assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates) | |
| 164 | 124 | ||
| 125 | + | hooks.EXPECT().Record(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.IsRoot() }), | |
| 126 | + | repoName(t, "beta"), updates).Return(nil).Once() | |
| 165 | 127 | r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`) | |
| 166 | 128 | assert.True(t, r.OK) | |
| 167 | - | c = fh.last() | |
| 168 | - | assert.Equal(t, "record", c.hook) | |
| 169 | - | assert.Equal(t, repoName(t, "beta"), c.repo) | |
| 170 | - | assert.True(t, c.p.IsRoot()) | |
| 171 | 129 | }) | |
| 172 | 130 | ||
| 173 | 131 | t.Run("a repository sent by the client is ignored", func(t *testing.T) { | |
| 174 | - | raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) | |
| 175 | - | assert.Equal(t, repoName(t, "alpha"), fh.last().repo) | |
| 132 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once() | |
| 133 | + | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) | |
| 134 | + | assert.True(t, r.OK) | |
| 176 | 135 | }) | |
| 177 | 136 | ||
| 178 | 137 | t.Run("refusal carries the message", func(t *testing.T) { | |
| 179 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch")) | |
| 180 | - | defer func() { fh.checkErr = nil }() | |
| 138 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 139 | + | Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once() | |
| 181 | 140 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 182 | 141 | assert.False(t, r.OK) | |
| 183 | 142 | assert.Contains(t, r.Message, "rejected") | |
| 184 | 143 | }) | |
| 185 | 144 | ||
| 186 | 145 | t.Run("an internal error fails closed without leaking details", func(t *testing.T) { | |
| 187 | - | fh.checkErr = errors.New("disk /secret/path exploded") | |
| 188 | - | defer func() { fh.checkErr = nil }() | |
| 146 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 147 | + | Return(errors.New("disk /secret/path exploded")).Once() | |
| 189 | 148 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 190 | 149 | assert.False(t, r.OK) | |
| 191 | 150 | assert.NotContains(t, r.Message, "/secret/path") | |
| 192 | 151 | }) | |
| 193 | 152 | ||
| 194 | 153 | t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) { | |
| 195 | - | fh.recErr = errors.New("boom") | |
| 196 | - | defer func() { fh.recErr = nil }() | |
| 154 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("boom")).Once() | |
| 197 | 155 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`) | |
| 198 | 156 | assert.True(t, r.OK) | |
| 199 | 157 | assert.NotEmpty(t, r.Message) | |
| @@ -205,18 +163,16 @@ func TestHookProtocol(t *testing.T) { | |||
| 205 | 163 | assert.False(t, r.OK, tok) | |
| 206 | 164 | } | |
| 207 | 165 | releaseA() | |
| 208 | - | before := len(fh.calls) | |
| 209 | 166 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`) | |
| 210 | 167 | assert.False(t, r.OK, "released token") | |
| 211 | 168 | assert.Contains(t, r.Message, "token") | |
| 212 | - | assert.Len(t, fh.calls, before, "no use case ran") | |
| 169 | + | // The hooks mock expects no call: no use case ran. | |
| 213 | 170 | releaseB() | |
| 214 | 171 | }) | |
| 215 | 172 | ||
| 216 | 173 | t.Run("malformed requests are refused", func(t *testing.T) { | |
| 217 | 174 | tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root()) | |
| 218 | 175 | defer rel() | |
| 219 | - | before := len(fh.calls) | |
| 220 | 176 | for _, line := range []string{ | |
| 221 | 177 | `not json`, | |
| 222 | 178 | `{}`, | |
| @@ -227,7 +183,7 @@ func TestHookProtocol(t *testing.T) { | |||
| 227 | 183 | r := raw(t, sock, line) | |
| 228 | 184 | assert.False(t, r.OK, line) | |
| 229 | 185 | } | |
| 230 | - | assert.Len(t, fh.calls, before) | |
| 186 | + | // The hooks mock expects no call: no use case ran. | |
| 231 | 187 | }) | |
| 232 | 188 | ||
| 233 | 189 | t.Run("a connection that closes without a request does not hurt", func(t *testing.T) { | |
| @@ -242,24 +198,26 @@ func TestHookProtocol(t *testing.T) { | |||
| 242 | 198 | // A shutdown while a push is being recorded must not drop the recording: the | |
| 243 | 199 | // push is already in the repository. | |
| 244 | 200 | func TestHookRecordSurvivesShutdown(t *testing.T) { | |
| 245 | - | fh := &fakeHooks{} | |
| 201 | + | hooks := ghmocks.NewPushHooks(t) | |
| 246 | 202 | started, proceed := make(chan struct{}), make(chan struct{}) | |
| 247 | 203 | var cancelled, hasDeadline bool | |
| 248 | 204 | var ctxMu sync.Mutex | |
| 249 | - | fh.onRecord = func(ctx context.Context) { | |
| 250 | - | close(started) | |
| 251 | - | <-proceed | |
| 252 | - | ctxMu.Lock() | |
| 253 | - | defer ctxMu.Unlock() | |
| 254 | - | cancelled = ctx.Err() != nil | |
| 255 | - | _, hasDeadline = ctx.Deadline() | |
| 256 | - | } | |
| 205 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 206 | + | func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ []domain.RefUpdate) error { | |
| 207 | + | close(started) | |
| 208 | + | <-proceed | |
| 209 | + | ctxMu.Lock() | |
| 210 | + | defer ctxMu.Unlock() | |
| 211 | + | cancelled = ctx.Err() != nil | |
| 212 | + | _, hasDeadline = ctx.Deadline() | |
| 213 | + | return nil | |
| 214 | + | }).Once() | |
| 257 | 215 | ||
| 258 | 216 | dir, err := os.MkdirTemp("", "hk") | |
| 259 | 217 | require.NoError(t, err) | |
| 260 | 218 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | |
| 261 | 219 | sock := filepath.Join(dir, "hook.sock") | |
| 262 | - | srv := githttp.NewHookServer(sock, fh) | |
| 220 | + | srv := githttp.NewHookServer(sock, hooks) | |
| 263 | 221 | require.NoError(t, srv.Listen(context.Background())) | |
| 264 | 222 | ctx, cancel := context.WithCancel(context.Background()) | |
| 265 | 223 | done := make(chan error, 1) | |
| @@ -294,9 +252,13 @@ func TestHookRecordSurvivesShutdown(t *testing.T) { | |||
| 294 | 252 | } | |
| 295 | 253 | ||
| 296 | 254 | func TestHookConcurrentTokens(t *testing.T) { | |
| 297 | - | fh := &fakeHooks{} | |
| 298 | - | srv, sock := startHooks(t, fh) | |
| 255 | + | hooks := ghmocks.NewPushHooks(t) | |
| 256 | + | srv, sock := startHooks(t, hooks) | |
| 299 | 257 | var wg sync.WaitGroup | |
| 258 | + | // Each of the 16 tokens reaches the use case for its own repository, once. | |
| 259 | + | for i := range 16 { | |
| 260 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "r"+string(rune('a'+i))), mock.Anything).Return(nil).Once() | |
| 261 | + | } | |
| 300 | 262 | for i := range 16 { | |
| 301 | 263 | wg.Add(1) | |
| 302 | 264 | go func() { | |
| @@ -309,39 +271,35 @@ func TestHookConcurrentTokens(t *testing.T) { | |||
| 309 | 271 | }() | |
| 310 | 272 | } | |
| 311 | 273 | wg.Wait() | |
| 312 | - | seen := map[domain.RepoName]bool{} | |
| 313 | - | for _, c := range fh.calls { | |
| 314 | - | seen[c.repo] = true | |
| 315 | - | } | |
| 316 | - | assert.Len(t, seen, 16) | |
| 317 | 274 | } | |
| 318 | 275 | ||
| 319 | 276 | func TestRunHook(t *testing.T) { | |
| 320 | 277 | ctx := context.Background() | |
| 321 | - | fh := &fakeHooks{} | |
| 322 | - | srv, sock := startHooks(t, fh) | |
| 278 | + | hooks := ghmocks.NewPushHooks(t) | |
| 279 | + | srv, sock := startHooks(t, hooks) | |
| 323 | 280 | tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root()) | |
| 324 | 281 | defer rel() | |
| 325 | 282 | input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n" | |
| 326 | 283 | ||
| 327 | 284 | t.Run("sends the lines git feeds the hook", func(t *testing.T) { | |
| 328 | 285 | var errOut bytes.Buffer | |
| 329 | - | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | |
| 330 | - | assert.Equal(t, []domain.RefUpdate{ | |
| 286 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{ | |
| 331 | 287 | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}, | |
| 332 | 288 | {Old: oidA, New: oidB, Ref: "refs/heads/dev"}, | |
| 333 | - | }, fh.last().updates) | |
| 289 | + | }).Return(nil).Once() | |
| 290 | + | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | |
| 334 | 291 | assert.Empty(t, errOut.String()) | |
| 335 | 292 | }) | |
| 336 | 293 | ||
| 337 | 294 | t.Run("a ref name with spaces survives", func(t *testing.T) { | |
| 295 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"), | |
| 296 | + | []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once() | |
| 338 | 297 | require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok)) | |
| 339 | - | assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref) | |
| 340 | 298 | }) | |
| 341 | 299 | ||
| 342 | 300 | t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) { | |
| 343 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope")) | |
| 344 | - | defer func() { fh.checkErr = nil }() | |
| 301 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 302 | + | Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once() | |
| 345 | 303 | var errOut bytes.Buffer | |
| 346 | 304 | err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok) | |
| 347 | 305 | require.ErrorIs(t, err, githttp.ErrHookRejected) | |
| @@ -18,9 +18,11 @@ import ( | |||||
| 18 | "time" | 18 | "time" | ||
| 19 | 19 | ||||
| 20 | "github.com/stretchr/testify/assert" | 20 | "github.com/stretchr/testify/assert" | ||
| 21 | + | "github.com/stretchr/testify/mock" | |||
| 21 | "github.com/stretchr/testify/require" | 22 | "github.com/stretchr/testify/require" | ||
| 22 | 23 | ||||
| 23 | "bnema.dev/ermite/internal/adapters/githttp" | 24 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 25 | + | ghmocks "bnema.dev/ermite/internal/adapters/githttp/mocks" | |||
| 24 | "bnema.dev/ermite/internal/domain" | 26 | "bnema.dev/ermite/internal/domain" | ||
| 25 | ) | 27 | ) | ||
| 26 | 28 | ||||
| @@ -29,46 +31,6 @@ const ( | |||||
| 29 | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | 31 | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | ||
| 30 | ) | 32 | ) | ||
| 31 | 33 | ||||
| 32 | - | type call struct { | |||
| 33 | - | hook string | |||
| 34 | - | p domain.Principal | |||
| 35 | - | repo domain.RepoName | |||
| 36 | - | updates []domain.RefUpdate | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | type fakeHooks struct { | |||
| 40 | - | mu sync.Mutex | |||
| 41 | - | calls []call | |||
| 42 | - | checkErr error | |||
| 43 | - | recErr error | |||
| 44 | - | // onRecord, when set, runs inside Record with its context. | |||
| 45 | - | onRecord func(ctx context.Context) | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |||
| 49 | - | f.mu.Lock() | |||
| 50 | - | defer f.mu.Unlock() | |||
| 51 | - | f.calls = append(f.calls, call{"check", p, n, u}) | |||
| 52 | - | return f.checkErr | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |||
| 56 | - | f.mu.Lock() | |||
| 57 | - | f.calls = append(f.calls, call{"record", p, n, u}) | |||
| 58 | - | err, on := f.recErr, f.onRecord | |||
| 59 | - | f.mu.Unlock() | |||
| 60 | - | if on != nil { | |||
| 61 | - | on(ctx) | |||
| 62 | - | } | |||
| 63 | - | return err | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | func (f *fakeHooks) last() call { | |||
| 67 | - | f.mu.Lock() | |||
| 68 | - | defer f.mu.Unlock() | |||
| 69 | - | return f.calls[len(f.calls)-1] | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | func repoName(t *testing.T, s string) domain.RepoName { | 34 | func repoName(t *testing.T, s string) domain.RepoName { | ||
| 73 | t.Helper() | 35 | t.Helper() | ||
| 74 | n, err := domain.ParseRepoName(s) | 36 | n, err := domain.ParseRepoName(s) | ||
| @@ -117,7 +79,7 @@ func raw(t *testing.T, sock, line string) githttp.HookReply { | |||||
| 117 | } | 79 | } | ||
| 118 | 80 | ||||
| 119 | func TestHookSocketMode(t *testing.T) { | 81 | func TestHookSocketMode(t *testing.T) { | ||
| 120 | - | _, sock := startHooks(t, &fakeHooks{}) | 82 | + | _, sock := startHooks(t, ghmocks.NewPushHooks(t)) |
| 121 | fi, err := os.Stat(sock) | 83 | fi, err := os.Stat(sock) | ||
| 122 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 123 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | 85 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | ||
| @@ -130,21 +92,21 @@ func TestHookServerReplacesStaleSocketFile(t *testing.T) { | |||||
| 130 | defer func() { _ = os.RemoveAll(dir) }() | 92 | defer func() { _ = os.RemoveAll(dir) }() | ||
| 131 | sock := filepath.Join(dir, "hook.sock") | 93 | sock := filepath.Join(dir, "hook.sock") | ||
| 132 | require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600)) | 94 | require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600)) | ||
| 133 | - | s := githttp.NewHookServer(sock, &fakeHooks{}) | 95 | + | s := githttp.NewHookServer(sock, ghmocks.NewPushHooks(t)) |
| 134 | require.NoError(t, s.Listen(context.Background())) | 96 | require.NoError(t, s.Listen(context.Background())) | ||
| 135 | s.Close(context.Background()) | 97 | s.Close(context.Background()) | ||
| 136 | } | 98 | } | ||
| 137 | 99 | ||||
| 138 | func TestHookServerRejectsTooLongSocketPath(t *testing.T) { | 100 | func TestHookServerRejectsTooLongSocketPath(t *testing.T) { | ||
| 139 | - | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{}) | 101 | + | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", ghmocks.NewPushHooks(t)) |
| 140 | err := s.Listen(context.Background()) | 102 | err := s.Listen(context.Background()) | ||
| 141 | require.Error(t, err) | 103 | require.Error(t, err) | ||
| 142 | assert.Contains(t, err.Error(), "too long") | 104 | assert.Contains(t, err.Error(), "too long") | ||
| 143 | } | 105 | } | ||
| 144 | 106 | ||||
| 145 | func TestHookProtocol(t *testing.T) { | 107 | func TestHookProtocol(t *testing.T) { | ||
| 146 | - | fh := &fakeHooks{} | 108 | + | hooks := ghmocks.NewPushHooks(t) |
| 147 | - | srv, sock := startHooks(t, fh) | 109 | + | srv, sock := startHooks(t, hooks) |
| 148 | p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | 110 | p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | ||
| 149 | tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p) | 111 | tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p) | ||
| 150 | tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root()) | 112 | tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root()) | ||
| @@ -154,46 +116,42 @@ func TestHookProtocol(t *testing.T) { | |||||
| 154 | upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]` | 116 | upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]` | ||
| 155 | 117 | ||||
| 156 | t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) { | 118 | t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) { | ||
| 119 | + | updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}} | |||
| 120 | + | hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }), | |||
| 121 | + | repoName(t, "alpha"), updates).Return(nil).Once() | |||
| 157 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | 122 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | ||
| 158 | assert.True(t, r.OK) | 123 | assert.True(t, r.OK) | ||
| 159 | - | c := fh.last() | |||
| 160 | - | assert.Equal(t, "check", c.hook) | |||
| 161 | - | assert.Equal(t, repoName(t, "alpha"), c.repo) | |||
| 162 | - | assert.Equal(t, "tok1", c.p.TokenID()) | |||
| 163 | - | assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates) | |||
| 164 | 124 | ||||
| 125 | + | hooks.EXPECT().Record(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.IsRoot() }), | |||
| 126 | + | repoName(t, "beta"), updates).Return(nil).Once() | |||
| 165 | r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`) | 127 | r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`) | ||
| 166 | assert.True(t, r.OK) | 128 | assert.True(t, r.OK) | ||
| 167 | - | c = fh.last() | |||
| 168 | - | assert.Equal(t, "record", c.hook) | |||
| 169 | - | assert.Equal(t, repoName(t, "beta"), c.repo) | |||
| 170 | - | assert.True(t, c.p.IsRoot()) | |||
| 171 | }) | 129 | }) | ||
| 172 | 130 | ||||
| 173 | t.Run("a repository sent by the client is ignored", func(t *testing.T) { | 131 | t.Run("a repository sent by the client is ignored", func(t *testing.T) { | ||
| 174 | - | raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) | 132 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once() |
| 175 | - | assert.Equal(t, repoName(t, "alpha"), fh.last().repo) | 133 | + | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) |
| 134 | + | assert.True(t, r.OK) | |||
| 176 | }) | 135 | }) | ||
| 177 | 136 | ||||
| 178 | t.Run("refusal carries the message", func(t *testing.T) { | 137 | t.Run("refusal carries the message", func(t *testing.T) { | ||
| 179 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch")) | 138 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). |
| 180 | - | defer func() { fh.checkErr = nil }() | 139 | + | Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once() |
| 181 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | 140 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | ||
| 182 | assert.False(t, r.OK) | 141 | assert.False(t, r.OK) | ||
| 183 | assert.Contains(t, r.Message, "rejected") | 142 | assert.Contains(t, r.Message, "rejected") | ||
| 184 | }) | 143 | }) | ||
| 185 | 144 | ||||
| 186 | t.Run("an internal error fails closed without leaking details", func(t *testing.T) { | 145 | t.Run("an internal error fails closed without leaking details", func(t *testing.T) { | ||
| 187 | - | fh.checkErr = errors.New("disk /secret/path exploded") | 146 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). |
| 188 | - | defer func() { fh.checkErr = nil }() | 147 | + | Return(errors.New("disk /secret/path exploded")).Once() |
| 189 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | 148 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | ||
| 190 | assert.False(t, r.OK) | 149 | assert.False(t, r.OK) | ||
| 191 | assert.NotContains(t, r.Message, "/secret/path") | 150 | assert.NotContains(t, r.Message, "/secret/path") | ||
| 192 | }) | 151 | }) | ||
| 193 | 152 | ||||
| 194 | t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) { | 153 | t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) { | ||
| 195 | - | fh.recErr = errors.New("boom") | 154 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("boom")).Once() |
| 196 | - | defer func() { fh.recErr = nil }() | |||
| 197 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`) | 155 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`) | ||
| 198 | assert.True(t, r.OK) | 156 | assert.True(t, r.OK) | ||
| 199 | assert.NotEmpty(t, r.Message) | 157 | assert.NotEmpty(t, r.Message) | ||
| @@ -205,18 +163,16 @@ func TestHookProtocol(t *testing.T) { | |||||
| 205 | assert.False(t, r.OK, tok) | 163 | assert.False(t, r.OK, tok) | ||
| 206 | } | 164 | } | ||
| 207 | releaseA() | 165 | releaseA() | ||
| 208 | - | before := len(fh.calls) | |||
| 209 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`) | 166 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`) | ||
| 210 | assert.False(t, r.OK, "released token") | 167 | assert.False(t, r.OK, "released token") | ||
| 211 | assert.Contains(t, r.Message, "token") | 168 | assert.Contains(t, r.Message, "token") | ||
| 212 | - | assert.Len(t, fh.calls, before, "no use case ran") | 169 | + | // The hooks mock expects no call: no use case ran. |
| 213 | releaseB() | 170 | releaseB() | ||
| 214 | }) | 171 | }) | ||
| 215 | 172 | ||||
| 216 | t.Run("malformed requests are refused", func(t *testing.T) { | 173 | t.Run("malformed requests are refused", func(t *testing.T) { | ||
| 217 | tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root()) | 174 | tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root()) | ||
| 218 | defer rel() | 175 | defer rel() | ||
| 219 | - | before := len(fh.calls) | |||
| 220 | for _, line := range []string{ | 176 | for _, line := range []string{ | ||
| 221 | `not json`, | 177 | `not json`, | ||
| 222 | `{}`, | 178 | `{}`, | ||
| @@ -227,7 +183,7 @@ func TestHookProtocol(t *testing.T) { | |||||
| 227 | r := raw(t, sock, line) | 183 | r := raw(t, sock, line) | ||
| 228 | assert.False(t, r.OK, line) | 184 | assert.False(t, r.OK, line) | ||
| 229 | } | 185 | } | ||
| 230 | - | assert.Len(t, fh.calls, before) | 186 | + | // The hooks mock expects no call: no use case ran. |
| 231 | }) | 187 | }) | ||
| 232 | 188 | ||||
| 233 | t.Run("a connection that closes without a request does not hurt", func(t *testing.T) { | 189 | t.Run("a connection that closes without a request does not hurt", func(t *testing.T) { | ||
| @@ -242,24 +198,26 @@ func TestHookProtocol(t *testing.T) { | |||||
| 242 | // A shutdown while a push is being recorded must not drop the recording: the | 198 | // A shutdown while a push is being recorded must not drop the recording: the | ||
| 243 | // push is already in the repository. | 199 | // push is already in the repository. | ||
| 244 | func TestHookRecordSurvivesShutdown(t *testing.T) { | 200 | func TestHookRecordSurvivesShutdown(t *testing.T) { | ||
| 245 | - | fh := &fakeHooks{} | 201 | + | hooks := ghmocks.NewPushHooks(t) |
| 246 | started, proceed := make(chan struct{}), make(chan struct{}) | 202 | started, proceed := make(chan struct{}), make(chan struct{}) | ||
| 247 | var cancelled, hasDeadline bool | 203 | var cancelled, hasDeadline bool | ||
| 248 | var ctxMu sync.Mutex | 204 | var ctxMu sync.Mutex | ||
| 249 | - | fh.onRecord = func(ctx context.Context) { | 205 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 250 | - | close(started) | 206 | + | func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ []domain.RefUpdate) error { |
| 251 | - | <-proceed | 207 | + | close(started) |
| 252 | - | ctxMu.Lock() | 208 | + | <-proceed |
| 253 | - | defer ctxMu.Unlock() | 209 | + | ctxMu.Lock() |
| 254 | - | cancelled = ctx.Err() != nil | 210 | + | defer ctxMu.Unlock() |
| 255 | - | _, hasDeadline = ctx.Deadline() | 211 | + | cancelled = ctx.Err() != nil |
| 256 | - | } | 212 | + | _, hasDeadline = ctx.Deadline() |
| 213 | + | return nil | |||
| 214 | + | }).Once() | |||
| 257 | 215 | ||||
| 258 | dir, err := os.MkdirTemp("", "hk") | 216 | dir, err := os.MkdirTemp("", "hk") | ||
| 259 | require.NoError(t, err) | 217 | require.NoError(t, err) | ||
| 260 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | 218 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | ||
| 261 | sock := filepath.Join(dir, "hook.sock") | 219 | sock := filepath.Join(dir, "hook.sock") | ||
| 262 | - | srv := githttp.NewHookServer(sock, fh) | 220 | + | srv := githttp.NewHookServer(sock, hooks) |
| 263 | require.NoError(t, srv.Listen(context.Background())) | 221 | require.NoError(t, srv.Listen(context.Background())) | ||
| 264 | ctx, cancel := context.WithCancel(context.Background()) | 222 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 265 | done := make(chan error, 1) | 223 | done := make(chan error, 1) | ||
| @@ -294,9 +252,13 @@ func TestHookRecordSurvivesShutdown(t *testing.T) { | |||||
| 294 | } | 252 | } | ||
| 295 | 253 | ||||
| 296 | func TestHookConcurrentTokens(t *testing.T) { | 254 | func TestHookConcurrentTokens(t *testing.T) { | ||
| 297 | - | fh := &fakeHooks{} | 255 | + | hooks := ghmocks.NewPushHooks(t) |
| 298 | - | srv, sock := startHooks(t, fh) | 256 | + | srv, sock := startHooks(t, hooks) |
| 299 | var wg sync.WaitGroup | 257 | var wg sync.WaitGroup | ||
| 258 | + | // Each of the 16 tokens reaches the use case for its own repository, once. | |||
| 259 | + | for i := range 16 { | |||
| 260 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "r"+string(rune('a'+i))), mock.Anything).Return(nil).Once() | |||
| 261 | + | } | |||
| 300 | for i := range 16 { | 262 | for i := range 16 { | ||
| 301 | wg.Add(1) | 263 | wg.Add(1) | ||
| 302 | go func() { | 264 | go func() { | ||
| @@ -309,39 +271,35 @@ func TestHookConcurrentTokens(t *testing.T) { | |||||
| 309 | }() | 271 | }() | ||
| 310 | } | 272 | } | ||
| 311 | wg.Wait() | 273 | wg.Wait() | ||
| 312 | - | seen := map[domain.RepoName]bool{} | |||
| 313 | - | for _, c := range fh.calls { | |||
| 314 | - | seen[c.repo] = true | |||
| 315 | - | } | |||
| 316 | - | assert.Len(t, seen, 16) | |||
| 317 | } | 274 | } | ||
| 318 | 275 | ||||
| 319 | func TestRunHook(t *testing.T) { | 276 | func TestRunHook(t *testing.T) { | ||
| 320 | ctx := context.Background() | 277 | ctx := context.Background() | ||
| 321 | - | fh := &fakeHooks{} | 278 | + | hooks := ghmocks.NewPushHooks(t) |
| 322 | - | srv, sock := startHooks(t, fh) | 279 | + | srv, sock := startHooks(t, hooks) |
| 323 | tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root()) | 280 | tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root()) | ||
| 324 | defer rel() | 281 | defer rel() | ||
| 325 | input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n" | 282 | input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n" | ||
| 326 | 283 | ||||
| 327 | t.Run("sends the lines git feeds the hook", func(t *testing.T) { | 284 | t.Run("sends the lines git feeds the hook", func(t *testing.T) { | ||
| 328 | var errOut bytes.Buffer | 285 | var errOut bytes.Buffer | ||
| 329 | - | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | 286 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{ |
| 330 | - | assert.Equal(t, []domain.RefUpdate{ | |||
| 331 | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}, | 287 | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}, | ||
| 332 | {Old: oidA, New: oidB, Ref: "refs/heads/dev"}, | 288 | {Old: oidA, New: oidB, Ref: "refs/heads/dev"}, | ||
| 333 | - | }, fh.last().updates) | 289 | + | }).Return(nil).Once() |
| 290 | + | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | |||
| 334 | assert.Empty(t, errOut.String()) | 291 | assert.Empty(t, errOut.String()) | ||
| 335 | }) | 292 | }) | ||
| 336 | 293 | ||||
| 337 | t.Run("a ref name with spaces survives", func(t *testing.T) { | 294 | t.Run("a ref name with spaces survives", func(t *testing.T) { | ||
| 295 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"), | |||
| 296 | + | []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once() | |||
| 338 | require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok)) | 297 | require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok)) | ||
| 339 | - | assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref) | |||
| 340 | }) | 298 | }) | ||
| 341 | 299 | ||||
| 342 | t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) { | 300 | t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) { | ||
| 343 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope")) | 301 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). |
| 344 | - | defer func() { fh.checkErr = nil }() | 302 | + | Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once() |
| 345 | var errOut bytes.Buffer | 303 | var errOut bytes.Buffer | ||
| 346 | err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok) | 304 | err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok) | ||
| 347 | require.ErrorIs(t, err, githttp.ErrHookRejected) | 305 | require.ErrorIs(t, err, githttp.ErrHookRejected) | ||
A internal/adapters/githttp/mocks/Authenticator.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewAuthenticator creates a new instance of Authenticator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewAuthenticator(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *Authenticator { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &Authenticator{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Authenticator is an autogenerated mock type for the Authenticator type | |
| 38 | + | type Authenticator struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type Authenticator_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *Authenticator) EXPECT() *Authenticator_Expecter { | |
| 47 | + | return &Authenticator_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Authenticate provides a mock function for the type Authenticator | |
| 51 | + | func (_mock *Authenticator) Authenticate(ctx context.Context, secret string) (domain.Principal, error) { | |
| 52 | + | ret := _mock.Called(ctx, secret) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Authenticate") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.Principal | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, error)); ok { | |
| 61 | + | return returnFunc(ctx, secret) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok { | |
| 64 | + | r0 = returnFunc(ctx, secret) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(domain.Principal) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 69 | + | r1 = returnFunc(ctx, secret) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Error(1) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Authenticator_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate' | |
| 77 | + | type Authenticator_Authenticate_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Authenticate is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - secret string | |
| 84 | + | func (_e *Authenticator_Expecter) Authenticate(ctx any, secret any) *Authenticator_Authenticate_Call { | |
| 85 | + | return &Authenticator_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, secret)} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *Authenticator_Authenticate_Call) Run(run func(ctx context.Context, secret string)) *Authenticator_Authenticate_Call { | |
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 90 | + | var arg0 context.Context | |
| 91 | + | if args[0] != nil { | |
| 92 | + | arg0 = args[0].(context.Context) | |
| 93 | + | } | |
| 94 | + | var arg1 string | |
| 95 | + | if args[1] != nil { | |
| 96 | + | arg1 = args[1].(string) | |
| 97 | + | } | |
| 98 | + | run( | |
| 99 | + | arg0, | |
| 100 | + | arg1, | |
| 101 | + | ) | |
| 102 | + | }) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *Authenticator_Authenticate_Call) Return(principal domain.Principal, err error) *Authenticator_Authenticate_Call { | |
| 107 | + | _c.Call.Return(principal, err) | |
| 108 | + | return _c | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (_c *Authenticator_Authenticate_Call) RunAndReturn(run func(ctx context.Context, secret string) (domain.Principal, error)) *Authenticator_Authenticate_Call { | |
| 112 | + | _c.Call.Return(run) | |
| 113 | + | return _c | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewAuthenticator creates a new instance of Authenticator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewAuthenticator(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *Authenticator { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &Authenticator{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Authenticator is an autogenerated mock type for the Authenticator type | |||
| 38 | + | type Authenticator struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type Authenticator_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *Authenticator) EXPECT() *Authenticator_Expecter { | |||
| 47 | + | return &Authenticator_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Authenticate provides a mock function for the type Authenticator | |||
| 51 | + | func (_mock *Authenticator) Authenticate(ctx context.Context, secret string) (domain.Principal, error) { | |||
| 52 | + | ret := _mock.Called(ctx, secret) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Authenticate") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 domain.Principal | |||
| 59 | + | var r1 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, error)); ok { | |||
| 61 | + | return returnFunc(ctx, secret) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok { | |||
| 64 | + | r0 = returnFunc(ctx, secret) | |||
| 65 | + | } else { | |||
| 66 | + | r0 = ret.Get(0).(domain.Principal) | |||
| 67 | + | } | |||
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 69 | + | r1 = returnFunc(ctx, secret) | |||
| 70 | + | } else { | |||
| 71 | + | r1 = ret.Error(1) | |||
| 72 | + | } | |||
| 73 | + | return r0, r1 | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Authenticator_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate' | |||
| 77 | + | type Authenticator_Authenticate_Call struct { | |||
| 78 | + | *mock.Call | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Authenticate is a helper method to define mock.On call | |||
| 82 | + | // - ctx context.Context | |||
| 83 | + | // - secret string | |||
| 84 | + | func (_e *Authenticator_Expecter) Authenticate(ctx any, secret any) *Authenticator_Authenticate_Call { | |||
| 85 | + | return &Authenticator_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, secret)} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (_c *Authenticator_Authenticate_Call) Run(run func(ctx context.Context, secret string)) *Authenticator_Authenticate_Call { | |||
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 90 | + | var arg0 context.Context | |||
| 91 | + | if args[0] != nil { | |||
| 92 | + | arg0 = args[0].(context.Context) | |||
| 93 | + | } | |||
| 94 | + | var arg1 string | |||
| 95 | + | if args[1] != nil { | |||
| 96 | + | arg1 = args[1].(string) | |||
| 97 | + | } | |||
| 98 | + | run( | |||
| 99 | + | arg0, | |||
| 100 | + | arg1, | |||
| 101 | + | ) | |||
| 102 | + | }) | |||
| 103 | + | return _c | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (_c *Authenticator_Authenticate_Call) Return(principal domain.Principal, err error) *Authenticator_Authenticate_Call { | |||
| 107 | + | _c.Call.Return(principal, err) | |||
| 108 | + | return _c | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (_c *Authenticator_Authenticate_Call) RunAndReturn(run func(ctx context.Context, secret string) (domain.Principal, error)) *Authenticator_Authenticate_Call { | |||
| 112 | + | _c.Call.Return(run) | |||
| 113 | + | return _c | |||
| 114 | + | } | |||
A internal/adapters/githttp/mocks/HookTokens.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewHookTokens creates a new instance of HookTokens. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewHookTokens(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *HookTokens { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &HookTokens{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // HookTokens is an autogenerated mock type for the HookTokens type | |
| 38 | + | type HookTokens struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type HookTokens_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *HookTokens) EXPECT() *HookTokens_Expecter { | |
| 47 | + | return &HookTokens_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Register provides a mock function for the type HookTokens | |
| 51 | + | func (_mock *HookTokens) Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func()) { | |
| 52 | + | ret := _mock.Called(ctx, repo, p) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Register") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 string | |
| 59 | + | var r1 func() | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) (string, func())); ok { | |
| 61 | + | return returnFunc(ctx, repo, p) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) string); ok { | |
| 64 | + | r0 = returnFunc(ctx, repo, p) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(string) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Principal) func()); ok { | |
| 69 | + | r1 = returnFunc(ctx, repo, p) | |
| 70 | + | } else { | |
| 71 | + | if ret.Get(1) != nil { | |
| 72 | + | r1 = ret.Get(1).(func()) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // HookTokens_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' | |
| 79 | + | type HookTokens_Register_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Register is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - p domain.Principal | |
| 87 | + | func (_e *HookTokens_Expecter) Register(ctx any, repo any, p any) *HookTokens_Register_Call { | |
| 88 | + | return &HookTokens_Register_Call{Call: _e.mock.On("Register", ctx, repo, p)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *HookTokens_Register_Call) Run(run func(ctx context.Context, repo domain.RepoName, p domain.Principal)) *HookTokens_Register_Call { | |
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 93 | + | var arg0 context.Context | |
| 94 | + | if args[0] != nil { | |
| 95 | + | arg0 = args[0].(context.Context) | |
| 96 | + | } | |
| 97 | + | var arg1 domain.RepoName | |
| 98 | + | if args[1] != nil { | |
| 99 | + | arg1 = args[1].(domain.RepoName) | |
| 100 | + | } | |
| 101 | + | var arg2 domain.Principal | |
| 102 | + | if args[2] != nil { | |
| 103 | + | arg2 = args[2].(domain.Principal) | |
| 104 | + | } | |
| 105 | + | run( | |
| 106 | + | arg0, | |
| 107 | + | arg1, | |
| 108 | + | arg2, | |
| 109 | + | ) | |
| 110 | + | }) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *HookTokens_Register_Call) Return(token string, release func()) *HookTokens_Register_Call { | |
| 115 | + | _c.Call.Return(token, release) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *HookTokens_Register_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func())) *HookTokens_Register_Call { | |
| 120 | + | _c.Call.Return(run) | |
| 121 | + | return _c | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Socket provides a mock function for the type HookTokens | |
| 125 | + | func (_mock *HookTokens) Socket() string { | |
| 126 | + | ret := _mock.Called() | |
| 127 | + | ||
| 128 | + | if len(ret) == 0 { | |
| 129 | + | panic("no return value specified for Socket") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | var r0 string | |
| 133 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 134 | + | r0 = returnFunc() | |
| 135 | + | } else { | |
| 136 | + | r0 = ret.Get(0).(string) | |
| 137 | + | } | |
| 138 | + | return r0 | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // HookTokens_Socket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Socket' | |
| 142 | + | type HookTokens_Socket_Call struct { | |
| 143 | + | *mock.Call | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Socket is a helper method to define mock.On call | |
| 147 | + | func (_e *HookTokens_Expecter) Socket() *HookTokens_Socket_Call { | |
| 148 | + | return &HookTokens_Socket_Call{Call: _e.mock.On("Socket")} | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (_c *HookTokens_Socket_Call) Run(run func()) *HookTokens_Socket_Call { | |
| 152 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 153 | + | run() | |
| 154 | + | }) | |
| 155 | + | return _c | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (_c *HookTokens_Socket_Call) Return(s string) *HookTokens_Socket_Call { | |
| 159 | + | _c.Call.Return(s) | |
| 160 | + | return _c | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (_c *HookTokens_Socket_Call) RunAndReturn(run func() string) *HookTokens_Socket_Call { | |
| 164 | + | _c.Call.Return(run) | |
| 165 | + | return _c | |
| 166 | + | } | |
| @@ -0,0 +1,166 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewHookTokens creates a new instance of HookTokens. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewHookTokens(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *HookTokens { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &HookTokens{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // HookTokens is an autogenerated mock type for the HookTokens type | |||
| 38 | + | type HookTokens struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type HookTokens_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *HookTokens) EXPECT() *HookTokens_Expecter { | |||
| 47 | + | return &HookTokens_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Register provides a mock function for the type HookTokens | |||
| 51 | + | func (_mock *HookTokens) Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func()) { | |||
| 52 | + | ret := _mock.Called(ctx, repo, p) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Register") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 string | |||
| 59 | + | var r1 func() | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) (string, func())); ok { | |||
| 61 | + | return returnFunc(ctx, repo, p) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) string); ok { | |||
| 64 | + | r0 = returnFunc(ctx, repo, p) | |||
| 65 | + | } else { | |||
| 66 | + | r0 = ret.Get(0).(string) | |||
| 67 | + | } | |||
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Principal) func()); ok { | |||
| 69 | + | r1 = returnFunc(ctx, repo, p) | |||
| 70 | + | } else { | |||
| 71 | + | if ret.Get(1) != nil { | |||
| 72 | + | r1 = ret.Get(1).(func()) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return r0, r1 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // HookTokens_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' | |||
| 79 | + | type HookTokens_Register_Call struct { | |||
| 80 | + | *mock.Call | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Register is a helper method to define mock.On call | |||
| 84 | + | // - ctx context.Context | |||
| 85 | + | // - repo domain.RepoName | |||
| 86 | + | // - p domain.Principal | |||
| 87 | + | func (_e *HookTokens_Expecter) Register(ctx any, repo any, p any) *HookTokens_Register_Call { | |||
| 88 | + | return &HookTokens_Register_Call{Call: _e.mock.On("Register", ctx, repo, p)} | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (_c *HookTokens_Register_Call) Run(run func(ctx context.Context, repo domain.RepoName, p domain.Principal)) *HookTokens_Register_Call { | |||
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 93 | + | var arg0 context.Context | |||
| 94 | + | if args[0] != nil { | |||
| 95 | + | arg0 = args[0].(context.Context) | |||
| 96 | + | } | |||
| 97 | + | var arg1 domain.RepoName | |||
| 98 | + | if args[1] != nil { | |||
| 99 | + | arg1 = args[1].(domain.RepoName) | |||
| 100 | + | } | |||
| 101 | + | var arg2 domain.Principal | |||
| 102 | + | if args[2] != nil { | |||
| 103 | + | arg2 = args[2].(domain.Principal) | |||
| 104 | + | } | |||
| 105 | + | run( | |||
| 106 | + | arg0, | |||
| 107 | + | arg1, | |||
| 108 | + | arg2, | |||
| 109 | + | ) | |||
| 110 | + | }) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (_c *HookTokens_Register_Call) Return(token string, release func()) *HookTokens_Register_Call { | |||
| 115 | + | _c.Call.Return(token, release) | |||
| 116 | + | return _c | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (_c *HookTokens_Register_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func())) *HookTokens_Register_Call { | |||
| 120 | + | _c.Call.Return(run) | |||
| 121 | + | return _c | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Socket provides a mock function for the type HookTokens | |||
| 125 | + | func (_mock *HookTokens) Socket() string { | |||
| 126 | + | ret := _mock.Called() | |||
| 127 | + | ||||
| 128 | + | if len(ret) == 0 { | |||
| 129 | + | panic("no return value specified for Socket") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | var r0 string | |||
| 133 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 134 | + | r0 = returnFunc() | |||
| 135 | + | } else { | |||
| 136 | + | r0 = ret.Get(0).(string) | |||
| 137 | + | } | |||
| 138 | + | return r0 | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // HookTokens_Socket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Socket' | |||
| 142 | + | type HookTokens_Socket_Call struct { | |||
| 143 | + | *mock.Call | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Socket is a helper method to define mock.On call | |||
| 147 | + | func (_e *HookTokens_Expecter) Socket() *HookTokens_Socket_Call { | |||
| 148 | + | return &HookTokens_Socket_Call{Call: _e.mock.On("Socket")} | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func (_c *HookTokens_Socket_Call) Run(run func()) *HookTokens_Socket_Call { | |||
| 152 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 153 | + | run() | |||
| 154 | + | }) | |||
| 155 | + | return _c | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (_c *HookTokens_Socket_Call) Return(s string) *HookTokens_Socket_Call { | |||
| 159 | + | _c.Call.Return(s) | |||
| 160 | + | return _c | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (_c *HookTokens_Socket_Call) RunAndReturn(run func() string) *HookTokens_Socket_Call { | |||
| 164 | + | _c.Call.Return(run) | |||
| 165 | + | return _c | |||
| 166 | + | } | |||
A internal/adapters/githttp/mocks/PushHooks.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewPushHooks creates a new instance of PushHooks. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewPushHooks(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *PushHooks { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &PushHooks{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // PushHooks is an autogenerated mock type for the PushHooks type | |
| 38 | + | type PushHooks struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type PushHooks_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *PushHooks) EXPECT() *PushHooks_Expecter { | |
| 47 | + | return &PushHooks_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Check provides a mock function for the type PushHooks | |
| 51 | + | func (_mock *PushHooks) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| 52 | + | ret := _mock.Called(ctx, p, name, updates) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Check") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, p, name, updates) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // PushHooks_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' | |
| 68 | + | type PushHooks_Check_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Check is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - p domain.Principal | |
| 75 | + | // - name domain.RepoName | |
| 76 | + | // - updates []domain.RefUpdate | |
| 77 | + | func (_e *PushHooks_Expecter) Check(ctx any, p any, name any, updates any) *PushHooks_Check_Call { | |
| 78 | + | return &PushHooks_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *PushHooks_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Check_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 domain.Principal | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(domain.Principal) | |
| 90 | + | } | |
| 91 | + | var arg2 domain.RepoName | |
| 92 | + | if args[2] != nil { | |
| 93 | + | arg2 = args[2].(domain.RepoName) | |
| 94 | + | } | |
| 95 | + | var arg3 []domain.RefUpdate | |
| 96 | + | if args[3] != nil { | |
| 97 | + | arg3 = args[3].([]domain.RefUpdate) | |
| 98 | + | } | |
| 99 | + | run( | |
| 100 | + | arg0, | |
| 101 | + | arg1, | |
| 102 | + | arg2, | |
| 103 | + | arg3, | |
| 104 | + | ) | |
| 105 | + | }) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (_c *PushHooks_Check_Call) Return(err error) *PushHooks_Check_Call { | |
| 110 | + | _c.Call.Return(err) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *PushHooks_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Check_Call { | |
| 115 | + | _c.Call.Return(run) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Record provides a mock function for the type PushHooks | |
| 120 | + | func (_mock *PushHooks) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| 121 | + | ret := _mock.Called(ctx, p, name, updates) | |
| 122 | + | ||
| 123 | + | if len(ret) == 0 { | |
| 124 | + | panic("no return value specified for Record") | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | var r0 error | |
| 128 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |
| 129 | + | r0 = returnFunc(ctx, p, name, updates) | |
| 130 | + | } else { | |
| 131 | + | r0 = ret.Error(0) | |
| 132 | + | } | |
| 133 | + | return r0 | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // PushHooks_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record' | |
| 137 | + | type PushHooks_Record_Call struct { | |
| 138 | + | *mock.Call | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Record is a helper method to define mock.On call | |
| 142 | + | // - ctx context.Context | |
| 143 | + | // - p domain.Principal | |
| 144 | + | // - name domain.RepoName | |
| 145 | + | // - updates []domain.RefUpdate | |
| 146 | + | func (_e *PushHooks_Expecter) Record(ctx any, p any, name any, updates any) *PushHooks_Record_Call { | |
| 147 | + | return &PushHooks_Record_Call{Call: _e.mock.On("Record", ctx, p, name, updates)} | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (_c *PushHooks_Record_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Record_Call { | |
| 151 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 152 | + | var arg0 context.Context | |
| 153 | + | if args[0] != nil { | |
| 154 | + | arg0 = args[0].(context.Context) | |
| 155 | + | } | |
| 156 | + | var arg1 domain.Principal | |
| 157 | + | if args[1] != nil { | |
| 158 | + | arg1 = args[1].(domain.Principal) | |
| 159 | + | } | |
| 160 | + | var arg2 domain.RepoName | |
| 161 | + | if args[2] != nil { | |
| 162 | + | arg2 = args[2].(domain.RepoName) | |
| 163 | + | } | |
| 164 | + | var arg3 []domain.RefUpdate | |
| 165 | + | if args[3] != nil { | |
| 166 | + | arg3 = args[3].([]domain.RefUpdate) | |
| 167 | + | } | |
| 168 | + | run( | |
| 169 | + | arg0, | |
| 170 | + | arg1, | |
| 171 | + | arg2, | |
| 172 | + | arg3, | |
| 173 | + | ) | |
| 174 | + | }) | |
| 175 | + | return _c | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (_c *PushHooks_Record_Call) Return(err error) *PushHooks_Record_Call { | |
| 179 | + | _c.Call.Return(err) | |
| 180 | + | return _c | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (_c *PushHooks_Record_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Record_Call { | |
| 184 | + | _c.Call.Return(run) | |
| 185 | + | return _c | |
| 186 | + | } | |
| @@ -0,0 +1,186 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewPushHooks creates a new instance of PushHooks. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewPushHooks(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *PushHooks { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &PushHooks{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // PushHooks is an autogenerated mock type for the PushHooks type | |||
| 38 | + | type PushHooks struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type PushHooks_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *PushHooks) EXPECT() *PushHooks_Expecter { | |||
| 47 | + | return &PushHooks_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Check provides a mock function for the type PushHooks | |||
| 51 | + | func (_mock *PushHooks) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |||
| 52 | + | ret := _mock.Called(ctx, p, name, updates) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Check") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |||
| 60 | + | r0 = returnFunc(ctx, p, name, updates) | |||
| 61 | + | } else { | |||
| 62 | + | r0 = ret.Error(0) | |||
| 63 | + | } | |||
| 64 | + | return r0 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // PushHooks_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' | |||
| 68 | + | type PushHooks_Check_Call struct { | |||
| 69 | + | *mock.Call | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Check is a helper method to define mock.On call | |||
| 73 | + | // - ctx context.Context | |||
| 74 | + | // - p domain.Principal | |||
| 75 | + | // - name domain.RepoName | |||
| 76 | + | // - updates []domain.RefUpdate | |||
| 77 | + | func (_e *PushHooks_Expecter) Check(ctx any, p any, name any, updates any) *PushHooks_Check_Call { | |||
| 78 | + | return &PushHooks_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (_c *PushHooks_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Check_Call { | |||
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 83 | + | var arg0 context.Context | |||
| 84 | + | if args[0] != nil { | |||
| 85 | + | arg0 = args[0].(context.Context) | |||
| 86 | + | } | |||
| 87 | + | var arg1 domain.Principal | |||
| 88 | + | if args[1] != nil { | |||
| 89 | + | arg1 = args[1].(domain.Principal) | |||
| 90 | + | } | |||
| 91 | + | var arg2 domain.RepoName | |||
| 92 | + | if args[2] != nil { | |||
| 93 | + | arg2 = args[2].(domain.RepoName) | |||
| 94 | + | } | |||
| 95 | + | var arg3 []domain.RefUpdate | |||
| 96 | + | if args[3] != nil { | |||
| 97 | + | arg3 = args[3].([]domain.RefUpdate) | |||
| 98 | + | } | |||
| 99 | + | run( | |||
| 100 | + | arg0, | |||
| 101 | + | arg1, | |||
| 102 | + | arg2, | |||
| 103 | + | arg3, | |||
| 104 | + | ) | |||
| 105 | + | }) | |||
| 106 | + | return _c | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (_c *PushHooks_Check_Call) Return(err error) *PushHooks_Check_Call { | |||
| 110 | + | _c.Call.Return(err) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (_c *PushHooks_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Check_Call { | |||
| 115 | + | _c.Call.Return(run) | |||
| 116 | + | return _c | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Record provides a mock function for the type PushHooks | |||
| 120 | + | func (_mock *PushHooks) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |||
| 121 | + | ret := _mock.Called(ctx, p, name, updates) | |||
| 122 | + | ||||
| 123 | + | if len(ret) == 0 { | |||
| 124 | + | panic("no return value specified for Record") | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | var r0 error | |||
| 128 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |||
| 129 | + | r0 = returnFunc(ctx, p, name, updates) | |||
| 130 | + | } else { | |||
| 131 | + | r0 = ret.Error(0) | |||
| 132 | + | } | |||
| 133 | + | return r0 | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // PushHooks_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record' | |||
| 137 | + | type PushHooks_Record_Call struct { | |||
| 138 | + | *mock.Call | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Record is a helper method to define mock.On call | |||
| 142 | + | // - ctx context.Context | |||
| 143 | + | // - p domain.Principal | |||
| 144 | + | // - name domain.RepoName | |||
| 145 | + | // - updates []domain.RefUpdate | |||
| 146 | + | func (_e *PushHooks_Expecter) Record(ctx any, p any, name any, updates any) *PushHooks_Record_Call { | |||
| 147 | + | return &PushHooks_Record_Call{Call: _e.mock.On("Record", ctx, p, name, updates)} | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (_c *PushHooks_Record_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHooks_Record_Call { | |||
| 151 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 152 | + | var arg0 context.Context | |||
| 153 | + | if args[0] != nil { | |||
| 154 | + | arg0 = args[0].(context.Context) | |||
| 155 | + | } | |||
| 156 | + | var arg1 domain.Principal | |||
| 157 | + | if args[1] != nil { | |||
| 158 | + | arg1 = args[1].(domain.Principal) | |||
| 159 | + | } | |||
| 160 | + | var arg2 domain.RepoName | |||
| 161 | + | if args[2] != nil { | |||
| 162 | + | arg2 = args[2].(domain.RepoName) | |||
| 163 | + | } | |||
| 164 | + | var arg3 []domain.RefUpdate | |||
| 165 | + | if args[3] != nil { | |||
| 166 | + | arg3 = args[3].([]domain.RefUpdate) | |||
| 167 | + | } | |||
| 168 | + | run( | |||
| 169 | + | arg0, | |||
| 170 | + | arg1, | |||
| 171 | + | arg2, | |||
| 172 | + | arg3, | |||
| 173 | + | ) | |||
| 174 | + | }) | |||
| 175 | + | return _c | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (_c *PushHooks_Record_Call) Return(err error) *PushHooks_Record_Call { | |||
| 179 | + | _c.Call.Return(err) | |||
| 180 | + | return _c | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (_c *PushHooks_Record_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHooks_Record_Call { | |||
| 184 | + | _c.Call.Return(run) | |||
| 185 | + | return _c | |||
| 186 | + | } | |||
A internal/adapters/githttp/mocks/Pushes.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewPushes creates a new instance of Pushes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewPushes(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *Pushes { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &Pushes{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Pushes is an autogenerated mock type for the Pushes type | |
| 38 | + | type Pushes struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type Pushes_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *Pushes) EXPECT() *Pushes_Expecter { | |
| 47 | + | return &Pushes_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Open provides a mock function for the type Pushes | |
| 51 | + | func (_mock *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error { | |
| 52 | + | ret := _mock.Called(ctx, p, name, create) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Open") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, p, name, create) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Pushes_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |
| 68 | + | type Pushes_Open_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Open is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - p domain.Principal | |
| 75 | + | // - name domain.RepoName | |
| 76 | + | // - create bool | |
| 77 | + | func (_e *Pushes_Expecter) Open(ctx any, p any, name any, create any) *Pushes_Open_Call { | |
| 78 | + | return &Pushes_Open_Call{Call: _e.mock.On("Open", ctx, p, name, create)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *Pushes_Open_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool)) *Pushes_Open_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 domain.Principal | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(domain.Principal) | |
| 90 | + | } | |
| 91 | + | var arg2 domain.RepoName | |
| 92 | + | if args[2] != nil { | |
| 93 | + | arg2 = args[2].(domain.RepoName) | |
| 94 | + | } | |
| 95 | + | var arg3 bool | |
| 96 | + | if args[3] != nil { | |
| 97 | + | arg3 = args[3].(bool) | |
| 98 | + | } | |
| 99 | + | run( | |
| 100 | + | arg0, | |
| 101 | + | arg1, | |
| 102 | + | arg2, | |
| 103 | + | arg3, | |
| 104 | + | ) | |
| 105 | + | }) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (_c *Pushes_Open_Call) Return(err error) *Pushes_Open_Call { | |
| 110 | + | _c.Call.Return(err) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *Pushes_Open_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error) *Pushes_Open_Call { | |
| 115 | + | _c.Call.Return(run) | |
| 116 | + | return _c | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewPushes creates a new instance of Pushes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewPushes(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *Pushes { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &Pushes{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Pushes is an autogenerated mock type for the Pushes type | |||
| 38 | + | type Pushes struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type Pushes_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *Pushes) EXPECT() *Pushes_Expecter { | |||
| 47 | + | return &Pushes_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Open provides a mock function for the type Pushes | |||
| 51 | + | func (_mock *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error { | |||
| 52 | + | ret := _mock.Called(ctx, p, name, create) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Open") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok { | |||
| 60 | + | r0 = returnFunc(ctx, p, name, create) | |||
| 61 | + | } else { | |||
| 62 | + | r0 = ret.Error(0) | |||
| 63 | + | } | |||
| 64 | + | return r0 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Pushes_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |||
| 68 | + | type Pushes_Open_Call struct { | |||
| 69 | + | *mock.Call | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Open is a helper method to define mock.On call | |||
| 73 | + | // - ctx context.Context | |||
| 74 | + | // - p domain.Principal | |||
| 75 | + | // - name domain.RepoName | |||
| 76 | + | // - create bool | |||
| 77 | + | func (_e *Pushes_Expecter) Open(ctx any, p any, name any, create any) *Pushes_Open_Call { | |||
| 78 | + | return &Pushes_Open_Call{Call: _e.mock.On("Open", ctx, p, name, create)} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (_c *Pushes_Open_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool)) *Pushes_Open_Call { | |||
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 83 | + | var arg0 context.Context | |||
| 84 | + | if args[0] != nil { | |||
| 85 | + | arg0 = args[0].(context.Context) | |||
| 86 | + | } | |||
| 87 | + | var arg1 domain.Principal | |||
| 88 | + | if args[1] != nil { | |||
| 89 | + | arg1 = args[1].(domain.Principal) | |||
| 90 | + | } | |||
| 91 | + | var arg2 domain.RepoName | |||
| 92 | + | if args[2] != nil { | |||
| 93 | + | arg2 = args[2].(domain.RepoName) | |||
| 94 | + | } | |||
| 95 | + | var arg3 bool | |||
| 96 | + | if args[3] != nil { | |||
| 97 | + | arg3 = args[3].(bool) | |||
| 98 | + | } | |||
| 99 | + | run( | |||
| 100 | + | arg0, | |||
| 101 | + | arg1, | |||
| 102 | + | arg2, | |||
| 103 | + | arg3, | |||
| 104 | + | ) | |||
| 105 | + | }) | |||
| 106 | + | return _c | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (_c *Pushes_Open_Call) Return(err error) *Pushes_Open_Call { | |||
| 110 | + | _c.Call.Return(err) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (_c *Pushes_Open_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error) *Pushes_Open_Call { | |||
| 115 | + | _c.Call.Return(run) | |||
| 116 | + | return _c | |||
| 117 | + | } | |||
A internal/adapters/githttp/mocks/Repos.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewRepos creates a new instance of Repos. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewRepos(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *Repos { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &Repos{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Repos is an autogenerated mock type for the Repos type | |
| 38 | + | type Repos struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type Repos_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *Repos) EXPECT() *Repos_Expecter { | |
| 47 | + | return &Repos_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Get provides a mock function for the type Repos | |
| 51 | + | func (_mock *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 52 | + | ret := _mock.Called(ctx, p, name) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Get") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.Repo | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok { | |
| 61 | + | return returnFunc(ctx, p, name) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok { | |
| 64 | + | r0 = returnFunc(ctx, p, name) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(domain.Repo) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 69 | + | r1 = returnFunc(ctx, p, name) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Error(1) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Repos_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 77 | + | type Repos_Get_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Get is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - p domain.Principal | |
| 84 | + | // - name domain.RepoName | |
| 85 | + | func (_e *Repos_Expecter) Get(ctx any, p any, name any) *Repos_Get_Call { | |
| 86 | + | return &Repos_Get_Call{Call: _e.mock.On("Get", ctx, p, name)} | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (_c *Repos_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *Repos_Get_Call { | |
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 91 | + | var arg0 context.Context | |
| 92 | + | if args[0] != nil { | |
| 93 | + | arg0 = args[0].(context.Context) | |
| 94 | + | } | |
| 95 | + | var arg1 domain.Principal | |
| 96 | + | if args[1] != nil { | |
| 97 | + | arg1 = args[1].(domain.Principal) | |
| 98 | + | } | |
| 99 | + | var arg2 domain.RepoName | |
| 100 | + | if args[2] != nil { | |
| 101 | + | arg2 = args[2].(domain.RepoName) | |
| 102 | + | } | |
| 103 | + | run( | |
| 104 | + | arg0, | |
| 105 | + | arg1, | |
| 106 | + | arg2, | |
| 107 | + | ) | |
| 108 | + | }) | |
| 109 | + | return _c | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (_c *Repos_Get_Call) Return(repo domain.Repo, err error) *Repos_Get_Call { | |
| 113 | + | _c.Call.Return(repo, err) | |
| 114 | + | return _c | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (_c *Repos_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *Repos_Get_Call { | |
| 118 | + | _c.Call.Return(run) | |
| 119 | + | return _c | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewRepos creates a new instance of Repos. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewRepos(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *Repos { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &Repos{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Repos is an autogenerated mock type for the Repos type | |||
| 38 | + | type Repos struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type Repos_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *Repos) EXPECT() *Repos_Expecter { | |||
| 47 | + | return &Repos_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Get provides a mock function for the type Repos | |||
| 51 | + | func (_mock *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |||
| 52 | + | ret := _mock.Called(ctx, p, name) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Get") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 domain.Repo | |||
| 59 | + | var r1 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok { | |||
| 61 | + | return returnFunc(ctx, p, name) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok { | |||
| 64 | + | r0 = returnFunc(ctx, p, name) | |||
| 65 | + | } else { | |||
| 66 | + | r0 = ret.Get(0).(domain.Repo) | |||
| 67 | + | } | |||
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |||
| 69 | + | r1 = returnFunc(ctx, p, name) | |||
| 70 | + | } else { | |||
| 71 | + | r1 = ret.Error(1) | |||
| 72 | + | } | |||
| 73 | + | return r0, r1 | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Repos_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |||
| 77 | + | type Repos_Get_Call struct { | |||
| 78 | + | *mock.Call | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Get is a helper method to define mock.On call | |||
| 82 | + | // - ctx context.Context | |||
| 83 | + | // - p domain.Principal | |||
| 84 | + | // - name domain.RepoName | |||
| 85 | + | func (_e *Repos_Expecter) Get(ctx any, p any, name any) *Repos_Get_Call { | |||
| 86 | + | return &Repos_Get_Call{Call: _e.mock.On("Get", ctx, p, name)} | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (_c *Repos_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *Repos_Get_Call { | |||
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 91 | + | var arg0 context.Context | |||
| 92 | + | if args[0] != nil { | |||
| 93 | + | arg0 = args[0].(context.Context) | |||
| 94 | + | } | |||
| 95 | + | var arg1 domain.Principal | |||
| 96 | + | if args[1] != nil { | |||
| 97 | + | arg1 = args[1].(domain.Principal) | |||
| 98 | + | } | |||
| 99 | + | var arg2 domain.RepoName | |||
| 100 | + | if args[2] != nil { | |||
| 101 | + | arg2 = args[2].(domain.RepoName) | |||
| 102 | + | } | |||
| 103 | + | run( | |||
| 104 | + | arg0, | |||
| 105 | + | arg1, | |||
| 106 | + | arg2, | |||
| 107 | + | ) | |||
| 108 | + | }) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (_c *Repos_Get_Call) Return(repo domain.Repo, err error) *Repos_Get_Call { | |||
| 113 | + | _c.Call.Return(repo, err) | |||
| 114 | + | return _c | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (_c *Repos_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *Repos_Get_Call { | |||
| 118 | + | _c.Call.Return(run) | |||
| 119 | + | return _c | |||
| 120 | + | } | |||
M internal/app/events_test.go +20 -43
| @@ -15,50 +15,20 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | 17 | "bnema.dev/ermite/internal/app" | |
| 18 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |
| 18 | 19 | "bnema.dev/ermite/internal/domain" | |
| 19 | 20 | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| 22 | - | // fakeAuth is the Revalidator of the rig: tokens are valid until revoked. | |
| 23 | - | type fakeAuth struct { | |
| 24 | - | mu sync.Mutex | |
| 25 | - | revoked map[string]bool | |
| 26 | - | calls int | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error { | |
| 30 | - | a.mu.Lock() | |
| 31 | - | defer a.mu.Unlock() | |
| 32 | - | a.calls++ | |
| 33 | - | if a.revoked[p.TokenID()] { | |
| 34 | - | return domain.ErrUnauthenticated | |
| 35 | - | } | |
| 36 | - | return nil | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func (a *fakeAuth) revoke(id string) { | |
| 40 | - | a.mu.Lock() | |
| 41 | - | defer a.mu.Unlock() | |
| 42 | - | if a.revoked == nil { | |
| 43 | - | a.revoked = map[string]bool{} | |
| 44 | - | } | |
| 45 | - | a.revoked[id] = true | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func (a *fakeAuth) count() int { | |
| 49 | - | a.mu.Lock() | |
| 50 | - | defer a.mu.Unlock() | |
| 51 | - | return a.calls | |
| 52 | - | } | |
| 53 | - | ||
| 54 | 23 | // eventRig is an Events use case fed by a channel the test writes to. | |
| 55 | 24 | type eventRig struct { | |
| 56 | - | t *testing.T | |
| 57 | - | meta *mocks.RepoMetaStore | |
| 58 | - | git *mocks.GitRepos | |
| 59 | - | feed chan domain.Event | |
| 60 | - | auth *fakeAuth | |
| 61 | - | events *app.Events | |
| 25 | + | t *testing.T | |
| 26 | + | meta *mocks.RepoMetaStore | |
| 27 | + | git *mocks.GitRepos | |
| 28 | + | feed chan domain.Event | |
| 29 | + | auth *appmocks.Revalidator | |
| 30 | + | revoked sync.Map // token IDs revoked by the test | |
| 31 | + | events *app.Events | |
| 62 | 32 | } | |
| 63 | 33 | ||
| 64 | 34 | func newEventRig(t *testing.T, size int) *eventRig { | |
| @@ -73,8 +43,15 @@ func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig { | |||
| 73 | 43 | meta: mocks.NewRepoMetaStore(t), | |
| 74 | 44 | git: mocks.NewGitRepos(t), | |
| 75 | 45 | feed: make(chan domain.Event, 4096), | |
| 76 | - | auth: &fakeAuth{}, | |
| 46 | + | auth: appmocks.NewRevalidator(t), | |
| 77 | 47 | } | |
| 48 | + | // Tokens are valid until the test revokes them. | |
| 49 | + | r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error { | |
| 50 | + | if _, gone := r.revoked.Load(p.TokenID()); gone { | |
| 51 | + | return domain.ErrUnauthenticated | |
| 52 | + | } | |
| 53 | + | return nil | |
| 54 | + | }).Maybe() | |
| 78 | 55 | cfg.Auth = r.auth | |
| 79 | 56 | bus := mocks.NewEventBus(t) | |
| 80 | 57 | bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event { | |
| @@ -463,7 +440,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |||
| 463 | 440 | r.push(pub, 1) | |
| 464 | 441 | w.next(t) | |
| 465 | 442 | ||
| 466 | - | r.auth.revoke("tok") | |
| 443 | + | r.revoked.Store("tok", true) | |
| 467 | 444 | r.push(priv, 2) // would be delivered to a token that may read priv | |
| 468 | 445 | select { | |
| 469 | 446 | case err := <-w.done: | |
| @@ -499,7 +476,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||
| 499 | 476 | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond}) | |
| 500 | 477 | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | |
| 501 | 478 | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 502 | - | r.auth.revoke("tok") | |
| 479 | + | r.revoked.Store("tok", true) | |
| 503 | 480 | select { | |
| 504 | 481 | case err := <-w.done: | |
| 505 | 482 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -519,7 +496,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||
| 519 | 496 | for range 20 { | |
| 520 | 497 | w.next(t) | |
| 521 | 498 | } | |
| 522 | - | assert.Zero(t, r.auth.count(), "no store lookup within the interval") | |
| 499 | + | r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) // no store lookup within the interval | |
| 523 | 500 | }) | |
| 524 | 501 | ||
| 525 | 502 | t.Run("root and anonymous never ask", func(t *testing.T) { | |
| @@ -531,7 +508,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||
| 531 | 508 | r.push(pub, 1) | |
| 532 | 509 | a.next(t) | |
| 533 | 510 | b.next(t) | |
| 534 | - | assert.Zero(t, r.auth.count()) | |
| 511 | + | r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) | |
| 535 | 512 | }) | |
| 536 | 513 | } | |
| 537 | 514 | ||
| @@ -15,50 +15,20 @@ import ( | |||||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | 16 | ||||
| 17 | "bnema.dev/ermite/internal/app" | 17 | "bnema.dev/ermite/internal/app" | ||
| 18 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |||
| 18 | "bnema.dev/ermite/internal/domain" | 19 | "bnema.dev/ermite/internal/domain" | ||
| 19 | "bnema.dev/ermite/internal/ports/mocks" | 20 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 20 | ) | 21 | ) | ||
| 21 | 22 | ||||
| 22 | - | // fakeAuth is the Revalidator of the rig: tokens are valid until revoked. | |||
| 23 | - | type fakeAuth struct { | |||
| 24 | - | mu sync.Mutex | |||
| 25 | - | revoked map[string]bool | |||
| 26 | - | calls int | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error { | |||
| 30 | - | a.mu.Lock() | |||
| 31 | - | defer a.mu.Unlock() | |||
| 32 | - | a.calls++ | |||
| 33 | - | if a.revoked[p.TokenID()] { | |||
| 34 | - | return domain.ErrUnauthenticated | |||
| 35 | - | } | |||
| 36 | - | return nil | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | func (a *fakeAuth) revoke(id string) { | |||
| 40 | - | a.mu.Lock() | |||
| 41 | - | defer a.mu.Unlock() | |||
| 42 | - | if a.revoked == nil { | |||
| 43 | - | a.revoked = map[string]bool{} | |||
| 44 | - | } | |||
| 45 | - | a.revoked[id] = true | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func (a *fakeAuth) count() int { | |||
| 49 | - | a.mu.Lock() | |||
| 50 | - | defer a.mu.Unlock() | |||
| 51 | - | return a.calls | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | // eventRig is an Events use case fed by a channel the test writes to. | 23 | // eventRig is an Events use case fed by a channel the test writes to. | ||
| 55 | type eventRig struct { | 24 | type eventRig struct { | ||
| 56 | - | t *testing.T | 25 | + | t *testing.T |
| 57 | - | meta *mocks.RepoMetaStore | 26 | + | meta *mocks.RepoMetaStore |
| 58 | - | git *mocks.GitRepos | 27 | + | git *mocks.GitRepos |
| 59 | - | feed chan domain.Event | 28 | + | feed chan domain.Event |
| 60 | - | auth *fakeAuth | 29 | + | auth *appmocks.Revalidator |
| 61 | - | events *app.Events | 30 | + | revoked sync.Map // token IDs revoked by the test |
| 31 | + | events *app.Events | |||
| 62 | } | 32 | } | ||
| 63 | 33 | ||||
| 64 | func newEventRig(t *testing.T, size int) *eventRig { | 34 | func newEventRig(t *testing.T, size int) *eventRig { | ||
| @@ -73,8 +43,15 @@ func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig { | |||||
| 73 | meta: mocks.NewRepoMetaStore(t), | 43 | meta: mocks.NewRepoMetaStore(t), | ||
| 74 | git: mocks.NewGitRepos(t), | 44 | git: mocks.NewGitRepos(t), | ||
| 75 | feed: make(chan domain.Event, 4096), | 45 | feed: make(chan domain.Event, 4096), | ||
| 76 | - | auth: &fakeAuth{}, | 46 | + | auth: appmocks.NewRevalidator(t), |
| 77 | } | 47 | } | ||
| 48 | + | // Tokens are valid until the test revokes them. | |||
| 49 | + | r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error { | |||
| 50 | + | if _, gone := r.revoked.Load(p.TokenID()); gone { | |||
| 51 | + | return domain.ErrUnauthenticated | |||
| 52 | + | } | |||
| 53 | + | return nil | |||
| 54 | + | }).Maybe() | |||
| 78 | cfg.Auth = r.auth | 55 | cfg.Auth = r.auth | ||
| 79 | bus := mocks.NewEventBus(t) | 56 | bus := mocks.NewEventBus(t) | ||
| 80 | bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event { | 57 | bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event { | ||
| @@ -463,7 +440,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |||||
| 463 | r.push(pub, 1) | 440 | r.push(pub, 1) | ||
| 464 | w.next(t) | 441 | w.next(t) | ||
| 465 | 442 | ||||
| 466 | - | r.auth.revoke("tok") | 443 | + | r.revoked.Store("tok", true) |
| 467 | r.push(priv, 2) // would be delivered to a token that may read priv | 444 | r.push(priv, 2) // would be delivered to a token that may read priv | ||
| 468 | select { | 445 | select { | ||
| 469 | case err := <-w.done: | 446 | case err := <-w.done: | ||
| @@ -499,7 +476,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||||
| 499 | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond}) | 476 | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond}) | ||
| 500 | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | 477 | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | ||
| 501 | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | 478 | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | ||
| 502 | - | r.auth.revoke("tok") | 479 | + | r.revoked.Store("tok", true) |
| 503 | select { | 480 | select { | ||
| 504 | case err := <-w.done: | 481 | case err := <-w.done: | ||
| 505 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | 482 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | ||
| @@ -519,7 +496,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||||
| 519 | for range 20 { | 496 | for range 20 { | ||
| 520 | w.next(t) | 497 | w.next(t) | ||
| 521 | } | 498 | } | ||
| 522 | - | assert.Zero(t, r.auth.count(), "no store lookup within the interval") | 499 | + | r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) // no store lookup within the interval |
| 523 | }) | 500 | }) | ||
| 524 | 501 | ||||
| 525 | t.Run("root and anonymous never ask", func(t *testing.T) { | 502 | t.Run("root and anonymous never ask", func(t *testing.T) { | ||
| @@ -531,7 +508,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||||
| 531 | r.push(pub, 1) | 508 | r.push(pub, 1) | ||
| 532 | a.next(t) | 509 | a.next(t) | ||
| 533 | b.next(t) | 510 | b.next(t) | ||
| 534 | - | assert.Zero(t, r.auth.count()) | 511 | + | r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) |
| 535 | }) | 512 | }) | ||
| 536 | } | 513 | } | ||
| 537 | 514 | ||||
M internal/app/maintenance_test.go +5 -3
| @@ -103,12 +103,14 @@ func TestMaintainerTellsTheReaderAfterARun(t *testing.T) { | |||
| 103 | 103 | for _, failure := range []error{nil, errors.New("boom")} { | |
| 104 | 104 | git := mocks.NewGitRepos(t) | |
| 105 | 105 | git.EXPECT().Maintain(mockAny, n).Return(failure) | |
| 106 | - | fresh := &freshener{} | |
| 106 | + | told := make(chan struct{}, 2) | |
| 107 | + | fresh := mocks.NewRepoFreshener(t) | |
| 108 | + | fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once() | |
| 107 | 109 | m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh) | |
| 108 | 110 | m.Schedule(ctx, n) | |
| 109 | - | waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told") | |
| 111 | + | waitFor(t, func() bool { return len(told) == 1 }, "the reader to be told") | |
| 110 | 112 | m.Close(ctx) | |
| 111 | - | assert.Equal(t, []domain.RepoName{n}, fresh.seen()) | |
| 113 | + | assert.Len(t, told, 1, "told exactly once") | |
| 112 | 114 | } | |
| 113 | 115 | } | |
| 114 | 116 | ||
| @@ -103,12 +103,14 @@ func TestMaintainerTellsTheReaderAfterARun(t *testing.T) { | |||||
| 103 | for _, failure := range []error{nil, errors.New("boom")} { | 103 | for _, failure := range []error{nil, errors.New("boom")} { | ||
| 104 | git := mocks.NewGitRepos(t) | 104 | git := mocks.NewGitRepos(t) | ||
| 105 | git.EXPECT().Maintain(mockAny, n).Return(failure) | 105 | git.EXPECT().Maintain(mockAny, n).Return(failure) | ||
| 106 | - | fresh := &freshener{} | 106 | + | told := make(chan struct{}, 2) |
| 107 | + | fresh := mocks.NewRepoFreshener(t) | |||
| 108 | + | fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once() | |||
| 107 | m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh) | 109 | m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh) | ||
| 108 | m.Schedule(ctx, n) | 110 | m.Schedule(ctx, n) | ||
| 109 | - | waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told") | 111 | + | waitFor(t, func() bool { return len(told) == 1 }, "the reader to be told") |
| 110 | m.Close(ctx) | 112 | m.Close(ctx) | ||
| 111 | - | assert.Equal(t, []domain.RepoName{n}, fresh.seen()) | 113 | + | assert.Len(t, told, 1, "told exactly once") |
| 112 | } | 114 | } | ||
| 113 | } | 115 | } | ||
| 114 | 116 | ||||
M internal/app/mergerequests_integration_test.go +29 -4
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "time" | |
| 17 | 17 | ||
| 18 | 18 | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 19 | 20 | "github.com/stretchr/testify/require" | |
| 20 | 21 | ||
| 21 | 22 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| @@ -23,9 +24,28 @@ import ( | |||
| 23 | 24 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 24 | 25 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 25 | 26 | "bnema.dev/ermite/internal/app" | |
| 27 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |
| 26 | 28 | "bnema.dev/ermite/internal/domain" | |
| 27 | 29 | ) | |
| 28 | 30 | ||
| 31 | + | // queuedRepos records the repositories whose maintenance was queued. | |
| 32 | + | type queuedRepos struct { | |
| 33 | + | mu sync.Mutex | |
| 34 | + | rs []domain.RepoName | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (q *queuedRepos) add(_ context.Context, n domain.RepoName) { | |
| 38 | + | q.mu.Lock() | |
| 39 | + | defer q.mu.Unlock() | |
| 40 | + | q.rs = append(q.rs, n) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (q *queuedRepos) names() []domain.RepoName { | |
| 44 | + | q.mu.Lock() | |
| 45 | + | defer q.mu.Unlock() | |
| 46 | + | return append([]domain.RepoName(nil), q.rs...) | |
| 47 | + | } | |
| 48 | + | ||
| 29 | 49 | // mrRig runs the merge request use cases over the real adapters: a data | |
| 30 | 50 | // directory with ermite-kv and bare repositories, and a work tree to push | |
| 31 | 51 | // branches from. | |
| @@ -39,7 +59,8 @@ type mrRig struct { | |||
| 39 | 59 | store *kvstore.Store | |
| 40 | 60 | git *gitcli.Git | |
| 41 | 61 | bus *eventbus.Bus | |
| 42 | - | sched *scheduler | |
| 62 | + | sched *appmocks.MaintenanceScheduler | |
| 63 | + | queued *queuedRepos | |
| 43 | 64 | events <-chan domain.Event | |
| 44 | 65 | work string | |
| 45 | 66 | repo domain.RepoName | |
| @@ -74,8 +95,12 @@ func newMRRig(t *testing.T) *mrRig { | |||
| 74 | 95 | require.NoError(t, err) | |
| 75 | 96 | bus := eventbus.NewWithBuffer(1024) | |
| 76 | 97 | committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"} | |
| 98 | + | sched := appmocks.NewMaintenanceScheduler(t) | |
| 99 | + | queued := &queuedRepos{} | |
| 100 | + | sched.EXPECT().Schedule(mock.Anything, mock.Anything).Run(queued.add).Return().Maybe() | |
| 77 | 101 | r := &mrRig{ | |
| 78 | - | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, sched: &scheduler{}, events: bus.Subscribe(ctx), | |
| 102 | + | sched: sched, queued: queued, | |
| 103 | + | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx), | |
| 79 | 104 | repos: app.NewRepos(store, git, bus, fixedClock{}), | |
| 80 | 105 | key: app.NewSigningKey(signer, committer), | |
| 81 | 106 | work: t.TempDir(), | |
| @@ -1238,7 +1263,7 @@ func TestMergeRecordsAPush(t *testing.T) { | |||
| 1238 | 1263 | before, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1239 | 1264 | require.NoError(t, err) | |
| 1240 | 1265 | require.True(t, before.PushedAt.IsZero(), "no push recorded yet") | |
| 1241 | - | assert.Empty(t, r.sched.names) | |
| 1266 | + | assert.Empty(t, r.queued.names()) | |
| 1242 | 1267 | ||
| 1243 | 1268 | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head}) | |
| 1244 | 1269 | require.NoError(t, err) | |
| @@ -1246,7 +1271,7 @@ func TestMergeRecordsAPush(t *testing.T) { | |||
| 1246 | 1271 | after, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1247 | 1272 | require.NoError(t, err) | |
| 1248 | 1273 | assert.Equal(t, now, after.PushedAt) | |
| 1249 | - | assert.Equal(t, []domain.RepoName{r.repo}, r.sched.names, "maintenance queued") | |
| 1274 | + | assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued") | |
| 1250 | 1275 | }) | |
| 1251 | 1276 | } | |
| 1252 | 1277 | } | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "time" | 16 | "time" | ||
| 17 | 17 | ||||
| 18 | "github.com/stretchr/testify/assert" | 18 | "github.com/stretchr/testify/assert" | ||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 19 | "github.com/stretchr/testify/require" | 20 | "github.com/stretchr/testify/require" | ||
| 20 | 21 | ||||
| 21 | "bnema.dev/ermite/internal/adapters/eventbus" | 22 | "bnema.dev/ermite/internal/adapters/eventbus" | ||
| @@ -23,9 +24,28 @@ import ( | |||||
| 23 | "bnema.dev/ermite/internal/adapters/kvstore" | 24 | "bnema.dev/ermite/internal/adapters/kvstore" | ||
| 24 | "bnema.dev/ermite/internal/adapters/sshsig" | 25 | "bnema.dev/ermite/internal/adapters/sshsig" | ||
| 25 | "bnema.dev/ermite/internal/app" | 26 | "bnema.dev/ermite/internal/app" | ||
| 27 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |||
| 26 | "bnema.dev/ermite/internal/domain" | 28 | "bnema.dev/ermite/internal/domain" | ||
| 27 | ) | 29 | ) | ||
| 28 | 30 | ||||
| 31 | + | // queuedRepos records the repositories whose maintenance was queued. | |||
| 32 | + | type queuedRepos struct { | |||
| 33 | + | mu sync.Mutex | |||
| 34 | + | rs []domain.RepoName | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (q *queuedRepos) add(_ context.Context, n domain.RepoName) { | |||
| 38 | + | q.mu.Lock() | |||
| 39 | + | defer q.mu.Unlock() | |||
| 40 | + | q.rs = append(q.rs, n) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (q *queuedRepos) names() []domain.RepoName { | |||
| 44 | + | q.mu.Lock() | |||
| 45 | + | defer q.mu.Unlock() | |||
| 46 | + | return append([]domain.RepoName(nil), q.rs...) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 29 | // mrRig runs the merge request use cases over the real adapters: a data | 49 | // mrRig runs the merge request use cases over the real adapters: a data | ||
| 30 | // directory with ermite-kv and bare repositories, and a work tree to push | 50 | // directory with ermite-kv and bare repositories, and a work tree to push | ||
| 31 | // branches from. | 51 | // branches from. | ||
| @@ -39,7 +59,8 @@ type mrRig struct { | |||||
| 39 | store *kvstore.Store | 59 | store *kvstore.Store | ||
| 40 | git *gitcli.Git | 60 | git *gitcli.Git | ||
| 41 | bus *eventbus.Bus | 61 | bus *eventbus.Bus | ||
| 42 | - | sched *scheduler | 62 | + | sched *appmocks.MaintenanceScheduler |
| 63 | + | queued *queuedRepos | |||
| 43 | events <-chan domain.Event | 64 | events <-chan domain.Event | ||
| 44 | work string | 65 | work string | ||
| 45 | repo domain.RepoName | 66 | repo domain.RepoName | ||
| @@ -74,8 +95,12 @@ func newMRRig(t *testing.T) *mrRig { | |||||
| 74 | require.NoError(t, err) | 95 | require.NoError(t, err) | ||
| 75 | bus := eventbus.NewWithBuffer(1024) | 96 | bus := eventbus.NewWithBuffer(1024) | ||
| 76 | committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"} | 97 | committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"} | ||
| 98 | + | sched := appmocks.NewMaintenanceScheduler(t) | |||
| 99 | + | queued := &queuedRepos{} | |||
| 100 | + | sched.EXPECT().Schedule(mock.Anything, mock.Anything).Run(queued.add).Return().Maybe() | |||
| 77 | r := &mrRig{ | 101 | r := &mrRig{ | ||
| 78 | - | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, sched: &scheduler{}, events: bus.Subscribe(ctx), | 102 | + | sched: sched, queued: queued, |
| 103 | + | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx), | |||
| 79 | repos: app.NewRepos(store, git, bus, fixedClock{}), | 104 | repos: app.NewRepos(store, git, bus, fixedClock{}), | ||
| 80 | key: app.NewSigningKey(signer, committer), | 105 | key: app.NewSigningKey(signer, committer), | ||
| 81 | work: t.TempDir(), | 106 | work: t.TempDir(), | ||
| @@ -1238,7 +1263,7 @@ func TestMergeRecordsAPush(t *testing.T) { | |||||
| 1238 | before, err := r.repos.Get(r.ctx, root, r.repo) | 1263 | before, err := r.repos.Get(r.ctx, root, r.repo) | ||
| 1239 | require.NoError(t, err) | 1264 | require.NoError(t, err) | ||
| 1240 | require.True(t, before.PushedAt.IsZero(), "no push recorded yet") | 1265 | require.True(t, before.PushedAt.IsZero(), "no push recorded yet") | ||
| 1241 | - | assert.Empty(t, r.sched.names) | 1266 | + | assert.Empty(t, r.queued.names()) |
| 1242 | 1267 | ||||
| 1243 | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head}) | 1268 | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head}) | ||
| 1244 | require.NoError(t, err) | 1269 | require.NoError(t, err) | ||
| @@ -1246,7 +1271,7 @@ func TestMergeRecordsAPush(t *testing.T) { | |||||
| 1246 | after, err := r.repos.Get(r.ctx, root, r.repo) | 1271 | after, err := r.repos.Get(r.ctx, root, r.repo) | ||
| 1247 | require.NoError(t, err) | 1272 | require.NoError(t, err) | ||
| 1248 | assert.Equal(t, now, after.PushedAt) | 1273 | assert.Equal(t, now, after.PushedAt) | ||
| 1249 | - | assert.Equal(t, []domain.RepoName{r.repo}, r.sched.names, "maintenance queued") | 1274 | + | assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued") |
| 1250 | }) | 1275 | }) | ||
| 1251 | } | 1276 | } | ||
| 1252 | } | 1277 | } | ||
M internal/app/mergerequests_test.go +4 -1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "bnema.dev/ermite/internal/app" | |
| 15 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |
| 15 | 16 | "bnema.dev/ermite/internal/domain" | |
| 16 | 17 | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | 18 | ) | |
| @@ -36,9 +37,11 @@ func newMRUnit(t *testing.T) *mrUnit { | |||
| 36 | 37 | } | |
| 37 | 38 | clock := mocks.NewClock(t) | |
| 38 | 39 | clock.EXPECT().Now().Return(now).Maybe() | |
| 40 | + | sched := appmocks.NewMaintenanceScheduler(t) | |
| 41 | + | sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe() | |
| 39 | 42 | u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | |
| 40 | 43 | Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git, | |
| 41 | - | Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: &scheduler{}, | |
| 44 | + | Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched, | |
| 42 | 45 | }) | |
| 43 | 46 | return u | |
| 44 | 47 | } | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | "bnema.dev/ermite/internal/app" | 14 | "bnema.dev/ermite/internal/app" | ||
| 15 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |||
| 15 | "bnema.dev/ermite/internal/domain" | 16 | "bnema.dev/ermite/internal/domain" | ||
| 16 | "bnema.dev/ermite/internal/ports/mocks" | 17 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 17 | ) | 18 | ) | ||
| @@ -36,9 +37,11 @@ func newMRUnit(t *testing.T) *mrUnit { | |||||
| 36 | } | 37 | } | ||
| 37 | clock := mocks.NewClock(t) | 38 | clock := mocks.NewClock(t) | ||
| 38 | clock.EXPECT().Now().Return(now).Maybe() | 39 | clock.EXPECT().Now().Return(now).Maybe() | ||
| 40 | + | sched := appmocks.NewMaintenanceScheduler(t) | |||
| 41 | + | sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe() | |||
| 39 | u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | 42 | u.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | ||
| 40 | 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, | ||
| 41 | - | Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: &scheduler{}, | 44 | + | Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched, |
| 42 | }) | 45 | }) | ||
| 43 | return u | 46 | return u | ||
| 44 | } | 47 | } | ||
A internal/app/mocks/MaintenanceScheduler.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMaintenanceScheduler creates a new instance of MaintenanceScheduler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMaintenanceScheduler(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MaintenanceScheduler { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &MaintenanceScheduler{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // MaintenanceScheduler is an autogenerated mock type for the MaintenanceScheduler type | |
| 38 | + | type MaintenanceScheduler struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type MaintenanceScheduler_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *MaintenanceScheduler) EXPECT() *MaintenanceScheduler_Expecter { | |
| 47 | + | return &MaintenanceScheduler_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Schedule provides a mock function for the type MaintenanceScheduler | |
| 51 | + | func (_mock *MaintenanceScheduler) Schedule(ctx context.Context, name domain.RepoName) { | |
| 52 | + | _mock.Called(ctx, name) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // MaintenanceScheduler_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule' | |
| 57 | + | type MaintenanceScheduler_Schedule_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Schedule is a helper method to define mock.On call | |
| 62 | + | // - ctx context.Context | |
| 63 | + | // - name domain.RepoName | |
| 64 | + | func (_e *MaintenanceScheduler_Expecter) Schedule(ctx any, name any) *MaintenanceScheduler_Schedule_Call { | |
| 65 | + | return &MaintenanceScheduler_Schedule_Call{Call: _e.mock.On("Schedule", ctx, name)} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (_c *MaintenanceScheduler_Schedule_Call) Run(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |
| 69 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 70 | + | var arg0 context.Context | |
| 71 | + | if args[0] != nil { | |
| 72 | + | arg0 = args[0].(context.Context) | |
| 73 | + | } | |
| 74 | + | var arg1 domain.RepoName | |
| 75 | + | if args[1] != nil { | |
| 76 | + | arg1 = args[1].(domain.RepoName) | |
| 77 | + | } | |
| 78 | + | run( | |
| 79 | + | arg0, | |
| 80 | + | arg1, | |
| 81 | + | ) | |
| 82 | + | }) | |
| 83 | + | return _c | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (_c *MaintenanceScheduler_Schedule_Call) Return() *MaintenanceScheduler_Schedule_Call { | |
| 87 | + | _c.Call.Return() | |
| 88 | + | return _c | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *MaintenanceScheduler_Schedule_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |
| 92 | + | _c.Run(run) | |
| 93 | + | return _c | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewMaintenanceScheduler creates a new instance of MaintenanceScheduler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewMaintenanceScheduler(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *MaintenanceScheduler { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &MaintenanceScheduler{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // MaintenanceScheduler is an autogenerated mock type for the MaintenanceScheduler type | |||
| 38 | + | type MaintenanceScheduler struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type MaintenanceScheduler_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *MaintenanceScheduler) EXPECT() *MaintenanceScheduler_Expecter { | |||
| 47 | + | return &MaintenanceScheduler_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Schedule provides a mock function for the type MaintenanceScheduler | |||
| 51 | + | func (_mock *MaintenanceScheduler) Schedule(ctx context.Context, name domain.RepoName) { | |||
| 52 | + | _mock.Called(ctx, name) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // MaintenanceScheduler_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule' | |||
| 57 | + | type MaintenanceScheduler_Schedule_Call struct { | |||
| 58 | + | *mock.Call | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Schedule is a helper method to define mock.On call | |||
| 62 | + | // - ctx context.Context | |||
| 63 | + | // - name domain.RepoName | |||
| 64 | + | func (_e *MaintenanceScheduler_Expecter) Schedule(ctx any, name any) *MaintenanceScheduler_Schedule_Call { | |||
| 65 | + | return &MaintenanceScheduler_Schedule_Call{Call: _e.mock.On("Schedule", ctx, name)} | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (_c *MaintenanceScheduler_Schedule_Call) Run(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |||
| 69 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 70 | + | var arg0 context.Context | |||
| 71 | + | if args[0] != nil { | |||
| 72 | + | arg0 = args[0].(context.Context) | |||
| 73 | + | } | |||
| 74 | + | var arg1 domain.RepoName | |||
| 75 | + | if args[1] != nil { | |||
| 76 | + | arg1 = args[1].(domain.RepoName) | |||
| 77 | + | } | |||
| 78 | + | run( | |||
| 79 | + | arg0, | |||
| 80 | + | arg1, | |||
| 81 | + | ) | |||
| 82 | + | }) | |||
| 83 | + | return _c | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (_c *MaintenanceScheduler_Schedule_Call) Return() *MaintenanceScheduler_Schedule_Call { | |||
| 87 | + | _c.Call.Return() | |||
| 88 | + | return _c | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (_c *MaintenanceScheduler_Schedule_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |||
| 92 | + | _c.Run(run) | |||
| 93 | + | return _c | |||
| 94 | + | } | |||
A internal/app/mocks/Revalidator.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewRevalidator creates a new instance of Revalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewRevalidator(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *Revalidator { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &Revalidator{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Revalidator is an autogenerated mock type for the Revalidator type | |
| 38 | + | type Revalidator struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type Revalidator_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *Revalidator) EXPECT() *Revalidator_Expecter { | |
| 47 | + | return &Revalidator_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Revalidate provides a mock function for the type Revalidator | |
| 51 | + | func (_mock *Revalidator) Revalidate(ctx context.Context, p domain.Principal) error { | |
| 52 | + | ret := _mock.Called(ctx, p) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Revalidate") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, p) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Revalidator_Revalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revalidate' | |
| 68 | + | type Revalidator_Revalidate_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Revalidate is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - p domain.Principal | |
| 75 | + | func (_e *Revalidator_Expecter) Revalidate(ctx any, p any) *Revalidator_Revalidate_Call { | |
| 76 | + | return &Revalidator_Revalidate_Call{Call: _e.mock.On("Revalidate", ctx, p)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *Revalidator_Revalidate_Call) Run(run func(ctx context.Context, p domain.Principal)) *Revalidator_Revalidate_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | var arg1 domain.Principal | |
| 86 | + | if args[1] != nil { | |
| 87 | + | arg1 = args[1].(domain.Principal) | |
| 88 | + | } | |
| 89 | + | run( | |
| 90 | + | arg0, | |
| 91 | + | arg1, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *Revalidator_Revalidate_Call) Return(err error) *Revalidator_Revalidate_Call { | |
| 98 | + | _c.Call.Return(err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *Revalidator_Revalidate_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *Revalidator_Revalidate_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewRevalidator creates a new instance of Revalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewRevalidator(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *Revalidator { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &Revalidator{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Revalidator is an autogenerated mock type for the Revalidator type | |||
| 38 | + | type Revalidator struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type Revalidator_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *Revalidator) EXPECT() *Revalidator_Expecter { | |||
| 47 | + | return &Revalidator_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Revalidate provides a mock function for the type Revalidator | |||
| 51 | + | func (_mock *Revalidator) Revalidate(ctx context.Context, p domain.Principal) error { | |||
| 52 | + | ret := _mock.Called(ctx, p) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for Revalidate") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok { | |||
| 60 | + | r0 = returnFunc(ctx, p) | |||
| 61 | + | } else { | |||
| 62 | + | r0 = ret.Error(0) | |||
| 63 | + | } | |||
| 64 | + | return r0 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Revalidator_Revalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revalidate' | |||
| 68 | + | type Revalidator_Revalidate_Call struct { | |||
| 69 | + | *mock.Call | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Revalidate is a helper method to define mock.On call | |||
| 73 | + | // - ctx context.Context | |||
| 74 | + | // - p domain.Principal | |||
| 75 | + | func (_e *Revalidator_Expecter) Revalidate(ctx any, p any) *Revalidator_Revalidate_Call { | |||
| 76 | + | return &Revalidator_Revalidate_Call{Call: _e.mock.On("Revalidate", ctx, p)} | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (_c *Revalidator_Revalidate_Call) Run(run func(ctx context.Context, p domain.Principal)) *Revalidator_Revalidate_Call { | |||
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 81 | + | var arg0 context.Context | |||
| 82 | + | if args[0] != nil { | |||
| 83 | + | arg0 = args[0].(context.Context) | |||
| 84 | + | } | |||
| 85 | + | var arg1 domain.Principal | |||
| 86 | + | if args[1] != nil { | |||
| 87 | + | arg1 = args[1].(domain.Principal) | |||
| 88 | + | } | |||
| 89 | + | run( | |||
| 90 | + | arg0, | |||
| 91 | + | arg1, | |||
| 92 | + | ) | |||
| 93 | + | }) | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (_c *Revalidator_Revalidate_Call) Return(err error) *Revalidator_Revalidate_Call { | |||
| 98 | + | _c.Call.Return(err) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *Revalidator_Revalidate_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *Revalidator_Revalidate_Call { | |||
| 103 | + | _c.Call.Return(run) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
M internal/app/pushes_test.go +14 -36
| @@ -5,7 +5,6 @@ package app_test | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | - | "sync" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| @@ -14,47 +13,19 @@ import ( | |||
| 14 | 13 | "github.com/stretchr/testify/require" | |
| 15 | 14 | ||
| 16 | 15 | "bnema.dev/ermite/internal/app" | |
| 16 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |
| 17 | 17 | "bnema.dev/ermite/internal/domain" | |
| 18 | 18 | "bnema.dev/ermite/internal/ports" | |
| 19 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | - | type scheduler struct { | |
| 23 | - | mu sync.Mutex | |
| 24 | - | names []domain.RepoName | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | func (s *scheduler) Schedule(_ context.Context, n domain.RepoName) { | |
| 28 | - | s.mu.Lock() | |
| 29 | - | defer s.mu.Unlock() | |
| 30 | - | s.names = append(s.names, n) | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // freshener records the repositories it was told changed. | |
| 34 | - | type freshener struct { | |
| 35 | - | mu sync.Mutex | |
| 36 | - | names []domain.RepoName | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func (f *freshener) Refresh(_ context.Context, n domain.RepoName) { | |
| 40 | - | f.mu.Lock() | |
| 41 | - | defer f.mu.Unlock() | |
| 42 | - | f.names = append(f.names, n) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (f *freshener) seen() []domain.RepoName { | |
| 46 | - | f.mu.Lock() | |
| 47 | - | defer f.mu.Unlock() | |
| 48 | - | return append([]domain.RepoName(nil), f.names...) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | 22 | type pushFixture struct { | |
| 52 | 23 | meta *mocks.RepoMetaStore | |
| 53 | 24 | git *mocks.GitRepos | |
| 54 | 25 | bus *mocks.EventBus | |
| 55 | 26 | clock *mocks.Clock | |
| 56 | 27 | tokens *mocks.TokenStore | |
| 57 | - | sched *scheduler | |
| 28 | + | sched *appmocks.MaintenanceScheduler | |
| 58 | 29 | pushes *app.Pushes | |
| 59 | 30 | } | |
| 60 | 31 | ||
| @@ -66,7 +37,7 @@ func newPushFixture(t *testing.T) *pushFixture { | |||
| 66 | 37 | bus: mocks.NewEventBus(t), | |
| 67 | 38 | clock: mocks.NewClock(t), | |
| 68 | 39 | tokens: mocks.NewTokenStore(t), | |
| 69 | - | sched: &scheduler{}, | |
| 40 | + | sched: appmocks.NewMaintenanceScheduler(t), | |
| 70 | 41 | } | |
| 71 | 42 | f.clock.EXPECT().Now().Return(now).Maybe() | |
| 72 | 43 | repos := app.NewRepos(f.meta, f.git, f.bus, f.clock) | |
| @@ -231,6 +202,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 231 | 202 | ||
| 232 | 203 | t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) { | |
| 233 | 204 | f := newPushFixture(t) | |
| 205 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 234 | 206 | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | |
| 235 | 207 | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |
| 236 | 208 | f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil) | |
| @@ -238,23 +210,24 @@ func TestPushesRecord(t *testing.T) { | |||
| 238 | 210 | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return() | |
| 239 | 211 | ||
| 240 | 212 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| 241 | - | assert.Equal(t, []domain.RepoName{n}, f.sched.names) | |
| 242 | 213 | }) | |
| 243 | 214 | ||
| 244 | 215 | t.Run("tells the reader that the repository changed", func(t *testing.T) { | |
| 245 | 216 | f := newPushFixture(t) | |
| 246 | - | fresh := &freshener{} | |
| 217 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 218 | + | fresh := mocks.NewRepoFreshener(t) | |
| 219 | + | fresh.EXPECT().Refresh(ctx, n).Return().Once() | |
| 247 | 220 | f.pushes.WithFreshener(fresh) | |
| 248 | 221 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | |
| 249 | 222 | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |
| 250 | 223 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 251 | 224 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 252 | 225 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| 253 | - | assert.Equal(t, []domain.RepoName{n}, fresh.seen()) | |
| 254 | 226 | }) | |
| 255 | 227 | ||
| 256 | 228 | t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) { | |
| 257 | 229 | f := newPushFixture(t) | |
| 230 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 258 | 231 | repo := domain.Repo{Name: n} | |
| 259 | 232 | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | |
| 260 | 233 | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | |
| @@ -267,6 +240,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 267 | 240 | ||
| 268 | 241 | t.Run("a deleted repository is not an error", func(t *testing.T) { | |
| 269 | 242 | f := newPushFixture(t) | |
| 243 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 270 | 244 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 271 | 245 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 272 | 246 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| @@ -275,6 +249,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 275 | 249 | ||
| 276 | 250 | t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) { | |
| 277 | 251 | f := newPushFixture(t) | |
| 252 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 278 | 253 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 279 | 254 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound) | |
| 280 | 255 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| @@ -283,6 +258,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 283 | 258 | ||
| 284 | 259 | t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) { | |
| 285 | 260 | f := newPushFixture(t) | |
| 261 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 286 | 262 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 287 | 263 | f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil) | |
| 288 | 264 | f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound) | |
| @@ -292,13 +268,13 @@ func TestPushesRecord(t *testing.T) { | |||
| 292 | 268 | ||
| 293 | 269 | t.Run("every step runs when one fails", func(t *testing.T) { | |
| 294 | 270 | f := newPushFixture(t) | |
| 271 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 295 | 272 | boom := errors.New("boom") | |
| 296 | 273 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | |
| 297 | 274 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | |
| 298 | 275 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 299 | 276 | err := f.pushes.Record(ctx, p, n, ups) | |
| 300 | 277 | assert.ErrorIs(t, err, boom) | |
| 301 | - | assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled") | |
| 302 | 278 | }) | |
| 303 | 279 | } | |
| 304 | 280 | ||
| @@ -328,6 +304,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 328 | 304 | for _, tt := range tests { | |
| 329 | 305 | t.Run(tt.name, func(t *testing.T) { | |
| 330 | 306 | f := newPushFixture(t) | |
| 307 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 331 | 308 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |
| 332 | 309 | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | |
| 333 | 310 | if tt.want != "" { | |
| @@ -340,6 +317,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 340 | 317 | ||
| 341 | 318 | t.Run("set failure is reported", func(t *testing.T) { | |
| 342 | 319 | f := newPushFixture(t) | |
| 320 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 343 | 321 | boom := errors.New("boom") | |
| 344 | 322 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |
| 345 | 323 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | |
| @@ -5,7 +5,6 @@ package app_test | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | - | "sync" | |||
| 9 | "testing" | 8 | "testing" | ||
| 10 | "time" | 9 | "time" | ||
| 11 | 10 | ||||
| @@ -14,47 +13,19 @@ import ( | |||||
| 14 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 15 | 14 | ||||
| 16 | "bnema.dev/ermite/internal/app" | 15 | "bnema.dev/ermite/internal/app" | ||
| 16 | + | appmocks "bnema.dev/ermite/internal/app/mocks" | |||
| 17 | "bnema.dev/ermite/internal/domain" | 17 | "bnema.dev/ermite/internal/domain" | ||
| 18 | "bnema.dev/ermite/internal/ports" | 18 | "bnema.dev/ermite/internal/ports" | ||
| 19 | "bnema.dev/ermite/internal/ports/mocks" | 19 | "bnema.dev/ermite/internal/ports/mocks" | ||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | - | type scheduler struct { | |||
| 23 | - | mu sync.Mutex | |||
| 24 | - | names []domain.RepoName | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func (s *scheduler) Schedule(_ context.Context, n domain.RepoName) { | |||
| 28 | - | s.mu.Lock() | |||
| 29 | - | defer s.mu.Unlock() | |||
| 30 | - | s.names = append(s.names, n) | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | // freshener records the repositories it was told changed. | |||
| 34 | - | type freshener struct { | |||
| 35 | - | mu sync.Mutex | |||
| 36 | - | names []domain.RepoName | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | func (f *freshener) Refresh(_ context.Context, n domain.RepoName) { | |||
| 40 | - | f.mu.Lock() | |||
| 41 | - | defer f.mu.Unlock() | |||
| 42 | - | f.names = append(f.names, n) | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func (f *freshener) seen() []domain.RepoName { | |||
| 46 | - | f.mu.Lock() | |||
| 47 | - | defer f.mu.Unlock() | |||
| 48 | - | return append([]domain.RepoName(nil), f.names...) | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | type pushFixture struct { | 22 | type pushFixture struct { | ||
| 52 | meta *mocks.RepoMetaStore | 23 | meta *mocks.RepoMetaStore | ||
| 53 | git *mocks.GitRepos | 24 | git *mocks.GitRepos | ||
| 54 | bus *mocks.EventBus | 25 | bus *mocks.EventBus | ||
| 55 | clock *mocks.Clock | 26 | clock *mocks.Clock | ||
| 56 | tokens *mocks.TokenStore | 27 | tokens *mocks.TokenStore | ||
| 57 | - | sched *scheduler | 28 | + | sched *appmocks.MaintenanceScheduler |
| 58 | pushes *app.Pushes | 29 | pushes *app.Pushes | ||
| 59 | } | 30 | } | ||
| 60 | 31 | ||||
| @@ -66,7 +37,7 @@ func newPushFixture(t *testing.T) *pushFixture { | |||||
| 66 | bus: mocks.NewEventBus(t), | 37 | bus: mocks.NewEventBus(t), | ||
| 67 | clock: mocks.NewClock(t), | 38 | clock: mocks.NewClock(t), | ||
| 68 | tokens: mocks.NewTokenStore(t), | 39 | tokens: mocks.NewTokenStore(t), | ||
| 69 | - | sched: &scheduler{}, | 40 | + | sched: appmocks.NewMaintenanceScheduler(t), |
| 70 | } | 41 | } | ||
| 71 | f.clock.EXPECT().Now().Return(now).Maybe() | 42 | f.clock.EXPECT().Now().Return(now).Maybe() | ||
| 72 | repos := app.NewRepos(f.meta, f.git, f.bus, f.clock) | 43 | repos := app.NewRepos(f.meta, f.git, f.bus, f.clock) | ||
| @@ -231,6 +202,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 231 | 202 | ||||
| 232 | t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) { | 203 | t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) { | ||
| 233 | f := newPushFixture(t) | 204 | f := newPushFixture(t) | ||
| 205 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 234 | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | 206 | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | ||
| 235 | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | 207 | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | ||
| 236 | f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil) | 208 | f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil) | ||
| @@ -238,23 +210,24 @@ func TestPushesRecord(t *testing.T) { | |||||
| 238 | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return() | 210 | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return() | ||
| 239 | 211 | ||||
| 240 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | 212 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | ||
| 241 | - | assert.Equal(t, []domain.RepoName{n}, f.sched.names) | |||
| 242 | }) | 213 | }) | ||
| 243 | 214 | ||||
| 244 | t.Run("tells the reader that the repository changed", func(t *testing.T) { | 215 | t.Run("tells the reader that the repository changed", func(t *testing.T) { | ||
| 245 | f := newPushFixture(t) | 216 | f := newPushFixture(t) | ||
| 246 | - | fresh := &freshener{} | 217 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() |
| 218 | + | fresh := mocks.NewRepoFreshener(t) | |||
| 219 | + | fresh.EXPECT().Refresh(ctx, n).Return().Once() | |||
| 247 | f.pushes.WithFreshener(fresh) | 220 | f.pushes.WithFreshener(fresh) | ||
| 248 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | 221 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | ||
| 249 | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | 222 | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | ||
| 250 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | 223 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | ||
| 251 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 224 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| 252 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | 225 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | ||
| 253 | - | assert.Equal(t, []domain.RepoName{n}, fresh.seen()) | |||
| 254 | }) | 226 | }) | ||
| 255 | 227 | ||||
| 256 | t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) { | 228 | t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) { | ||
| 257 | f := newPushFixture(t) | 229 | f := newPushFixture(t) | ||
| 230 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 258 | repo := domain.Repo{Name: n} | 231 | repo := domain.Repo{Name: n} | ||
| 259 | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | 232 | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | ||
| 260 | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | 233 | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | ||
| @@ -267,6 +240,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 267 | 240 | ||||
| 268 | t.Run("a deleted repository is not an error", func(t *testing.T) { | 241 | t.Run("a deleted repository is not an error", func(t *testing.T) { | ||
| 269 | f := newPushFixture(t) | 242 | f := newPushFixture(t) | ||
| 243 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 270 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 244 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | ||
| 271 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | 245 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | ||
| 272 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 246 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| @@ -275,6 +249,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 275 | 249 | ||||
| 276 | t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) { | 250 | t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) { | ||
| 277 | f := newPushFixture(t) | 251 | f := newPushFixture(t) | ||
| 252 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 278 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 253 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | ||
| 279 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound) | 254 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound) | ||
| 280 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 255 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| @@ -283,6 +258,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 283 | 258 | ||||
| 284 | t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) { | 259 | t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) { | ||
| 285 | f := newPushFixture(t) | 260 | f := newPushFixture(t) | ||
| 261 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 286 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 262 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | ||
| 287 | f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil) | 263 | f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil) | ||
| 288 | f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound) | 264 | f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound) | ||
| @@ -292,13 +268,13 @@ func TestPushesRecord(t *testing.T) { | |||||
| 292 | 268 | ||||
| 293 | t.Run("every step runs when one fails", func(t *testing.T) { | 269 | t.Run("every step runs when one fails", func(t *testing.T) { | ||
| 294 | f := newPushFixture(t) | 270 | f := newPushFixture(t) | ||
| 271 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 295 | boom := errors.New("boom") | 272 | boom := errors.New("boom") | ||
| 296 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | 273 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | ||
| 297 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | 274 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | ||
| 298 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 275 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| 299 | err := f.pushes.Record(ctx, p, n, ups) | 276 | err := f.pushes.Record(ctx, p, n, ups) | ||
| 300 | assert.ErrorIs(t, err, boom) | 277 | assert.ErrorIs(t, err, boom) | ||
| 301 | - | assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled") | |||
| 302 | }) | 278 | }) | ||
| 303 | } | 279 | } | ||
| 304 | 280 | ||||
| @@ -328,6 +304,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||||
| 328 | for _, tt := range tests { | 304 | for _, tt := range tests { | ||
| 329 | t.Run(tt.name, func(t *testing.T) { | 305 | t.Run(tt.name, func(t *testing.T) { | ||
| 330 | f := newPushFixture(t) | 306 | f := newPushFixture(t) | ||
| 307 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 331 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | 308 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | ||
| 332 | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | 309 | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | ||
| 333 | if tt.want != "" { | 310 | if tt.want != "" { | ||
| @@ -340,6 +317,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||||
| 340 | 317 | ||||
| 341 | t.Run("set failure is reported", func(t *testing.T) { | 318 | t.Run("set failure is reported", func(t *testing.T) { | ||
| 342 | f := newPushFixture(t) | 319 | f := newPushFixture(t) | ||
| 320 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |||
| 343 | boom := errors.New("boom") | 321 | boom := errors.New("boom") | ||
| 344 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | 322 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | ||
| 345 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | 323 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | ||
M internal/app/sessions_test.go +30 -50
| @@ -6,70 +6,50 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/golang-jwt/jwt/v5" | |
| 13 | 14 | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 14 | 16 | "github.com/stretchr/testify/require" | |
| 15 | 17 | ||
| 16 | 18 | "bnema.dev/ermite/internal/app" | |
| 17 | 19 | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | 21 | ) | |
| 19 | 22 | ||
| 20 | - | // memSessions is an in-memory ports.SessionStore. | |
| 21 | - | type memSessions struct { | |
| 22 | - | mu sync.Mutex | |
| 23 | - | key []byte | |
| 24 | - | epoch uint64 | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil } | |
| 28 | - | func (m *memSessions) Epoch(context.Context) (uint64, error) { | |
| 29 | - | m.mu.Lock() | |
| 30 | - | defer m.mu.Unlock() | |
| 31 | - | return m.epoch, nil | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func (m *memSessions) BumpEpoch(context.Context) (uint64, error) { | |
| 35 | - | m.mu.Lock() | |
| 36 | - | defer m.mu.Unlock() | |
| 37 | - | m.epoch++ | |
| 38 | - | return m.epoch, nil | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // testClock is a clock the test moves. | |
| 42 | - | type testClock struct { | |
| 43 | - | mu sync.Mutex | |
| 44 | - | t time.Time | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | func (c *testClock) Now() time.Time { | |
| 48 | - | c.mu.Lock() | |
| 49 | - | defer c.mu.Unlock() | |
| 50 | - | return c.t | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func (c *testClock) advance(d time.Duration) { | |
| 54 | - | c.mu.Lock() | |
| 55 | - | defer c.mu.Unlock() | |
| 56 | - | c.t = c.t.Add(d) | |
| 57 | - | } | |
| 58 | - | ||
| 59 | 23 | type sessionFixture struct { | |
| 60 | - | store *memSessions | |
| 61 | - | clock *testClock | |
| 24 | + | store *mocks.SessionStore | |
| 25 | + | clock *mocks.Clock | |
| 26 | + | at atomic.Pointer[time.Time] // what the clock reads; the test moves it | |
| 62 | 27 | sessions *app.Sessions | |
| 63 | 28 | key []byte | |
| 64 | 29 | } | |
| 65 | 30 | ||
| 31 | + | // advance moves the clock forward by d. | |
| 32 | + | func (f *sessionFixture) advance(d time.Duration) { | |
| 33 | + | next := f.at.Load().Add(d) | |
| 34 | + | f.at.Store(&next) | |
| 35 | + | } | |
| 36 | + | ||
| 66 | 37 | func newSessionFixture(t *testing.T) *sessionFixture { | |
| 67 | 38 | t.Helper() | |
| 68 | 39 | f := &sessionFixture{ | |
| 69 | - | store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")}, | |
| 70 | - | clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}, | |
| 40 | + | store: mocks.NewSessionStore(t), | |
| 41 | + | clock: mocks.NewClock(t), | |
| 42 | + | key: []byte("0123456789abcdef0123456789abcdef"), | |
| 71 | 43 | } | |
| 72 | - | f.key = f.store.key | |
| 44 | + | start := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) | |
| 45 | + | f.at.Store(&start) | |
| 46 | + | f.clock.EXPECT().Now().RunAndReturn(func() time.Time { return *f.at.Load() }).Maybe() | |
| 47 | + | ||
| 48 | + | var epoch atomic.Uint64 | |
| 49 | + | f.store.EXPECT().Key(mock.Anything).Return(f.key, 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() | |
| 52 | + | ||
| 73 | 53 | s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example") | |
| 74 | 54 | require.NoError(t, err) | |
| 75 | 55 | f.sessions = s | |
| @@ -155,7 +135,7 @@ func TestLoginLinkLifecycle(t *testing.T) { | |||
| 155 | 135 | ||
| 156 | 136 | // Expired. | |
| 157 | 137 | nonce = f.nonce(t) | |
| 158 | - | f.clock.advance(app.LoginLinkTTL) | |
| 138 | + | f.advance(app.LoginLinkTTL) | |
| 159 | 139 | assert.False(t, f.sessions.LoginLinkValid(nonce)) | |
| 160 | 140 | _, _, err = f.sessions.Login(ctx, nonce) | |
| 161 | 141 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired") | |
| @@ -213,17 +193,17 @@ func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) { | |||
| 213 | 193 | // It stops working when the token would have. | |
| 214 | 194 | tok, _, err := login(tokenExpiring(2 * time.Hour)) | |
| 215 | 195 | require.NoError(t, err) | |
| 216 | - | f.clock.advance(2*time.Hour - time.Second) | |
| 196 | + | f.advance(2*time.Hour - time.Second) | |
| 217 | 197 | _, err = f.sessions.Verify(ctx, tok) | |
| 218 | 198 | assert.NoError(t, err) | |
| 219 | - | f.clock.advance(time.Second) | |
| 199 | + | f.advance(time.Second) | |
| 220 | 200 | _, err = f.sessions.Verify(ctx, tok) | |
| 221 | 201 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session") | |
| 222 | 202 | ||
| 223 | 203 | // A token that expires before the link is used opens nothing. | |
| 224 | 204 | link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second)) | |
| 225 | 205 | require.NoError(t, err) | |
| 226 | - | f.clock.advance(31 * time.Second) | |
| 206 | + | f.advance(31 * time.Second) | |
| 227 | 207 | _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/")) | |
| 228 | 208 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 229 | 209 | } | |
| @@ -259,7 +239,7 @@ func TestLoginLinksAreBounded(t *testing.T) { | |||
| 259 | 239 | _, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 260 | 240 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 261 | 241 | ||
| 262 | - | f.clock.advance(app.LoginLinkTTL) | |
| 242 | + | f.advance(app.LoginLinkTTL) | |
| 263 | 243 | _, err = f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 264 | 244 | assert.NoError(t, err, "expired links make room") | |
| 265 | 245 | } | |
| @@ -322,7 +302,7 @@ func TestSessionTokenVerification(t *testing.T) { | |||
| 322 | 302 | assert.NoError(t, err) | |
| 323 | 303 | ||
| 324 | 304 | // Expiry after the session lifetime; and the epoch ends every session. | |
| 325 | - | f.clock.advance(app.SessionTTL) | |
| 305 | + | f.advance(app.SessionTTL) | |
| 326 | 306 | _, err = f.sessions.Verify(ctx, token) | |
| 327 | 307 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h") | |
| 328 | 308 | } | |
| @@ -6,70 +6,50 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "sync/atomic" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| 12 | "github.com/golang-jwt/jwt/v5" | 13 | "github.com/golang-jwt/jwt/v5" | ||
| 13 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 14 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 15 | 17 | ||||
| 16 | "bnema.dev/ermite/internal/app" | 18 | "bnema.dev/ermite/internal/app" | ||
| 17 | "bnema.dev/ermite/internal/domain" | 19 | "bnema.dev/ermite/internal/domain" | ||
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 18 | ) | 21 | ) | ||
| 19 | 22 | ||||
| 20 | - | // memSessions is an in-memory ports.SessionStore. | |||
| 21 | - | type memSessions struct { | |||
| 22 | - | mu sync.Mutex | |||
| 23 | - | key []byte | |||
| 24 | - | epoch uint64 | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil } | |||
| 28 | - | func (m *memSessions) Epoch(context.Context) (uint64, error) { | |||
| 29 | - | m.mu.Lock() | |||
| 30 | - | defer m.mu.Unlock() | |||
| 31 | - | return m.epoch, nil | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | func (m *memSessions) BumpEpoch(context.Context) (uint64, error) { | |||
| 35 | - | m.mu.Lock() | |||
| 36 | - | defer m.mu.Unlock() | |||
| 37 | - | m.epoch++ | |||
| 38 | - | return m.epoch, nil | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // testClock is a clock the test moves. | |||
| 42 | - | type testClock struct { | |||
| 43 | - | mu sync.Mutex | |||
| 44 | - | t time.Time | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | func (c *testClock) Now() time.Time { | |||
| 48 | - | c.mu.Lock() | |||
| 49 | - | defer c.mu.Unlock() | |||
| 50 | - | return c.t | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | func (c *testClock) advance(d time.Duration) { | |||
| 54 | - | c.mu.Lock() | |||
| 55 | - | defer c.mu.Unlock() | |||
| 56 | - | c.t = c.t.Add(d) | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | type sessionFixture struct { | 23 | type sessionFixture struct { | ||
| 60 | - | store *memSessions | 24 | + | store *mocks.SessionStore |
| 61 | - | clock *testClock | 25 | + | clock *mocks.Clock |
| 26 | + | at atomic.Pointer[time.Time] // what the clock reads; the test moves it | |||
| 62 | sessions *app.Sessions | 27 | sessions *app.Sessions | ||
| 63 | key []byte | 28 | key []byte | ||
| 64 | } | 29 | } | ||
| 65 | 30 | ||||
| 31 | + | // advance moves the clock forward by d. | |||
| 32 | + | func (f *sessionFixture) advance(d time.Duration) { | |||
| 33 | + | next := f.at.Load().Add(d) | |||
| 34 | + | f.at.Store(&next) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 66 | func newSessionFixture(t *testing.T) *sessionFixture { | 37 | func newSessionFixture(t *testing.T) *sessionFixture { | ||
| 67 | t.Helper() | 38 | t.Helper() | ||
| 68 | f := &sessionFixture{ | 39 | f := &sessionFixture{ | ||
| 69 | - | store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")}, | 40 | + | store: mocks.NewSessionStore(t), |
| 70 | - | clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}, | 41 | + | clock: mocks.NewClock(t), |
| 42 | + | key: []byte("0123456789abcdef0123456789abcdef"), | |||
| 71 | } | 43 | } | ||
| 72 | - | f.key = f.store.key | 44 | + | start := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) |
| 45 | + | f.at.Store(&start) | |||
| 46 | + | f.clock.EXPECT().Now().RunAndReturn(func() time.Time { return *f.at.Load() }).Maybe() | |||
| 47 | + | ||||
| 48 | + | var epoch atomic.Uint64 | |||
| 49 | + | f.store.EXPECT().Key(mock.Anything).Return(f.key, 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() | |||
| 52 | + | ||||
| 73 | s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example") | 53 | s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example") | ||
| 74 | require.NoError(t, err) | 54 | require.NoError(t, err) | ||
| 75 | f.sessions = s | 55 | f.sessions = s | ||
| @@ -155,7 +135,7 @@ func TestLoginLinkLifecycle(t *testing.T) { | |||||
| 155 | 135 | ||||
| 156 | // Expired. | 136 | // Expired. | ||
| 157 | nonce = f.nonce(t) | 137 | nonce = f.nonce(t) | ||
| 158 | - | f.clock.advance(app.LoginLinkTTL) | 138 | + | f.advance(app.LoginLinkTTL) |
| 159 | assert.False(t, f.sessions.LoginLinkValid(nonce)) | 139 | assert.False(t, f.sessions.LoginLinkValid(nonce)) | ||
| 160 | _, _, err = f.sessions.Login(ctx, nonce) | 140 | _, _, err = f.sessions.Login(ctx, nonce) | ||
| 161 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired") | 141 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired") | ||
| @@ -213,17 +193,17 @@ func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) { | |||||
| 213 | // It stops working when the token would have. | 193 | // It stops working when the token would have. | ||
| 214 | tok, _, err := login(tokenExpiring(2 * time.Hour)) | 194 | tok, _, err := login(tokenExpiring(2 * time.Hour)) | ||
| 215 | require.NoError(t, err) | 195 | require.NoError(t, err) | ||
| 216 | - | f.clock.advance(2*time.Hour - time.Second) | 196 | + | f.advance(2*time.Hour - time.Second) |
| 217 | _, err = f.sessions.Verify(ctx, tok) | 197 | _, err = f.sessions.Verify(ctx, tok) | ||
| 218 | assert.NoError(t, err) | 198 | assert.NoError(t, err) | ||
| 219 | - | f.clock.advance(time.Second) | 199 | + | f.advance(time.Second) |
| 220 | _, err = f.sessions.Verify(ctx, tok) | 200 | _, err = f.sessions.Verify(ctx, tok) | ||
| 221 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session") | 201 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session") | ||
| 222 | 202 | ||||
| 223 | // A token that expires before the link is used opens nothing. | 203 | // A token that expires before the link is used opens nothing. | ||
| 224 | link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second)) | 204 | link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second)) | ||
| 225 | require.NoError(t, err) | 205 | require.NoError(t, err) | ||
| 226 | - | f.clock.advance(31 * time.Second) | 206 | + | f.advance(31 * time.Second) |
| 227 | _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/")) | 207 | _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/")) | ||
| 228 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | 208 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | ||
| 229 | } | 209 | } | ||
| @@ -259,7 +239,7 @@ func TestLoginLinksAreBounded(t *testing.T) { | |||||
| 259 | _, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | 239 | _, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | ||
| 260 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | 240 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | ||
| 261 | 241 | ||||
| 262 | - | f.clock.advance(app.LoginLinkTTL) | 242 | + | f.advance(app.LoginLinkTTL) |
| 263 | _, err = f.sessions.CreateLoginLink(ctx, domain.Root()) | 243 | _, err = f.sessions.CreateLoginLink(ctx, domain.Root()) | ||
| 264 | assert.NoError(t, err, "expired links make room") | 244 | assert.NoError(t, err, "expired links make room") | ||
| 265 | } | 245 | } | ||
| @@ -322,7 +302,7 @@ func TestSessionTokenVerification(t *testing.T) { | |||||
| 322 | assert.NoError(t, err) | 302 | assert.NoError(t, err) | ||
| 323 | 303 | ||||
| 324 | // Expiry after the session lifetime; and the epoch ends every session. | 304 | // Expiry after the session lifetime; and the epoch ends every session. | ||
| 325 | - | f.clock.advance(app.SessionTTL) | 305 | + | f.advance(app.SessionTTL) |
| 326 | _, err = f.sessions.Verify(ctx, token) | 306 | _, err = f.sessions.Verify(ctx, token) | ||
| 327 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h") | 307 | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h") | ||
| 328 | } | 308 | } | ||
M internal/app/tokens_test.go +14 -5
| @@ -52,11 +52,20 @@ func names(t *testing.T, ss ...string) []domain.RepoName { | |||
| 52 | 52 | // Labels that only contain a reserved word are fine. | |
| 53 | 53 | func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) { | |
| 54 | 54 | ctx := context.Background() | |
| 55 | - | for _, label := range []string{"owner2", "my owner", "roots", "ow ner", "root-ci"} { | |
| 56 | - | f := newTokenFixture(t) | |
| 57 | - | f.store.EXPECT().Create(ctx, mock.Anything).Return(nil) | |
| 58 | - | _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: label, Scopes: scopes(domain.ScopeRepoRead)}) | |
| 59 | - | assert.NoError(t, err, label) | |
| 55 | + | tests := []struct{ name, label string }{ | |
| 56 | + | {"digit suffix", "owner2"}, | |
| 57 | + | {"prefixed with a word", "my owner"}, | |
| 58 | + | {"plural", "roots"}, | |
| 59 | + | {"split word", "ow ner"}, | |
| 60 | + | {"hyphenated", "root-ci"}, | |
| 61 | + | } | |
| 62 | + | for _, tt := range tests { | |
| 63 | + | t.Run(tt.name, func(t *testing.T) { | |
| 64 | + | f := newTokenFixture(t) | |
| 65 | + | f.store.EXPECT().Create(ctx, mock.Anything).Return(nil) | |
| 66 | + | _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: tt.label, Scopes: scopes(domain.ScopeRepoRead)}) | |
| 67 | + | assert.NoError(t, err) | |
| 68 | + | }) | |
| 60 | 69 | } | |
| 61 | 70 | } | |
| 62 | 71 | ||
| @@ -52,11 +52,20 @@ func names(t *testing.T, ss ...string) []domain.RepoName { | |||||
| 52 | // Labels that only contain a reserved word are fine. | 52 | // Labels that only contain a reserved word are fine. | ||
| 53 | func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) { | 53 | func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) { | ||
| 54 | ctx := context.Background() | 54 | ctx := context.Background() | ||
| 55 | - | for _, label := range []string{"owner2", "my owner", "roots", "ow ner", "root-ci"} { | 55 | + | tests := []struct{ name, label string }{ |
| 56 | - | f := newTokenFixture(t) | 56 | + | {"digit suffix", "owner2"}, |
| 57 | - | f.store.EXPECT().Create(ctx, mock.Anything).Return(nil) | 57 | + | {"prefixed with a word", "my owner"}, |
| 58 | - | _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: label, Scopes: scopes(domain.ScopeRepoRead)}) | 58 | + | {"plural", "roots"}, |
| 59 | - | assert.NoError(t, err, label) | 59 | + | {"split word", "ow ner"}, |
| 60 | + | {"hyphenated", "root-ci"}, | |||
| 61 | + | } | |||
| 62 | + | for _, tt := range tests { | |||
| 63 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 64 | + | f := newTokenFixture(t) | |||
| 65 | + | f.store.EXPECT().Create(ctx, mock.Anything).Return(nil) | |||
| 66 | + | _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: tt.label, Scopes: scopes(domain.ScopeRepoRead)}) | |||
| 67 | + | assert.NoError(t, err) | |||
| 68 | + | }) | |||
| 60 | } | 69 | } | ||
| 61 | } | 70 | } | ||
| 62 | 71 | ||||