Files
KenanZhu fec3ace600
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s
chore: migrate mail themes to shared framework and locked upstream inputs
2026-10-09 18:34:36 +08:00

316 lines
10 KiB
Go

// 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 <smoke@example.invalid>\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, "<style>") {
t.Fatalf("official reset body or injected head missing: %s", body)
}
if mode == "framed" && !strings.Contains(body, `class="email-wrap`) {
t.Fatal("framed override was not loaded")
}
if mode == "framed" && (!strings.Contains(body, `class="email-btn">Recover your account</a>`) || !strings.Contains(body, `src="`+rootURL+`assets/img/favicon.png"`) || strings.Count(body, "/user/recover_account?code=") < 3) {
t.Fatal("shared action label, fallback or instance logo missing from real mail")
}
if mode == "shared" && (!strings.Contains(body, "#123456") || strings.Contains(body, `class="email-wrap`)) {
t.Fatal("shared partial was not loaded")
}
if !strings.Contains(parts["text/plain"], "recover_account") {
t.Fatal("Gitea plain-text alternative missing official recovery link")
}
t.Logf("[PASS] %s %s: real password-reset mail captured; HTML override and plain-text link verified", s.Lock.Tag, mode)
case <-time.After(20 * time.Second):
t.Fatalf("no mail captured: %s", logs.String())
}
}
func mimeBodies(contentType, encoding string, r io.Reader) (map[string]string, error) {
media, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, err
}
result := map[string]string{}
if strings.HasPrefix(media, "multipart/") {
reader := multipart.NewReader(r, params["boundary"])
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
bodies, err := mimeBodies(part.Header.Get("Content-Type"), part.Header.Get("Content-Transfer-Encoding"), part)
if err != nil {
return nil, err
}
for k, v := range bodies {
result[k] = v
}
}
return result, nil
}
switch strings.ToLower(encoding) {
case "quoted-printable":
r = quotedprintable.NewReader(r)
case "base64":
r = base64.NewDecoder(base64.StdEncoding, r)
}
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}
result[media] = string(b)
return result, nil
}