From 732270221d979805ae56e6c98bb0ee062a77e624 Mon Sep 17 00:00:00 2001 From: eding Date: Mon, 14 Sep 2026 23:46:07 +0200 Subject: [PATCH] feat: add scoped E2E fixture mutations --- cmd/e2e_mutate.go | 56 ++++++++ cmd/e2e_mutate_test.go | 46 ++++++ internal/e2emutate/mutate.go | 232 ++++++++++++++++++++++++++++++ internal/e2emutate/mutate_test.go | 138 ++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 cmd/e2e_mutate.go create mode 100644 cmd/e2e_mutate_test.go create mode 100644 internal/e2emutate/mutate.go create mode 100644 internal/e2emutate/mutate_test.go diff --git a/cmd/e2e_mutate.go b/cmd/e2e_mutate.go new file mode 100644 index 0000000..eb75000 --- /dev/null +++ b/cmd/e2e_mutate.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "context" + + "github.com/Pingu-Studio/MaidnCLI/internal/e2emutate" + "github.com/spf13/cobra" +) + +var ( + e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA string + e2eMutateTokenEnv, e2eMutateTokenFile string + e2eMutateOpenPR bool + e2eMutator = e2emutate.DefaultMutator +) + +var e2eMutateCmd = &cobra.Command{ + Use: "e2e-mutate", + Short: "Update a Forgejo E2E fixture branch and optionally open its PR.", + RunE: runE2EMutate, +} + +func init() { + rootCmd.AddCommand(e2eMutateCmd) + e2eMutateCmd.Flags().StringVar(&e2eMutateForgejoURL, "forgejo-url", "", "Credential-free Forgejo base URL") + e2eMutateCmd.Flags().StringVar(&e2eMutateOwner, "owner", "", "Fixture Forgejo owner (must be test-org-2)") + e2eMutateCmd.Flags().StringVar(&e2eMutateRepo, "repo", "", "Fixture Forgejo repository (must start maidn-e2e-)") + e2eMutateCmd.Flags().StringVar(&e2eMutateBranch, "branch", "", "Fixture Forgejo branch (must start maidn-e2e-)") + e2eMutateCmd.Flags().StringVar(&e2eMutateSHA, "sha", "", "Full Git object ID for the fixture branch") + e2eMutateCmd.Flags().StringVar(&e2eMutateTokenEnv, "token-env", "", "Environment variable containing the Forgejo token") + e2eMutateCmd.Flags().StringVar(&e2eMutateTokenFile, "token-file", "", "Path to a file containing the Forgejo token") + e2eMutateCmd.Flags().BoolVar(&e2eMutateOpenPR, "open-pr", false, "Open one pull request from the fixture branch to main") + for _, name := range []string{"forgejo-url", "owner", "repo", "branch", "sha"} { + _ = e2eMutateCmd.MarkFlagRequired(name) + } +} + +func runE2EMutate(cmd *cobra.Command, _ []string) error { + token, err := e2emutate.ReadToken(e2eMutateTokenEnv, e2eMutateTokenFile) + if err != nil { + return err + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + return e2eMutator().Run(ctx, e2emutate.Options{ + ForgejoURL: e2eMutateForgejoURL, + Owner: e2eMutateOwner, + Repo: e2eMutateRepo, + Branch: e2eMutateBranch, + SHA: e2eMutateSHA, + Token: token, + OpenPR: e2eMutateOpenPR, + }) +} diff --git a/cmd/e2e_mutate_test.go b/cmd/e2e_mutate_test.go new file mode 100644 index 0000000..433f7de --- /dev/null +++ b/cmd/e2e_mutate_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/e2emutate" + "github.com/spf13/cobra" +) + +type commandMutationHTTP struct { + calls []*http.Request +} + +func (f *commandMutationHTTP) Do(request *http.Request) (*http.Response, error) { + f.calls = append(f.calls, request) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil +} + +func TestE2EMutateCommandWiringUsesOnlyTokenReferences(t *testing.T) { + originalMutator := e2eMutator + originalURL, originalOwner, originalRepo, originalBranch, originalSHA := e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA + originalEnv, originalFile, originalOpenPR := e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR + t.Cleanup(func() { + e2eMutator = originalMutator + e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA = originalURL, originalOwner, originalRepo, originalBranch, originalSHA + e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = originalEnv, originalFile, originalOpenPR + }) + + command, _, err := rootCmd.Find([]string{"e2e-mutate"}) + if err != nil || command != e2eMutateCmd || command.Flags().Lookup("token") != nil { + t.Fatalf("e2e-mutate command or token flags are not wired safely: %v", err) + } + fake := &commandMutationHTTP{} + e2eMutator = func() e2emutate.Mutator { return e2emutate.Mutator{HTTP: fake} } + e2eMutateForgejoURL, e2eMutateOwner = "https://git.example.test", "test-org-2" + e2eMutateRepo, e2eMutateBranch = "maidn-e2e-repo", "maidn-e2e-branch" + e2eMutateSHA = "0123456789abcdef0123456789abcdef01234567" + e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = "E2E_MUTATE_TEST_TOKEN", "", false + t.Setenv(e2eMutateTokenEnv, "test-token") + if err := runE2EMutate(&cobra.Command{}, nil); err != nil || len(fake.calls) != 1 || fake.calls[0].Method != http.MethodPatch { + t.Fatalf("runE2EMutate() = %v, calls = %#v", err, fake.calls) + } +} diff --git a/internal/e2emutate/mutate.go b/internal/e2emutate/mutate.go new file mode 100644 index 0000000..a07f1f9 --- /dev/null +++ b/internal/e2emutate/mutate.go @@ -0,0 +1,232 @@ +// Package e2emutate contains narrowly scoped Forgejo fixture mutations. +package e2emutate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +const ( + fixtureOwner = "test-org-2" + fixturePrefix = "maidn-e2e-" + maxBodyBytes = 1 << 20 +) + +var ( + forgejoName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + gitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$`) +) + +// Options identifies the only Forgejo resources this command may mutate. +type Options struct { + ForgejoURL string + Owner string + Repo string + Branch string + SHA string + Token string + OpenPR bool +} + +// HTTPDoer is the Forgejo API boundary and can be faked in tests. +type HTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +type Mutator struct { + HTTP HTTPDoer +} + +func ReadToken(environment, path string) (string, error) { + if environment != "" && path != "" { + return "", errors.New("use only one Forgejo token reference") + } + var token string + if environment != "" { + var present bool + token, present = os.LookupEnv(environment) + if !present { + return "", errors.New("Forgejo token environment variable is not set") + } + } else if path != "" { + data, err := os.ReadFile(path) + if err != nil { + return "", errors.New("read Forgejo token file") + } + token = string(data) + } else { + return "", errors.New("a Forgejo token environment or file reference is required") + } + token = strings.TrimSpace(token) + if token == "" || strings.ContainsAny(token, "\r\n") { + return "", errors.New("Forgejo token reference is empty or invalid") + } + return token, nil +} + +func (o Options) Validate() error { + if err := validForgejoURL(o.ForgejoURL); err != nil { + return err + } + if o.Owner != fixtureOwner { + return errors.New("Forgejo mutation owner must be test-org-2") + } + for _, value := range []string{o.Repo, o.Branch} { + if !fixtureName(value) { + return errors.New("Forgejo mutation repository and branch must be maidn-e2e fixture identifiers") + } + } + if !gitSHA.MatchString(o.SHA) { + return errors.New("Forgejo mutation SHA must be a full Git object ID") + } + if o.Token == "" || strings.ContainsAny(o.Token, "\r\n") { + return errors.New("Forgejo token is required") + } + return nil +} + +func fixtureName(value string) bool { + return strings.HasPrefix(value, fixturePrefix) && forgejoName.MatchString(value) && !strings.Contains(value, "..") && !strings.HasSuffix(value, ".") && !strings.HasSuffix(value, ".lock") +} + +func validForgejoURL(value string) error { + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("Forgejo URL must be a credential-free HTTP(S) URL without query or fragment") + } + return nil +} + +// Run updates one fixture branch and may ensure its single PR to main. +func (m Mutator) Run(ctx context.Context, options Options) error { + if err := options.Validate(); err != nil { + return err + } + if m.HTTP == nil { + return errors.New("Forgejo mutation API is required") + } + if err := m.updateRef(ctx, options); err != nil { + return err + } + if options.OpenPR { + return m.ensurePR(ctx, options) + } + return nil +} + +func (m Mutator) updateRef(ctx context.Context, options Options) error { + endpoint := options.apiURL("git", "refs", "heads", options.Branch) + status, err := m.request(ctx, options, http.MethodPatch, endpoint, struct { + SHA string `json:"sha"` + Force bool `json:"force"` + }{SHA: options.SHA}) + if err != nil { + return err + } + if status >= http.StatusOK && status < http.StatusMultipleChoices { + return nil + } + if status != http.StatusNotFound { + return errors.New("Forgejo branch update failed") + } + status, err = m.request(ctx, options, http.MethodPost, options.apiURL("git", "refs"), struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + }{Ref: "refs/heads/" + options.Branch, SHA: options.SHA}) + if err != nil { + return err + } + if status < http.StatusOK || status >= http.StatusMultipleChoices { + return errors.New("Forgejo branch creation failed") + } + return nil +} + +func (m Mutator) ensurePR(ctx context.Context, options Options) error { + endpoint, _ := url.Parse(options.apiURL("pulls")) + endpoint.RawQuery = url.Values{"state": {"open"}, "head": {options.Owner + ":" + options.Branch}}.Encode() + request, err := m.newRequest(ctx, options, http.MethodGet, endpoint.String(), nil) + if err != nil { + return err + } + response, err := m.HTTP.Do(request) + if err != nil { + return errors.New("Forgejo mutation request failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return errors.New("Forgejo pull request lookup failed") + } + var pulls []json.RawMessage + if json.NewDecoder(io.LimitReader(response.Body, maxBodyBytes)).Decode(&pulls) != nil { + return errors.New("Forgejo pull request lookup returned invalid data") + } + if len(pulls) > 1 { + return errors.New("multiple open Forgejo pull requests exist for the fixture branch") + } + if len(pulls) == 1 { + return nil + } + status, err := m.request(ctx, options, http.MethodPost, options.apiURL("pulls"), struct { + Title string `json:"title"` + Head string `json:"head"` + Base string `json:"base"` + }{Title: "maidn e2e mutation", Head: options.Branch, Base: "main"}) + if err != nil { + return err + } + if status < http.StatusOK || status >= http.StatusMultipleChoices { + return errors.New("Forgejo pull request creation failed") + } + return nil +} + +func (m Mutator) request(ctx context.Context, options Options, method, endpoint string, body any) (int, error) { + request, err := m.newRequest(ctx, options, method, endpoint, body) + if err != nil { + return 0, err + } + response, err := m.HTTP.Do(request) + if err != nil { + return 0, errors.New("Forgejo mutation request failed") + } + defer response.Body.Close() + return response.StatusCode, nil +} + +func (m Mutator) newRequest(ctx context.Context, options Options, method, endpoint string, body any) (*http.Request, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, errors.New("encode Forgejo mutation request") + } + reader = bytes.NewReader(data) + } + request, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return nil, errors.New("create Forgejo mutation request") + } + request.Header.Set("Authorization", "token "+options.Token) + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + return request, nil +} + +func (o Options) apiURL(parts ...string) string { + return strings.TrimRight(o.ForgejoURL, "/") + "/api/v1/repos/" + o.Owner + "/" + o.Repo + "/" + strings.Join(parts, "/") +} + +func DefaultMutator() Mutator { + return Mutator{HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} diff --git a/internal/e2emutate/mutate_test.go b/internal/e2emutate/mutate_test.go new file mode 100644 index 0000000..666d3d2 --- /dev/null +++ b/internal/e2emutate/mutate_test.go @@ -0,0 +1,138 @@ +package e2emutate + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type fakeHTTP struct { + do func(*http.Request) (*http.Response, error) + calls []*http.Request +} + +func (f *fakeHTTP) Do(request *http.Request) (*http.Response, error) { + f.calls = append(f.calls, request) + return f.do(request) +} + +func mutationResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} + +func testOptions() Options { + return Options{ + ForgejoURL: "https://git.example.test", + Owner: "test-org-2", + Repo: "maidn-e2e-repo", + Branch: "maidn-e2e-branch", + SHA: "0123456789abcdef0123456789abcdef01234567", + Token: "test-token", + } +} + +func TestOptionsValidateAcceptsOnlyFixtureTargets(t *testing.T) { + if err := testOptions().Validate(); err != nil { + t.Fatalf("valid fixture options: %v", err) + } + for _, update := range []func(*Options){ + func(o *Options) { o.Owner = "other-org" }, + func(o *Options) { o.Repo = "production" }, + func(o *Options) { o.Branch = "feature/maidn-e2e-branch" }, + func(o *Options) { o.Branch = "maidn-e2e-branch..unsafe" }, + func(o *Options) { o.Repo = "maidn-e2e-repo.lock" }, + } { + options := testOptions() + update(&options) + if err := options.Validate(); err == nil { + t.Fatalf("Validate accepted %#v", options) + } + } +} + +func TestMutatorUpdatesFixtureRefAndEnsuresOnePR(t *testing.T) { + fake := &fakeHTTP{do: func(request *http.Request) (*http.Response, error) { + if request.Header.Get("Authorization") != "token test-token" { + t.Fatal("mutation request did not authenticate at the API boundary") + } + switch { + case request.Method == http.MethodPatch && request.URL.Path == "/api/v1/repos/test-org-2/maidn-e2e-repo/git/refs/heads/maidn-e2e-branch": + var body struct { + SHA string `json:"sha"` + Force bool `json:"force"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.SHA != testOptions().SHA || body.Force { + t.Fatalf("unexpected branch update: %#v, %v", body, err) + } + return mutationResponse(http.StatusOK, ""), nil + case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/test-org-2/maidn-e2e-repo/pulls": + if request.URL.Query().Get("head") != "test-org-2:maidn-e2e-branch" || request.URL.Query().Get("state") != "open" { + t.Fatal("pull request lookup did not target the fixture branch") + } + return mutationResponse(http.StatusOK, "[]"), nil + case request.Method == http.MethodPost && request.URL.Path == "/api/v1/repos/test-org-2/maidn-e2e-repo/pulls": + var body struct { + Head string `json:"head"` + Base string `json:"base"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn-e2e-branch" || body.Base != "main" { + t.Fatalf("unexpected pull request creation: %#v, %v", body, err) + } + return mutationResponse(http.StatusCreated, ""), nil + default: + t.Fatalf("unexpected Forgejo request: %s %s", request.Method, request.URL) + return nil, nil + } + }} + options := testOptions() + options.OpenPR = true + if err := (Mutator{HTTP: fake}).Run(context.Background(), options); err != nil || len(fake.calls) != 3 { + t.Fatalf("Run() = %v, calls = %d", err, len(fake.calls)) + } +} + +func TestMutatorCreatesFixtureRefWhenAbsent(t *testing.T) { + fake := &fakeHTTP{do: func(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodPatch: + return mutationResponse(http.StatusNotFound, ""), nil + case http.MethodPost: + if request.URL.Path != "/api/v1/repos/test-org-2/maidn-e2e-repo/git/refs" { + t.Fatalf("branch creation targeted %q", request.URL.Path) + } + return mutationResponse(http.StatusCreated, ""), nil + default: + t.Fatalf("unexpected Forgejo request: %s %s", request.Method, request.URL) + return nil, nil + } + }} + if err := (Mutator{HTTP: fake}).Run(context.Background(), testOptions()); err != nil || len(fake.calls) != 2 { + t.Fatalf("Run() = %v, calls = %d", err, len(fake.calls)) + } +} + +func TestMutatorRejectsUnsafeTargetsBeforeAPIAndDoesNotExposeToken(t *testing.T) { + fake := &fakeHTTP{do: func(*http.Request) (*http.Response, error) { + t.Fatal("unsafe target reached the Forgejo API") + return nil, nil + }} + options := testOptions() + options.Owner = "production" + options.Token = "secret-token" + err := (Mutator{HTTP: fake}).Run(context.Background(), options) + if err == nil || strings.Contains(err.Error(), options.Token) { + t.Fatalf("Run() returned unsafe error: %v", err) + } + + fake.do = func(*http.Request) (*http.Response, error) { return nil, errors.New(options.Token) } + options = testOptions() + options.Token = "secret-token" + err = (Mutator{HTTP: fake}).Run(context.Background(), options) + if err == nil || strings.Contains(err.Error(), options.Token) { + t.Fatalf("Run() exposed token: %v", err) + } +}