Files
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

375 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lander
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func request(t *testing.T, l *Lander, stage, tier, target string, header ...string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
r.Host = "kunde.de"
if stage != "" {
r.Header.Set("X-Sultan-Stage", stage)
}
r.Header.Set("X-Sultan-Tenant", "kunde")
if tier != "" {
r.Header.Set("X-Sultan-Tier", tier)
}
for i := 0; i+1 < len(header); i += 2 {
r.Header.Set(header[i], header[i+1])
}
rec := httptest.NewRecorder()
l.ServeHTTP(rec, r)
return rec
}
func newLander(t *testing.T) *Lander {
return &Lander{Logger: slog.New(slog.DiscardHandler), HosterName: "Beispiel-Hosting", HosterURL: "https://hoster.example", ProURL: "https://hoster.example/pro"}
}
func TestStages(t *testing.T) {
tests := []struct {
stage string
status int
refresh string // erwarteter meta-refresh-Inhalt, leer = keiner
cookie bool
}{
{"watermark", 200, "5;url=/shop?x=1", true},
{"firemark", 200, "10;url=/shop?x=1", true},
{"paywall", 402, "", false},
{"landing", 402, "", false},
{"maintenance", 503, "", false},
}
for _, tt := range tests {
t.Run(tt.stage, func(t *testing.T) {
rec := request(t, newLander(t), tt.stage, "pro", "/shop?x=1")
if rec.Code != tt.status {
t.Errorf("Status %d, erwartet %d", rec.Code, tt.status)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type %q", ct)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Error("Seite darf nicht gecacht werden")
}
body := rec.Body.String()
if tt.refresh != "" && !strings.Contains(body, `http-equiv="refresh" content="`+tt.refresh+`"`) {
t.Errorf("meta refresh %q fehlt:\n%s", tt.refresh, body)
}
if tt.refresh == "" && strings.Contains(body, "refresh") {
t.Error("Stufe ohne Durchlass darf nicht weiterleiten")
}
cookie := rec.Header().Get("Set-Cookie")
if tt.cookie {
for _, want := range []string{"sultan_ack=" + tt.stage, "Max-Age=3600", "HttpOnly", "SameSite=Lax", "Path=/"} {
if !strings.Contains(cookie, want) {
t.Errorf("Cookie %q ohne %q", cookie, want)
}
}
} else if cookie != "" {
t.Errorf("unerwartetes Cookie %q", cookie)
}
if !strings.Contains(body, `lang="de"`) || !strings.Contains(body, `lang="en"`) {
t.Error("Text muss auf Deutsch und Englisch vorliegen")
}
})
}
}
func TestMaintenanceRetryAfterAndProHint(t *testing.T) {
l := newLander(t)
rec := request(t, l, "maintenance", "community", "/")
if rec.Header().Get("Retry-After") != "60" {
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
}
if !strings.Contains(rec.Body.String(), "https://hoster.example/pro") {
t.Error("Community-Wartungsseite ohne Pro-Hinweis")
}
if strings.Contains(request(t, l, "maintenance", "pro", "/").Body.String(), "https://hoster.example/pro") {
t.Error("Pro-Kunde bekommt Pro-Werbung")
}
}
func TestPaywallInvitesToHoster(t *testing.T) {
body := request(t, newLander(t), "paywall", "pro", "/").Body.String()
if !strings.Contains(body, "Beispiel-Hosting") || !strings.Contains(body, "https://hoster.example") {
t.Errorf("Einladung des Hosters fehlt:\n%s", body)
}
}
func TestSecureCookieBehindHTTPS(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", "/", "X-Forwarded-Proto", "https")
if !strings.Contains(rec.Header().Get("Set-Cookie"), "Secure") {
t.Errorf("Cookie ohne Secure: %q", rec.Header().Get("Set-Cookie"))
}
}
func TestConfigurableDelays(t *testing.T) {
l := newLander(t)
l.WatermarkDelay, l.FiremarkDelay, l.AckTTL = 2*time.Second, 7*time.Second, 15*time.Minute
if body := request(t, l, "watermark", "pro", "/").Body.String(); !strings.Contains(body, `content="2;url=/"`) {
t.Errorf("Wartezeit Stufe 1:\n%s", body)
}
rec := request(t, l, "firemark", "pro", "/")
if !strings.Contains(rec.Body.String(), `content="7;url=/"`) || !strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=900") {
t.Errorf("Stufe 2: %q / %s", rec.Header().Get("Set-Cookie"), rec.Body.String())
}
}
func TestUnknownOrMissingStage(t *testing.T) {
for _, stage := range []string{"", "normal", "kaputt"} {
if rec := request(t, newLander(t), stage, "pro", "/"); rec.Code != http.StatusBadRequest {
t.Errorf("Stufe %q: Status %d", stage, rec.Code)
}
}
}
// Werte aus Headern und URL werden nie roh ausgegeben.
func TestEscaping(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", `/a"><script>alert(1)</script>`,
"X-Sultan-Tenant", "<script>böse</script>")
body := rec.Body.String()
if strings.Contains(body, "<script>") {
t.Errorf("ungefilterte Ausgabe:\n%s", body)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCustomTemplateWithInlinedAssets(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html",
`<html><head><style>{{.CSS}}</style></head><body><img src="{{.Image}}"><p>Wartung bei {{.Tenant}} ({{.Tier}})</p></body></html>`)
writeFile(t, dir, "maintenance.css", "body { color: red; }")
writeFile(t, dir, "maintenance.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
l := newLander(t)
l.Dir = dir
rec := request(t, l, "maintenance", "pro", "/")
body := rec.Body.String()
if rec.Code != 503 || !strings.Contains(body, "Wartung bei kunde (pro)") {
t.Errorf("eigene Vorlage nicht verwendet: %d\n%s", rec.Code, body)
}
if !strings.Contains(body, "body { color: red; }") {
t.Error("CSS nicht eingebettet")
}
// html/template schreibt "+" im Attribut als &#43; – der Browser liest es als "+".
if !strings.Contains(body, `src="data:image/svg&#43;xml;base64,`) {
t.Errorf("Grafik nicht als data-URI eingebettet:\n%s", body)
}
// Andere Stufen ohne eigene Vorlage: eingebaut.
if body := request(t, l, "paywall", "pro", "/").Body.String(); !strings.Contains(body, "Beispiel-Hosting") {
t.Error("eingebaute Seite für paywall fehlt")
}
}
func TestCustomTemplateWebP(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "landing.html", `<img src="{{.Image}}">`)
writeFile(t, dir, "landing.webp", "RIFF0000WEBP")
l := newLander(t)
l.Dir = dir
if body := request(t, l, "landing", "pro", "/").Body.String(); !strings.Contains(body, `src="data:image/webp;base64,`) {
t.Errorf("WebP nicht eingebettet: %s", body)
}
}
// Eine kaputte Vorlage darf die Hinweisseite nie ausfallen lassen.
func TestBrokenTemplateFallsBackToBuiltin(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht`)
var logs bytes.Buffer
l := newLander(t)
l.Dir, l.Logger = dir, slog.New(slog.NewTextHandler(&logs, nil))
rec := request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("keine eingebaute Seite: %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(logs.String(), "maintenance.html") {
t.Errorf("Fehler nicht protokolliert: %s", logs.String())
}
// Ausführungsfehler (unbekanntes Feld) ebenfalls.
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht}}`)
rec = request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("Ausführungsfehler: %d %s", rec.Code, rec.Body.String())
}
}
func TestOversizedAssetIgnored(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `<img src="{{.Image}}">ok`)
writeFile(t, dir, "maintenance.svg", strings.Repeat("x", MaxAssetSize+1))
l := newLander(t)
l.Dir = dir
body := request(t, l, "maintenance", "pro", "/").Body.String()
if strings.Contains(body, "base64") || !strings.Contains(body, "ok") {
t.Errorf("zu große Grafik eingebettet: %.200s", body)
}
}
func TestTemplatesReloadedOnChange(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `Version 1`)
l := newLander(t)
l.Dir = dir
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 1" {
t.Fatalf("body = %q", body)
}
writeFile(t, dir, "maintenance.html", `Version 2 – länger`)
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 2 – länger" {
t.Errorf("Änderung nicht übernommen: %q", body)
}
}
func TestDump(t *testing.T) {
dir := filepath.Join(t.TempDir(), "landing")
files, err := Dump(dir, false)
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, stage := range Stages {
if _, err := os.Stat(filepath.Join(dir, stage+".html")); err != nil {
t.Errorf("%s.html fehlt", stage)
}
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Error("README.txt fehlt")
}
if len(files) != len(Stages)+1 {
t.Errorf("geschriebene Dateien: %v", files)
}
// Ausgegebene Vorlagen rendern genauso wie die eingebauten.
builtin := newLander(t)
custom := newLander(t)
custom.Dir = dir
for _, stage := range Stages {
a := request(t, builtin, stage, "community", "/x").Body.String()
b := request(t, custom, stage, "community", "/x").Body.String()
if a != b {
t.Errorf("%s: Ausgabe weicht ab", stage)
}
}
if _, err := Dump(dir, false); !errors.Is(err, os.ErrExist) {
t.Errorf("Überschreiben ohne force: %v", err)
}
writeFile(t, dir, "maintenance.html", "eigen")
if _, err := Dump(dir, true); err != nil {
t.Fatalf("Dump mit force: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(dir, "maintenance.html")); string(b) == "eigen" {
t.Error("force hat nicht überschrieben")
}
}
func TestAnyMethodGetsPage(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/formular", strings.NewReader("a=b"))
r.Header.Set("X-Sultan-Stage", "maintenance")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if rec.Code != 503 {
t.Errorf("POST: %d", rec.Code)
}
}
// Das Weiterleitungsziel ist immer ein lokaler Pfad: keine fremden Hosts
// (//evil), keine Schemata (javascript:), keine Backslash-Tricks.
func TestRefreshTargetIsLocal(t *testing.T) {
tests := map[string]string{
"/shop?x=1": "/shop?x=1",
"//evil.example/x": "/",
"/\\evil.example": "/",
"/a%2F%2Fb": "/a%2F%2Fb",
"/pfad mit leer": "/pfad%20mit%20leer",
}
for target, want := range tests {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.URL.Path, r.URL.RawPath, r.URL.RawQuery = "", "", ""
u, _ := r.URL.Parse(target)
if strings.HasPrefix(target, "//") {
u.Host, u.Path = "", target // so wie es bei einem Request "GET //evil.example/x" ankommt
}
r.URL = u
r.Header.Set("X-Sultan-Stage", "watermark")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if !strings.Contains(rec.Body.String(), `content="5;url=`+want+`"`) {
t.Errorf("%q: erwartet Ziel %q in\n%s", target, want, rec.Body.String())
}
}
if got := localTarget(nil); got != "/" {
t.Errorf("localTarget(nil) = %q", got)
}
}
func TestMaintenancePublicReasonAndUntil(t *testing.T) {
berlin, _ := time.LoadLocation("Europe/Berlin")
l := newLander(t)
l.Location = berlin
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
until := now.Add(90 * time.Minute) // 15:30 in Berlin (MESZ)
rec := request(t, l, "maintenance", "pro", "/",
"X-Sultan-Public-Reason", url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst."),
"X-Sultan-Until", until.Format(time.RFC3339))
body := rec.Body.String()
if !strings.Contains(body, "Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst.") {
t.Errorf("öffentlicher Grund fehlt:\n%s", body)
}
if strings.Contains(body, "Bitte versuchen Sie es in einer Minute") {
t.Error("Standardtext trotz öffentlichem Grund")
}
if !strings.Contains(body, "02.10.2026 15:30") || !strings.Contains(body, "2026-10-02 15:30") {
t.Errorf("voraussichtliches Ende fehlt:\n%s", body)
}
if rec.Header().Get("Retry-After") != "5400" {
t.Errorf("Retry-After %q, erwartet Sekunden bis zum Ende", rec.Header().Get("Retry-After"))
}
}
func TestMaintenanceHeaderHandling(t *testing.T) {
l := newLander(t)
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
tests := []struct {
name, until, retry string
}{
{"kein Ende", "", "60"},
{"unlesbar", "morgen", "60"},
{"vergangen", now.Add(-time.Hour).Format(time.RFC3339), "60"},
{"sehr fern", now.Add(30 * 24 * time.Hour).Format(time.RFC3339), "86400"},
}
for _, tt := range tests {
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Until", tt.until)
if got := rec.Header().Get("Retry-After"); got != tt.retry {
t.Errorf("%s: Retry-After %q, erwartet %q", tt.name, got, tt.retry)
}
}
// Eingeschleustes HTML im Grund wird maskiert, überlange Gründe gekürzt.
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Public-Reason", url.QueryEscape("<script>x</script>"+strings.Repeat("a", 400)))
body := rec.Body.String()
if strings.Contains(body, "<script>x") || strings.Contains(body, strings.Repeat("a", 301)) {
t.Errorf("Grund nicht maskiert/gekürzt: %.300s", body)
}
}