375 lines
13 KiB
Go
375 lines
13 KiB
Go
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 + – der Browser liest es als "+".
|
||
if !strings.Contains(body, `src="data:image/svg+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)
|
||
}
|
||
}
|