// Package integration exercises an explicitly supplied official Gitea binary. // Everything runs on loopback with disposable users, SQLite and captured SMTP. package integration import ( "bufio" "bytes" "context" "encoding/base64" "fmt" "io" "mime" "mime/multipart" "mime/quotedprintable" "net" "net/http" "net/http/cookiejar" "net/mail" "net/textproto" "net/url" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "time" "gitea-mail-templates/tools/builder" "gitea-mail-templates/tools/upstream" ) type safeLog struct { mu sync.Mutex buf bytes.Buffer } func (l *safeLog) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() return l.buf.Write(p) } func (l *safeLog) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.buf.String() } func smtpCapture(t *testing.T) (int, <-chan []byte) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { listener.Close() }) mailbox := make(chan []byte, 4) go func() { for { conn, err := listener.Accept() if err != nil { return } go func() { defer conn.Close() conn.SetDeadline(time.Now().Add(time.Minute)) r := textproto.NewReader(bufio.NewReader(conn)) fmt.Fprint(conn, "220 localhost SMTP capture\r\n") for { line, err := r.ReadLine() if err != nil { return } fields := strings.Fields(line) if len(fields) == 0 { continue } command := strings.ToUpper(fields[0]) switch command { case "EHLO", "HELO": fmt.Fprint(conn, "250 localhost\r\n") case "DATA": fmt.Fprint(conn, "354 Send mail\r\n") data, err := r.ReadDotBytes() if err != nil { return } mailbox <- data fmt.Fprint(conn, "250 Captured\r\n") case "QUIT": fmt.Fprint(conn, "221 Goodbye\r\n") return default: fmt.Fprint(conn, "250 OK\r\n") } } }() } }() return listener.Addr().(*net.TCPAddr).Port, mailbox } func TestGiteaMailSmoke(t *testing.T) { binary := os.Getenv("GITEA_SMOKE_BINARY") if binary == "" { t.Skip("set GITEA_SMOKE_BINARY to a checksum-verified official Gitea binary") } binary, err := filepath.Abs(binary) if err != nil { t.Fatal(err) } s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream")) if err != nil { t.Fatal(err) } version, err := exec.Command(binary, "--version").CombinedOutput() if err != nil { t.Fatal(err) } fields := strings.Fields(strings.ToLower(string(version))) if len(fields) < 3 || fields[0] != "gitea" || fields[1] != "version" || fields[2] != strings.TrimPrefix(s.Lock.Tag, "v") { t.Fatalf("binary does not match snapshot: %s", version) } for _, mode := range []string{"shared", "framed"} { t.Run(mode, func(t *testing.T) { smokeMode(t, binary, s, mode) }) } } func smokeMode(t *testing.T, binary string, s *upstream.Snapshot, mode string) { port, mailbox := smtpCapture(t) probe, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } webPort := probe.Addr().(*net.TCPAddr).Port probe.Close() work := t.TempDir() // Preseed only Gitea's disposable Git home; never touch the user's Git config. gitHome := filepath.Join(work, "data", "home") if err := os.MkdirAll(gitHome, 0755); err != nil { t.Fatal(err) } gitConfig := "[user]\nname = Gitea\nemail = gitea@fake.local\n[core]\nquotepath = false\nlongpaths = true\ncommitGraph = true\n[gc]\nwriteCommitGraph = true\n[fetch]\nwriteCommitGraph = true\n[init]\ndefaultBranch = main\n[receive]\nadvertisePushOptions = true\nprocReceiveRefs = refs/for\n[uploadpack]\nallowfilter = true\nallowAnySHA1InWant = true\n[safe]\ndirectory = *\n" if err := os.WriteFile(filepath.Join(gitHome, ".gitconfig"), []byte(gitConfig), 0600); err != nil { t.Fatal(err) } custom := filepath.Join(work, "custom") configFile := filepath.Join(work, "app.ini") rootURL := fmt.Sprintf("http://127.0.0.1:%d/", webPort) ini := fmt.Sprintf("APP_NAME = Smoke Gitea\nRUN_MODE = prod\n[server]\nAPP_DATA_PATH = %s/data\nHTTP_ADDR = 127.0.0.1\nHTTP_PORT = %d\nROOT_URL = %s\nDISABLE_SSH = true\n[database]\nDB_TYPE = sqlite3\nPATH = %s/gitea.db\n[repository]\nROOT = %s/repos\n[security]\nINSTALL_LOCK = true\n[service]\nDISABLE_REGISTRATION = true\nREGISTER_EMAIL_CONFIRM = false\nENABLE_NOTIFY_MAIL = true\n[mailer]\nENABLED = true\nPROTOCOL = smtp\nSMTP_ADDR = 127.0.0.1\nSMTP_PORT = %d\nFROM = Smoke Gitea \n[log]\nMODE = console\nLEVEL = warn\n", filepath.ToSlash(work), webPort, rootURL, filepath.ToSlash(work), filepath.ToSlash(work), port) if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil { t.Fatal(err) } // Disable key-file maintenance and isolate even its unused paths from HOME. ini = strings.Replace(ini, "DISABLE_SSH = true", "DISABLE_SSH = true\nSSH_ROOT_PATH = "+filepath.ToSlash(filepath.Join(work, "ssh"))+"\nSSH_CREATE_AUTHORIZED_KEYS_FILE = false\nSSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = false", 1) if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil { t.Fatal(err) } var p *builder.Package if mode == "framed" { theme, err := builder.LoadTheme(filepath.Join("..", "..", "themes", "horizon")) if err != nil { t.Fatal(err) } p, err = builder.Build(s, theme) if err != nil { t.Fatal(err) } } else { p, err = builder.Build(s, &builder.Theme{Name: "smoke", Mode: "shared", CSS: "a { color: #123456; }"}) if err != nil { t.Fatal(err) } } for name, data := range p.Files { target := filepath.Join(custom, "templates", filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(target, data, 0644); err != nil { t.Fatal(err) } } args := []string{"--work-path", work, "--custom-path", custom, "--config", configFile} run := func(more ...string) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, binary, append(append([]string{}, args...), more...)...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("Gitea %v: %v\n%s", more, err, output) } } run("migrate") run("admin", "user", "create", "--username", "smoke-user", "--password", "local-smoke-password-123!", "--email", "smoke@example.invalid", "--admin", "--must-change-password=false") ctx, cancel := context.WithCancel(context.Background()) defer cancel() var logs safeLog web := exec.CommandContext(ctx, binary, append(args, "web")...) web.Stdout = &logs web.Stderr = &logs if err := web.Start(); err != nil { t.Fatal(err) } done := make(chan struct{}) go func() { _ = web.Wait(); close(done) }() t.Cleanup(func() { cancel(); <-done }) jar, _ := cookiejar.New(nil) client := &http.Client{Jar: jar, Timeout: 5 * time.Second} deadline := time.Now().Add(30 * time.Second) var page []byte for time.Now().Before(deadline) { select { case <-done: t.Fatalf("Gitea exited: %s", logs.String()) default: } resp, err := client.Get(rootURL + "user/forgot_password") if err == nil { page, _ = io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode == 200 { break } } time.Sleep(100 * time.Millisecond) } if len(page) == 0 { t.Fatalf("Gitea did not start: %s", logs.String()) } if !bytes.Contains(page, []byte(`name="email"`)) { t.Fatalf("no reset form: %s\n%s", page, logs.String()) } // Gitea 28 uses origin checks rather than a hidden _csrf form field. req, err := http.NewRequest(http.MethodPost, rootURL+"user/forgot_password", strings.NewReader(url.Values{"email": {"smoke@example.invalid"}}.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", strings.TrimSuffix(rootURL, "/")) resp, err := client.Do(req) if err != nil { t.Fatal(err) } io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("password reset HTTP %d: %s", resp.StatusCode, logs.String()) } select { case raw := <-mailbox: message, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { t.Fatal(err) } parts, err := mimeBodies(message.Header.Get("Content-Type"), message.Header.Get("Content-Transfer-Encoding"), message.Body) if err != nil { t.Fatal(err) } body := parts["text/html"] if !strings.Contains(body, "/user/recover_account?code=") || !strings.Contains(body, "