chore: migrate mail themes to shared framework and locked upstream inputs
This commit is contained in:
1 parent
5c0589f6f6
commit
fec3ace600
225 files changed
+4718
-15807
No files matched your search
@@ -0,0 +1,315 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user