// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package app import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "maps" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" "gitea.dev/tea/internal/auth" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/git" "gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/transport" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testDeps returns hermetic dependencies: no environment, no system keyring and a Git runner without results. func testDeps(t *testing.T) (Dependencies, *bytes.Buffer, *bytes.Buffer) { t.Helper() out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{} store := settings.New(t.TempDir()) return Dependencies{ Git: &fakeGit{}, Executable: os.Executable, Settings: store, Credentials: &auth.Store{Dir: store.Dir, Keyring: testNoKeyring{}}, Streams: terminal.Streams{In: &bytes.Buffer{}, Out: out, ErrOut: diagnostics}, Getenv: envFunc(nil), Client: transport.New, }, out, diagnostics } // serverDeps returns testDeps authenticated against serverURL through the environment. func serverDeps(t *testing.T, serverURL string) (Dependencies, *bytes.Buffer, *bytes.Buffer) { t.Helper() deps, out, diagnostics := testDeps(t) deps.Getenv = serverEnv(serverURL, nil) return deps, out, diagnostics } func envFunc(vars map[string]string) func(string) string { return func(key string) string { return vars[key] } } // serverEnv authenticates against serverURL through the environment, plus extra variables. func serverEnv(serverURL string, extra map[string]string) func(string) string { vars := map[string]string{"TEA_HOST": serverURL, "GITEA_TOKEN": "token"} maps.Copy(vars, extra) return envFunc(vars) } // requireUsageBeforeRequests runs args against a fully configured target and asserts // that the command fails with a usage error before any request is sent. func requireUsageBeforeRequests(t *testing.T, want string, args ...string) { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusTeapot) })) defer server.Close() deps, out, diagnostics := testDeps(t) deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"}) err := Execute(context.Background(), deps, args) require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err) require.ErrorContains(t, err, want) require.Empty(t, out.String()) require.Empty(t, diagnostics.String()) } // requirePayload checks that a JSON request body sends every wanted field with its value and // leaves every other field null or empty: SDK option structs send unset fields that way. func requirePayload(t *testing.T, want string, body io.Reader) { t.Helper() var wanted, sent map[string]any require.NoError(t, json.Unmarshal([]byte(want), &wanted)) require.NoError(t, json.NewDecoder(body).Decode(&sent)) for key, value := range wanted { require.Contains(t, sent, key) require.Equal(t, value, sent[key], key) } for key, value := range sent { if _, ok := wanted[key]; !ok { require.Empty(t, value, key) } } } // newAuthenticatedServer supplies the common identity endpoint for workflow fixtures. // Tests of identity failures use an explicit httptest server instead. func newAuthenticatedServer(handler http.Handler) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/v1/user") { if r.Header.Get("Authorization") == "" { w.WriteHeader(http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"login":"alice"}`)) return } handler.ServeHTTP(w, r) })) } type testNoKeyring struct{} var errNoTestKeyring = errors.New("system keyring is unavailable in tests") func (testNoKeyring) Get(string, string) (string, error) { return "", errNoTestKeyring } func (testNoKeyring) Set(string, string, string) error { return errNoTestKeyring } func (testNoKeyring) Delete(string, string) error { return errNoTestKeyring } type fakeGit struct { results map[string]string calls []string dirs []string prompt bool } func (f *fakeGit) Output(_ context.Context, dir string, prompt bool, args ...string) (string, error) { key := strings.Join(args, " ") f.calls = append(f.calls, key) f.dirs = append(f.dirs, dir) f.prompt = prompt value, ok := f.results[key] if !ok { return "", errors.New("missing fake Git result") } return value, nil } // scriptedPrompter answers prompts with canned values and records every prompt it receives. // Input answers from inputs by field, then from input; a prompt without an answer fails. type scriptedPrompter struct { inputs map[string]string input, secret string accept bool asked []string secrets []string confirmations []string } func (p *scriptedPrompter) Input(_ context.Context, field string) (string, error) { p.asked = append(p.asked, field) if value, ok := p.inputs[field]; ok { return value, nil } if p.input == "" { return "", errors.New("unexpected input prompt " + field) } return p.input, nil } func (p *scriptedPrompter) SecretInput(_ context.Context, title string) (string, error) { p.secrets = append(p.secrets, title) if p.secret == "" { return "", errors.New("unexpected secret prompt " + title) } return p.secret, nil } func (p *scriptedPrompter) Confirm(_ context.Context, description string) (bool, error) { p.confirmations = append(p.confirmations, description) return p.accept, nil } // testManager returns the account manager for the stores in deps. func testManager(t *testing.T, deps Dependencies) auth.Manager { t.Helper() manager, err := auth.NewManager(deps.Settings, deps.Credentials) require.NoError(t, err) return manager } // configureHost stores host as the settings of the instance at url. func configureHost(t *testing.T, deps Dependencies, url string, host settings.Host) { t.Helper() require.NoError(t, deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error { s.Hosts.Hosts[url] = host return nil })) } // tokenAccounts returns one token account per user, kept in store. func tokenAccounts(store string, users ...string) map[string]settings.Account { accounts := map[string]settings.Account{} for _, user := range users { accounts[user] = settings.Account{AuthMethod: settings.AuthToken, CredentialStore: store} } return accounts } // configureAccounts configures token accounts at url with active selected, // and saves the file credential "-token" for each of them. func configureAccounts(t *testing.T, deps Dependencies, url, active string, users ...string) { t.Helper() configureHost(t, deps, url, settings.Host{User: active, Users: tokenAccounts(settings.StoreFile, users...)}) for _, user := range users { require.NoError(t, deps.Credentials.Save(url, user, settings.StoreFile, auth.Credential{AccessToken: user + "-token"})) } } // credentialCall returns the fakeGit key of a Git call authenticated through the tea credential helper. func credentialCall(t *testing.T, deps Dependencies, host, user string, args ...string) string { t.Helper() executable, err := deps.Executable() require.NoError(t, err) credentialArgs, err := git.CredentialArguments(host, executable, user, deps.Settings.Dir) require.NoError(t, err) return strings.Join(append(credentialArgs, args...), " ") } type draftEditor struct { draft terminal.Draft err error command string initial string } func (e *draftEditor) Edit(_ context.Context, command, initial string) (terminal.Draft, error) { e.command = command e.initial = initial return e.draft, e.err } func (*draftEditor) EditPath(context.Context, string, string) error { return errors.New("unexpected file edit") } // newDraftEditor returns an editor whose session saved body to a new draft file, and that file's path. func newDraftEditor(t *testing.T, body string) (*draftEditor, string) { t.Helper() path := filepath.Join(t.TempDir(), "draft.md") require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) return &draftEditor{draft: terminal.Draft{Path: path, Body: body}}, path } // requireDraft asserts whether the draft file at path was kept. func requireDraft(t *testing.T, path string, kept bool) { t.Helper() _, err := os.Stat(path) if kept { require.NoError(t, err) } else { require.ErrorIs(t, err, os.ErrNotExist) } } // selectablesHandler serves the alice/project repository with the given push permission, its issue template, // assignees, open milestones and labels, as offered by interactive forms, and passes other requests to next. func selectablesHandler(t *testing.T, push bool, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { firstPage := func(items string) { if r.URL.Query().Get("page") != "1" { items = `[]` } _, _ = w.Write([]byte(items)) } switch r.URL.Path { case "/api/v1/repos/alice/project": if r.Method != http.MethodGet { next(w, r) return } _, _ = fmt.Fprintf(w, `{"full_name":"alice/project","default_branch":"main","permissions":{"push":%t,"pull":true}}`, push) case "/api/v1/repos/alice/project/issue_templates": _, _ = w.Write([]byte(`[{"name":"Bug","about":"Report","title":"bug: ","content":"## Steps"}]`)) case "/api/v1/repos/alice/project/assignees": _, _ = w.Write([]byte(`[{"login":"alice"},{"login":"bob"}]`)) case "/api/v1/repos/alice/project/milestones": assert.Equal(t, "open", r.URL.Query().Get("state")) firstPage(`[{"id":4,"title":"Release","state":"open"}]`) case "/api/v1/repos/alice/project/labels": firstPage(`[{"id":2,"name":"bug","color":"ff0000"},{"id":3,"name":"docs","color":"00ff00"}]`) default: next(w, r) } } } var ansiSequence = regexp.MustCompile("\x1b\\[[0-9;]*m") // withoutANSI drops SGR color sequences, such as those in pretty JSON on a terminal. func withoutANSI(s string) string { return ansiSequence.ReplaceAllString(s, "") }