software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+374
View File
@@ -0,0 +1,374 @@
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)
}
}