xetup/internal/report/report_test.go
X9 Dev eabf207e3f
All checks were successful
release / build-and-release (push) Successful in 43s
feat(report): file failed runs as Forgejo issues + attach zipped log to email
On any ERROR step, xetup now files an issue in the private x9/xetup-runs
tracker (title "FAILED <host> - <version> - <date>", label "failed", body =
step table + tail-capped Deploy.log) via the xetup-bot write:issue token,
baked in through -ldflags. Best-effort and non-blocking; successful runs stay
silent and the email is the fallback.

The deployment email now attaches the zipped Deploy.log and links to this
run's issue plus the run history filtered to this machine.

- internal/buildinfo: ldflags-injected Version + RunsToken
- internal/runreport: issue filing, retries, tail-capped log
- internal/report: multipart/mixed with zip attachment, tracker links;
  buildMessage split out and covered by report_test.go
- release.yml: inject Version (tag/SHA) + FORGEJO_RUNS_TOKEN via ldflags

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:30:08 +02:00

155 lines
4.9 KiB
Go

package report
import (
"archive/zip"
"bytes"
"encoding/base64"
"io"
"mime"
"mime/multipart"
"net/mail"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var sampleResults = []StepResult{
{Num: "01", Name: "Bloatware", Status: "OK", Elapsed: 3 * time.Second},
{Num: "08", Name: "Activation", Status: "ERROR", Elapsed: 2 * time.Second},
}
// writeLog writes a temp Deploy.log and returns its path.
func writeLog(t *testing.T, content []byte) string {
t.Helper()
p := filepath.Join(t.TempDir(), "Deploy.log")
if err := os.WriteFile(p, content, 0644); err != nil {
t.Fatal(err)
}
return p
}
// buildHTML must embed the direct issue link and the per-machine tracker filter.
func TestBuildHTMLLinks(t *testing.T) {
issueURL := "https://git.xetup.x9.cz/x9/xetup-runs/issues/42"
html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", issueURL)
if !strings.Contains(html, issueURL) {
t.Errorf("html does not link the run issue %q", issueURL)
}
if !strings.Contains(html, "xetup-runs/issues?q=PC-TEST") {
t.Errorf("html missing per-machine tracker filter link; got:\n%s", html)
}
}
// With no issue (clean run / post failed), only the tracker link is shown.
func TestBuildHTMLNoIssue(t *testing.T) {
html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", "")
if strings.Contains(html, "Chyby tohoto behu") {
t.Error("issue-specific line shown when issueURL is empty")
}
if !strings.Contains(html, "xetup-runs/issues?q=PC-TEST") {
t.Error("tracker filter link missing")
}
}
// zipLog must produce a valid zip whose Deploy.log entry round-trips.
func TestZipLogRoundTrip(t *testing.T) {
content := []byte("line one\nERROR something failed\nline three\n")
name, data := zipLog(writeLog(t, content), "PC-TEST")
if name != "PC-TEST-Deploy.log.zip" {
t.Errorf("unexpected attachment name %q", name)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("attachment is not a valid zip: %v", err)
}
if len(zr.File) != 1 || zr.File[0].Name != "Deploy.log" {
t.Fatalf("zip should contain exactly Deploy.log, got %v", zr.File)
}
rc, _ := zr.File[0].Open()
got, _ := io.ReadAll(rc)
rc.Close()
if !bytes.Equal(got, content) {
t.Errorf("zip content mismatch: got %q want %q", got, content)
}
}
// zipLog returns no attachment when the log is unreadable (email still sends).
func TestZipLogMissing(t *testing.T) {
name, data := zipLog(filepath.Join(t.TempDir(), "nope.log"), "PC-TEST")
if name != "" || data != nil {
t.Errorf("expected no attachment for missing log, got name=%q len=%d", name, len(data))
}
}
// buildMessage must yield a parseable multipart/mixed message: an HTML part and
// a base64 application/zip attachment that unzips back to the log.
func TestBuildMessageMultipart(t *testing.T) {
logContent := []byte("deploy log body\nERROR boom\n")
_, zipData := zipLog(writeLog(t, logContent), "PC-TEST")
html := buildHTML(sampleResults, "PC-TEST", "2026-07-28 10:00", "https://x/issues/1")
raw := buildMessage("xetup report PC-TEST", html, "PC-TEST-Deploy.log.zip", zipData)
m, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatalf("message does not parse: %v", err)
}
mediaType, params, err := mime.ParseMediaType(m.Header.Get("Content-Type"))
if err != nil || mediaType != "multipart/mixed" {
t.Fatalf("expected multipart/mixed, got %q (%v)", mediaType, err)
}
mr := multipart.NewReader(m.Body, params["boundary"])
var sawHTML, sawZip bool
for {
p, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("part read error: %v", err)
}
ct := p.Header.Get("Content-Type")
body, _ := io.ReadAll(p)
switch {
case strings.HasPrefix(ct, "text/html"):
sawHTML = true
if !strings.Contains(string(body), "xetup report") {
t.Error("html part missing report content")
}
case strings.HasPrefix(ct, "application/zip"):
sawZip = true
if enc := p.Header.Get("Content-Transfer-Encoding"); enc != "base64" {
t.Errorf("zip part not base64, got %q", enc)
}
// multipart does not auto-decode base64; strip wraps and decode.
clean := strings.NewReplacer("\r", "", "\n", "").Replace(string(body))
dec, err := base64.StdEncoding.DecodeString(clean)
if err != nil {
t.Fatalf("attachment base64 decode failed: %v", err)
}
if _, err := zip.NewReader(bytes.NewReader(dec), int64(len(dec))); err != nil {
t.Errorf("decoded attachment is not a zip: %v", err)
}
}
}
if !sawHTML || !sawZip {
t.Errorf("missing parts: html=%v zip=%v", sawHTML, sawZip)
}
}
// Plain message (no attachment) must be single-part text/html.
func TestBuildMessagePlain(t *testing.T) {
raw := buildMessage("subj", "<html>x</html>", "", nil)
m, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatalf("plain message does not parse: %v", err)
}
if ct := m.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("expected text/html, got %q", ct)
}
}