316 lines
10 KiB
Go
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
|
|
}
|