Merge pull request 'feat: add scoped E2E fixture mutations' (#51) from feat/e2e-mutation into main

Reviewed-on: #51
This commit is contained in:
eding 2026-09-14 23:53:30 +02:00
commit 8e546e1e50
4 changed files with 472 additions and 0 deletions

56
cmd/e2e_mutate.go Normal file
View file

@ -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,
})
}

46
cmd/e2e_mutate_test.go Normal file
View file

@ -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)
}
}

View file

@ -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 }}}
}

View file

@ -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)
}
}