taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | README | LICENSE

app_test.go (26540B)


      1 package web
      2 
      3 import (
      4 	"encoding/json"
      5 	"io"
      6 	"net/http"
      7 	"net/http/httptest"
      8 	"net/url"
      9 	"strings"
     10 	"testing"
     11 	"time"
     12 
     13 	"git.taler.net/taler-merchant-demos/internal/backend"
     14 )
     15 
     16 func TestLandingRoutesAndEmbeddedStatic(t *testing.T) {
     17 	app, err := New(Options{Shop: "landing", Currency: "KUDOS"})
     18 	if err != nil {
     19 		t.Fatal(err)
     20 	}
     21 	request := httptest.NewRequest(http.MethodGet, "/", nil)
     22 	request.Header.Set("Accept-Language", "de-DE,de;q=0.9,en;q=0.5")
     23 	response := httptest.NewRecorder()
     24 	app.ServeHTTP(response, request)
     25 	if response.Code != http.StatusFound || response.Header().Get("Location") != "/de/" {
     26 		t.Fatalf("root response = %d, location %q", response.Code, response.Header().Get("Location"))
     27 	}
     28 	request = httptest.NewRequest(http.MethodGet, "/", nil)
     29 	request.Header.Set("Accept-Language", "fr;q=0,en;q=1")
     30 	response = httptest.NewRecorder()
     31 	app.ServeHTTP(response, request)
     32 	if response.Header().Get("Location") != "/en/" {
     33 		t.Fatalf("quality-weighted location = %q", response.Header().Get("Location"))
     34 	}
     35 	response = httptest.NewRecorder()
     36 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/en", nil))
     37 	if response.Code != http.StatusPermanentRedirect || response.Header().Get("Location") != "/en/" {
     38 		t.Fatalf("canonical language redirect = %d, %q", response.Code, response.Header().Get("Location"))
     39 	}
     40 
     41 	response = httptest.NewRecorder()
     42 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/en/", nil))
     43 	body := response.Body.String()
     44 	if response.Code != http.StatusOK || !strings.Contains(body, "Step 1: Install the wallet") {
     45 		t.Fatalf("landing response = %d, %q", response.Code, response.Body.String())
     46 	}
     47 	for _, expected := range []string{
     48 		`class="site-header"`, `class="heading-lockup"`, `class="demo-nav"`, `class="step-list"`,
     49 		`taler-logo-light.svg`, `taler-logo-dark.svg`, `aria-current="page"`, `class="link-icon"`,
     50 	} {
     51 		if !strings.Contains(body, expected) {
     52 			t.Errorf("landing response missing branded shell marker %q", expected)
     53 		}
     54 	}
     55 	if strings.Contains(body, "pure.css") || strings.Contains(body, "style=") {
     56 		t.Errorf("landing response still includes legacy presentation markup")
     57 	}
     58 	if response.Header().Get("Cache-Control") != "private, no-store" {
     59 		t.Fatalf("dynamic cache policy = %q", response.Header().Get("Cache-Control"))
     60 	}
     61 
     62 	response = httptest.NewRecorder()
     63 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/static/demo.css", nil))
     64 	if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "text/css; charset=utf-8" {
     65 		t.Fatalf("static response = %d, type %q", response.Code, response.Header().Get("Content-Type"))
     66 	}
     67 	if strings.Contains(response.Body.String(), "url(/static/") {
     68 		t.Fatalf("static stylesheet bypasses the forwarded prefix: %q", response.Body.String())
     69 	}
     70 	response = httptest.NewRecorder()
     71 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/static/blog.css", nil))
     72 	if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "url(/static/") {
     73 		t.Fatalf("blog stylesheet response = %d, body %q", response.Code, response.Body.String())
     74 	}
     75 	for _, asset := range []string{"/static/taler-logo-light.svg", "/static/taler-logo-dark.svg"} {
     76 		response = httptest.NewRecorder()
     77 		app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, asset, nil))
     78 		if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "image/svg+xml" {
     79 			t.Errorf("logo response for %s = %d, type %q", asset, response.Code, response.Header().Get("Content-Type"))
     80 		}
     81 	}
     82 
     83 	response = httptest.NewRecorder()
     84 	app.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/en/", nil))
     85 	if response.Code != http.StatusMethodNotAllowed {
     86 		t.Fatalf("POST language index response = %d", response.Code)
     87 	}
     88 }
     89 
     90 func TestPagesUseConfiguredPublicURLs(t *testing.T) {
     91 	app, err := New(Options{
     92 		Shop: "landing", Currency: "KUDOS",
     93 		PublicURLs: PublicURLs{
     94 			Landing: "https://landing.example/", Bank: "https://bank.example/",
     95 			Blog: "https://blog.example/", Donations: "https://donations.example/",
     96 		},
     97 	})
     98 	if err != nil {
     99 		t.Fatal(err)
    100 	}
    101 	response := httptest.NewRecorder()
    102 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/", nil))
    103 	if response.Code != http.StatusOK {
    104 		t.Fatalf("response status = %d", response.Code)
    105 	}
    106 	body := response.Body.String()
    107 	for _, expected := range []string{
    108 		`href="https://landing.example/de/"`,
    109 		`href="https://bank.example/de/"`,
    110 		`href="https://blog.example/de/"`,
    111 		`href="https://donations.example/de/"`,
    112 	} {
    113 		if !strings.Contains(body, expected) {
    114 			t.Errorf("response missing configured public URL %q", expected)
    115 		}
    116 	}
    117 }
    118 
    119 func TestDemoPagesUseDistinctBrandThemes(t *testing.T) {
    120 	merchant, err := backend.New("https://merchant.example/", "secret-token:test")
    121 	if err != nil {
    122 		t.Fatal(err)
    123 	}
    124 	tests := []struct {
    125 		shop       string
    126 		stylesheet string
    127 		component  string
    128 	}{
    129 		{shop: "landing", component: `class="step-card"`},
    130 		{shop: "blog", stylesheet: "colors-blog.css", component: `class="article-card"`},
    131 		{shop: "donations", stylesheet: "colors-donations.css", component: `class="demo-form form-card"`},
    132 	}
    133 	for _, test := range tests {
    134 		t.Run(test.shop, func(t *testing.T) {
    135 			options := Options{Shop: test.shop, Currency: "KUDOS"}
    136 			switch test.shop {
    137 			case "blog":
    138 				options.BlogBackend = merchant
    139 			case "donations":
    140 				options.DonationBackends = map[string]*backend.Client{"gnunet": merchant, "taler": merchant, "tor": merchant}
    141 			}
    142 			app, err := New(options)
    143 			if err != nil {
    144 				t.Fatal(err)
    145 			}
    146 			response := httptest.NewRecorder()
    147 			app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/en/", nil))
    148 			body := response.Body.String()
    149 			if response.Code != http.StatusOK {
    150 				t.Fatalf("response status = %d", response.Code)
    151 			}
    152 			if !strings.Contains(body, test.component) {
    153 				t.Errorf("response missing themed component %q", test.component)
    154 			}
    155 			if strings.Contains(body, `>Bank<svg class="link-icon"`) {
    156 				t.Error("response contains external-link icon on Bank navigation item")
    157 			}
    158 			if test.stylesheet != "" && !strings.Contains(body, test.stylesheet) {
    159 				t.Errorf("response missing theme stylesheet %q", test.stylesheet)
    160 			}
    161 			if test.shop == "blog" {
    162 				for _, marker := range []string{`id="edition-info-button"`, `<dialog class="fsfs-license"`, `KUDOS:0.5`} {
    163 					if !strings.Contains(body, marker) {
    164 						t.Errorf("blog response missing %q", marker)
    165 					}
    166 				}
    167 				if strings.Contains(body, `<h2>Chapters</h2>`) {
    168 					t.Error("blog response contains redundant chapters heading")
    169 				}
    170 			}
    171 		})
    172 	}
    173 }
    174 
    175 func TestDonationCheckoutUsesBrandedPaymentCards(t *testing.T) {
    176 	merchant, err := backend.New("https://merchant.example/", "secret-token:test")
    177 	if err != nil {
    178 		t.Fatal(err)
    179 	}
    180 	app, err := New(Options{
    181 		Shop: "donations", Currency: "KUDOS",
    182 		DonationBackends: map[string]*backend.Client{"gnunet": merchant, "taler": merchant, "tor": merchant},
    183 	})
    184 	if err != nil {
    185 		t.Fatal(err)
    186 	}
    187 	response := httptest.NewRecorder()
    188 	request := httptest.NewRequest(http.MethodGet, "/en/checkout?donation_receiver=taler&donation_amount=KUDOS%3A1&donation_donor=Alice", nil)
    189 	app.ServeHTTP(response, request)
    190 	body := response.Body.String()
    191 	if response.Code != http.StatusOK {
    192 		t.Fatalf("checkout response = %d, body %q", response.Code, body)
    193 	}
    194 	if strings.Count(body, `class="payment-option"`) != 4 || !strings.Contains(body, `class="checkout-card"`) {
    195 		t.Errorf("checkout response is missing branded payment cards: %q", body)
    196 	}
    197 	if !strings.Contains(body, `value="taler" checked`) {
    198 		t.Errorf("checkout response no longer defaults to Taler")
    199 	}
    200 
    201 	response = httptest.NewRecorder()
    202 	request = httptest.NewRequest(http.MethodGet, "/en/donate?donation_receiver=taler&donation_amount=KUDOS%3A1&donation_donor=Alice&payment_system=lisa", nil)
    203 	app.ServeHTTP(response, request)
    204 	wantLocation := "/en/provider-not-supported?donation_amount=KUDOS%3A1&donation_donor=Alice&donation_receiver=taler"
    205 	if response.Code != http.StatusFound || response.Header().Get("Location") != wantLocation {
    206 		t.Fatalf("unsupported provider response = %d, location %q", response.Code, response.Header().Get("Location"))
    207 	}
    208 
    209 	response = httptest.NewRecorder()
    210 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, wantLocation, nil))
    211 	body = response.Body.String()
    212 	if response.Code != http.StatusOK || !strings.Contains(body, "Back to payment methods") || !strings.Contains(body, `href="/en/checkout?donation_amount=KUDOS%3A1&amp;donation_donor=Alice&amp;donation_receiver=taler"`) {
    213 		t.Fatalf("unsupported provider return action missing: status %d, body %q", response.Code, body)
    214 	}
    215 
    216 	response = httptest.NewRecorder()
    217 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/en/provider-not-supported", nil))
    218 	body = response.Body.String()
    219 	if response.Code != http.StatusOK || !strings.Contains(body, "Back to donations") || !strings.Contains(body, `href="/en/"`) {
    220 		t.Fatalf("direct unsupported provider fallback missing: status %d, body %q", response.Code, body)
    221 	}
    222 }
    223 
    224 func TestUnsupportedLocalesFallBackToEnglish(t *testing.T) {
    225 	app, err := New(Options{Shop: "landing", Currency: "KUDOS"})
    226 	if err != nil {
    227 		t.Fatal(err)
    228 	}
    229 	request := httptest.NewRequest(http.MethodGet, "/", nil)
    230 	request.Header.Set("Accept-Language", "ar,zh;q=0.9")
    231 	response := httptest.NewRecorder()
    232 	app.ServeHTTP(response, request)
    233 	if response.Header().Get("Location") != "/en/" {
    234 		t.Fatalf("unsupported language negotiation location = %q", response.Header().Get("Location"))
    235 	}
    236 
    237 	response = httptest.NewRecorder()
    238 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/ar/", nil))
    239 	if response.Code != http.StatusTemporaryRedirect || response.Header().Get("Location") != "/en/" {
    240 		t.Fatalf("unsupported locale response = %d, location %q", response.Code, response.Header().Get("Location"))
    241 	}
    242 }
    243 
    244 func TestLocaleRedirects(t *testing.T) {
    245 	merchant, err := backend.New("https://merchant.example/", "secret-token:test")
    246 	if err != nil {
    247 		t.Fatal(err)
    248 	}
    249 	for _, shop := range []string{"landing", "blog", "donations"} {
    250 		t.Run(shop, func(t *testing.T) {
    251 			app, err := New(Options{
    252 				Shop: shop, Currency: "KUDOS", BlogBackend: merchant,
    253 				DonationBackends: map[string]*backend.Client{"gnunet": merchant, "taler": merchant, "tor": merchant},
    254 			})
    255 			if err != nil {
    256 				t.Fatal(err)
    257 			}
    258 			for _, test := range []struct {
    259 				path, prefix, location string
    260 				status                 int
    261 			}{
    262 				{"/de_CH/", "", "/de/", 307},
    263 				{"/de-CH", "", "/de/", 307},
    264 				{"/DE-ch/", "", "/de/", 307},
    265 				{"/fr_CA/checkout?donor=A%2BB&x=1&x=2", "/demo", "/demo/fr/checkout?donor=A%2BB&x=1&x=2", 307},
    266 				{"/de_CH/essay/a%2Fb/data/c%20d?order_id=123", "", "/de/essay/a%2Fb/data/c%20d?order_id=123", 307},
    267 				{"/xx/", "", "/en/", 307},
    268 				{"/zzz_ZZ/checkout?x=1", "", "/en/checkout?x=1", 307},
    269 				{"/de?x=1", "", "/de/?x=1", 308},
    270 				{"/en/", "", "", 200},
    271 				{"/en/not-a-page", "", "", 404},
    272 				{"/favicon.ico", "", "", 404},
    273 				{"/not-a-locale/", "", "", 404},
    274 				{"/de__CH/", "", "", 404},
    275 				{"/static/missing.css", "", "", 404},
    276 				{"/de_CH/", "//evil.example", "/de/", 307},
    277 			} {
    278 				t.Run(test.path+test.prefix, func(t *testing.T) {
    279 					request := httptest.NewRequest(http.MethodGet, test.path, nil)
    280 					request.Header.Set("Accept-Language", "fr")
    281 					request.Header.Set("X-Forwarded-Prefix", test.prefix)
    282 					response := httptest.NewRecorder()
    283 					app.ServeHTTP(response, request)
    284 					if response.Code != test.status || response.Header().Get("Location") != test.location {
    285 						t.Fatalf("response = %d, %q; want %d, %q", response.Code, response.Header().Get("Location"), test.status, test.location)
    286 					}
    287 				})
    288 			}
    289 		})
    290 	}
    291 }
    292 
    293 func TestLocaleRedirectPreservesPostBody(t *testing.T) {
    294 	app, err := New(Options{Shop: "landing", Currency: "KUDOS"})
    295 	if err != nil {
    296 		t.Fatal(err)
    297 	}
    298 	app.routes.HandleFunc("POST /{lang}/echo", func(w http.ResponseWriter, r *http.Request) {
    299 		if r.PathValue("lang") != "de" || r.URL.RawQuery != "order_id=123" {
    300 			t.Errorf("redirected request = %s", r.URL)
    301 		}
    302 		_, _ = io.Copy(w, r.Body)
    303 	})
    304 	server := httptest.NewServer(app)
    305 	defer server.Close()
    306 	response, err := server.Client().Post(server.URL+"/de_CH/echo?order_id=123", "application/x-www-form-urlencoded", strings.NewReader("amount=10&reason=A%2BB"))
    307 	if err != nil {
    308 		t.Fatal(err)
    309 	}
    310 	defer response.Body.Close()
    311 	body, err := io.ReadAll(response.Body)
    312 	if err != nil || response.StatusCode != 200 || string(body) != "amount=10&reason=A%2BB" {
    313 		t.Fatalf("POST response = %d, %q, %v", response.StatusCode, body, err)
    314 	}
    315 }
    316 
    317 func TestErrorsKeepLocalizedSummary(t *testing.T) {
    318 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    319 		w.WriteHeader(http.StatusInternalServerError)
    320 		_, _ = w.Write([]byte(`{"hint":"technical backend detail","code":42}`))
    321 	}))
    322 	defer merchant.Close()
    323 	client, err := backend.New(merchant.URL, "secret-token:test")
    324 	if err != nil {
    325 		t.Fatal(err)
    326 	}
    327 	app, err := New(Options{
    328 		Shop: "donations", Currency: "KUDOS",
    329 		DonationBackends: map[string]*backend.Client{"gnunet": client, "taler": client, "tor": client},
    330 	})
    331 	if err != nil {
    332 		t.Fatal(err)
    333 	}
    334 	response := httptest.NewRecorder()
    335 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/donation/taler?order_id=order-1", nil))
    336 	body := response.Body.String()
    337 	if response.Code != http.StatusBadGateway || !strings.Contains(body, "<p>Backend-Anfrage fehlgeschlagen</p>") {
    338 		t.Fatalf("localized backend error response = %d, body %q", response.Code, body)
    339 	}
    340 	if strings.Contains(body, "<p>technical backend detail</p>") {
    341 		t.Fatalf("technical backend detail replaced localized summary: %q", body)
    342 	}
    343 	if !strings.Contains(body, "<pre>technical backend detail</pre>") {
    344 		t.Fatalf("technical backend detail was not rendered: %q", body)
    345 	}
    346 
    347 	response = httptest.NewRecorder()
    348 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/checkout", nil))
    349 	if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "Der Parameter donation_receiver ist erforderlich.") {
    350 		t.Fatalf("localized missing parameter response = %d, body %q", response.Code, response.Body.String())
    351 	}
    352 }
    353 
    354 func TestBlogStartsCookieCheckBeforeBackend(t *testing.T) {
    355 	merchant := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
    356 		t.Fatal("backend called before cookie check completed")
    357 	}))
    358 	defer merchant.Close()
    359 	client, err := backend.New(merchant.URL, "secret-token:test")
    360 	if err != nil {
    361 		t.Fatal(err)
    362 	}
    363 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client})
    364 	if err != nil {
    365 		t.Fatal(err)
    366 	}
    367 	articles := sortedArticles(app.articles["en"])
    368 	if len(articles) == 0 {
    369 		t.Fatal("no English articles loaded")
    370 	}
    371 	request := httptest.NewRequest(http.MethodGet, "/en/essay/"+articles[0].Slug, nil)
    372 	response := httptest.NewRecorder()
    373 	app.ServeHTTP(response, request)
    374 	if response.Code != http.StatusFound || !strings.Contains(response.Header().Get("Location"), "expect_state=yes") {
    375 		t.Fatalf("article response = %d, location %q", response.Code, response.Header().Get("Location"))
    376 	}
    377 	if !strings.Contains(response.Header().Get("Set-Cookie"), sessionCookieName+"=") {
    378 		t.Fatalf("session cookie missing: %q", response.Header().Get("Set-Cookie"))
    379 	}
    380 }
    381 
    382 func TestBlogCreatesCurrentMerchantOrder(t *testing.T) {
    383 	var posted backend.PostOrderRequest
    384 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    385 		switch {
    386 		case r.Method == http.MethodGet && r.URL.Path == "/private/orders":
    387 			_ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{}})
    388 		case r.Method == http.MethodPost && r.URL.Path == "/private/orders":
    389 			_ = json.NewDecoder(r.Body).Decode(&posted)
    390 			_ = json.NewEncoder(w).Encode(map[string]any{"order_id": "order-1", "token": "claim-1"})
    391 		case r.Method == http.MethodGet && r.URL.Path == "/private/orders/order-1":
    392 			_ = json.NewEncoder(w).Encode(map[string]any{
    393 				"order_status": "paid", "refunded": false,
    394 				"contract_terms": map[string]any{
    395 					"extra":           map[string]any{"article_name": r.Header.Get("X-Test-Article")},
    396 					"refund_deadline": map[string]any{"t_s": time.Now().Add(time.Minute).Unix()},
    397 				},
    398 			})
    399 		default:
    400 			http.Error(w, "unexpected request", http.StatusNotFound)
    401 		}
    402 	}))
    403 	defer merchant.Close()
    404 	client, err := backend.New(merchant.URL, "secret-token:test")
    405 	if err != nil {
    406 		t.Fatal(err)
    407 	}
    408 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client, EnableTokens: true})
    409 	if err != nil {
    410 		t.Fatal(err)
    411 	}
    412 	article := sortedArticles(app.articles["de"])[0]
    413 	articlePath := "/de/essay/" + article.Slug
    414 	request := httptest.NewRequest(http.MethodGet, articlePath, nil)
    415 	request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "browser-session"})
    416 	response := httptest.NewRecorder()
    417 	app.ServeHTTP(response, request)
    418 	if response.Code != http.StatusFound || !strings.Contains(response.Header().Get("Location"), "/orders/order-1?") {
    419 		t.Fatalf("purchase response = %d, location %q", response.Code, response.Header().Get("Location"))
    420 	}
    421 	if posted.SessionID != "browser-session" || !posted.CreateToken {
    422 		t.Fatalf("post-order envelope = %#v", posted)
    423 	}
    424 	if posted.Order.Version != 1 {
    425 		t.Fatalf("order version = %d", posted.Order.Version)
    426 	}
    427 	if len(posted.Order.Choices) != 3 || posted.Order.Choices[0].Description != "Einen einzelnen Artikel kaufen" || posted.Order.Choices[1].Description != "Einen Monat unbegrenzten Zugriff kaufen" {
    428 		t.Fatalf("localized order choices = %#v", posted.Order.Choices)
    429 	}
    430 	if posted.RefundDelay.Microseconds != time.Hour.Microseconds() {
    431 		t.Fatalf("refund delay = %d microseconds", posted.RefundDelay.Microseconds)
    432 	}
    433 	if posted.Order.WireTransferDeadline.Seconds <= posted.Order.PayDeadline.Seconds+int64(time.Hour/time.Second) {
    434 		t.Fatalf("wire deadline does not allow the advertised refund window: %#v", posted.Order)
    435 	}
    436 }
    437 
    438 func TestDonationReceiptOrderAndFulfillment(t *testing.T) {
    439 	var posted backend.PostOrderRequest
    440 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    441 		switch r.Method {
    442 		case http.MethodPost:
    443 			_ = json.NewDecoder(r.Body).Decode(&posted)
    444 			_ = json.NewEncoder(w).Encode(map[string]any{"order_id": "donation-1"})
    445 		case http.MethodGet:
    446 			_ = json.NewEncoder(w).Encode(map[string]any{
    447 				"order_status": "paid",
    448 				"contract_terms": map[string]any{"extra": map[string]any{
    449 					"receiver": "taler", "amount": "KUDOS:1", "donor": "<script>alert(1)</script>",
    450 				}},
    451 			})
    452 		}
    453 	}))
    454 	defer merchant.Close()
    455 	client, err := backend.New(merchant.URL, "secret-token:test")
    456 	if err != nil {
    457 		t.Fatal(err)
    458 	}
    459 	app, err := New(Options{
    460 		Shop: "donations", Currency: "KUDOS", DonauURL: "https://donau.example/",
    461 		DonationBackends: map[string]*backend.Client{"gnunet": client, "taler": client, "tor": client},
    462 	})
    463 	if err != nil {
    464 		t.Fatal(err)
    465 	}
    466 	request := httptest.NewRequest(http.MethodGet, "http://demo.example/en/donate?donation_receiver=taler&donation_amount=KUDOS%3A1&donation_donor=Alice&payment_system=taler", nil)
    467 	request.Header.Set("X-Forwarded-Prefix", "/merchant")
    468 	request.Header.Set("X-Forwarded-Proto", "https")
    469 	response := httptest.NewRecorder()
    470 	app.ServeHTTP(response, request)
    471 	if response.Code != http.StatusFound || response.Header().Get("Location") != "/merchant/en/donation/taler?order_id=donation-1" {
    472 		t.Fatalf("donation response = %d, location %q", response.Code, response.Header().Get("Location"))
    473 	}
    474 	if !strings.HasPrefix(posted.Order.FulfillmentURL, "https://demo.example/merchant/en/donation/taler?") {
    475 		t.Fatalf("fulfillment URL = %q", posted.Order.FulfillmentURL)
    476 	}
    477 
    478 	request = httptest.NewRequest(http.MethodGet, "http://demo.example/de/donate?donation_receiver=taler&donation_amount=KUDOS%3A1&donation_donor=Alice&payment_system=taler", nil)
    479 	response = httptest.NewRecorder()
    480 	app.ServeHTTP(response, request)
    481 	if response.Code != http.StatusFound || posted.Order.Summary != "Spende an taler (mit Spendenquittung)" {
    482 		t.Fatalf("localized donation order response = %d, order = %#v", response.Code, posted.Order)
    483 	}
    484 	output := posted.Order.Choices[0].Outputs[0]
    485 	if output.Type != "tax-receipt" || output.TokenFamilySlug != "" || output.Amount != "" {
    486 		t.Fatalf("tax receipt output = %#v", output)
    487 	}
    488 
    489 	request = httptest.NewRequest(http.MethodGet, "http://demo.example/en/donation/taler?order_id=donation-1", nil)
    490 	response = httptest.NewRecorder()
    491 	app.ServeHTTP(response, request)
    492 	body := response.Body.String()
    493 	if response.Code != http.StatusOK || strings.Contains(body, "<script>alert(1)</script>") || !strings.Contains(body, "&lt;script&gt;alert(1)&lt;/script&gt;") {
    494 		t.Fatalf("fulfillment response = %d, body %q", response.Code, response.Body.String())
    495 	}
    496 	for _, marker := range []string{`data-copy-value="http://demo.example/en/donation/taler?order_id=donation-1"`, "Copy receipt link", "Donate again", `role="status" hidden`} {
    497 		if !strings.Contains(body, marker) {
    498 			t.Errorf("fulfillment response missing receipt action %q", marker)
    499 		}
    500 	}
    501 }
    502 
    503 func TestArticleReferencesBundledSupplementalData(t *testing.T) {
    504 	var articleName string
    505 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    506 		if r.Method != http.MethodGet {
    507 			http.Error(w, "unexpected request", http.StatusMethodNotAllowed)
    508 			return
    509 		}
    510 		_ = json.NewEncoder(w).Encode(backend.OrderStatusResponse{
    511 			OrderStatus: "paid",
    512 			ContractTerms: backend.ContractTerms{
    513 				Extra:          backend.OrderExtra{ArticleName: articleName},
    514 				RefundDeadline: backend.Timestamp{Seconds: time.Now().Add(time.Minute).Unix()},
    515 			},
    516 		})
    517 	}))
    518 	defer merchant.Close()
    519 	client, err := backend.New(merchant.URL, "secret-token:test")
    520 	if err != nil {
    521 		t.Fatal(err)
    522 	}
    523 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client})
    524 	if err != nil {
    525 		t.Fatal(err)
    526 	}
    527 	var article Article
    528 	for _, candidate := range app.articles["en"] {
    529 		if candidate.ExtraFiles["category.png"] != "" {
    530 			article = candidate
    531 			break
    532 		}
    533 	}
    534 	if article.Slug == "" {
    535 		t.Fatal("no English article references category.png")
    536 	}
    537 	articleName = article.Slug
    538 	articlePath := "/en/essay/" + url.PathEscape(article.Slug)
    539 	request := httptest.NewRequest(http.MethodGet, articlePath, nil)
    540 	request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "browser-session"})
    541 	request.AddCookie(&http.Cookie{Name: orderCookieName, Value: "paid-order"})
    542 	response := httptest.NewRecorder()
    543 	app.ServeHTTP(response, request)
    544 	if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), url.PathEscape(article.Slug)+"/data/category.png") {
    545 		t.Fatalf("article response = %d, supplemental reference missing", response.Code)
    546 	}
    547 
    548 	response = httptest.NewRecorder()
    549 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, articlePath+"/data/category.png", nil))
    550 	if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "image/png" || !strings.HasPrefix(response.Body.String(), "\x89PNG") {
    551 		t.Fatalf("supplemental response = %d, type %q", response.Code, response.Header().Get("Content-Type"))
    552 	}
    553 
    554 	response = httptest.NewRecorder()
    555 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, articlePath+"/data/unused.jpg", nil))
    556 	if response.Code != http.StatusNotFound {
    557 		t.Fatalf("unreferenced supplemental response = %d", response.Code)
    558 	}
    559 }
    560 
    561 func TestBlogRefundUsesMethodAwareRoute(t *testing.T) {
    562 	var refund backend.RefundRequest
    563 	choiceIndex := 0
    564 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    565 		switch {
    566 		case r.Method == http.MethodGet && r.URL.Path == "/private/orders/order-1":
    567 			_ = json.NewEncoder(w).Encode(backend.OrderStatusResponse{
    568 				OrderStatus:    "paid",
    569 				OrderStatusURL: "https://merchant.example/orders/order-1",
    570 				ChoiceIndex:    &choiceIndex,
    571 				ContractTerms: backend.ContractTerms{
    572 					Version:        1,
    573 					Choices:        []backend.OrderChoice{{Amount: "KUDOS:0.5"}, {Amount: "KUDOS:10"}, {Amount: "KUDOS:0"}},
    574 					RefundDeadline: backend.Timestamp{Seconds: time.Now().Add(time.Minute).Unix()},
    575 				},
    576 			})
    577 		case r.Method == http.MethodPost && r.URL.Path == "/private/orders/order-1/refund":
    578 			_ = json.NewDecoder(r.Body).Decode(&refund)
    579 			_ = json.NewEncoder(w).Encode(map[string]any{})
    580 		default:
    581 			http.Error(w, "unexpected request", http.StatusNotFound)
    582 		}
    583 	}))
    584 	defer merchant.Close()
    585 	client, err := backend.New(merchant.URL, "secret-token:test")
    586 	if err != nil {
    587 		t.Fatal(err)
    588 	}
    589 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client})
    590 	if err != nil {
    591 		t.Fatal(err)
    592 	}
    593 	request := httptest.NewRequest(http.MethodPost, "/de/refund/order-1", nil)
    594 	request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "browser-session"})
    595 	response := httptest.NewRecorder()
    596 	app.ServeHTTP(response, request)
    597 	if response.Code != http.StatusFound || response.Header().Get("Location") != "https://merchant.example/orders/order-1" {
    598 		t.Fatalf("refund response = %d, location %q", response.Code, response.Header().Get("Location"))
    599 	}
    600 	if refund.Refund != "KUDOS:0.5" || refund.Reason != "Rückerstattung in der Demo" {
    601 		t.Fatalf("refund request = %#v", refund)
    602 	}
    603 
    604 	response = httptest.NewRecorder()
    605 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/refund/order-1", nil))
    606 	if response.Code != http.StatusMethodNotAllowed {
    607 		t.Fatalf("GET refund response = %d", response.Code)
    608 	}
    609 }
    610 
    611 func TestRefundUsesSelectedOrderChoice(t *testing.T) {
    612 	app := &App{opts: Options{Currency: "KUDOS"}}
    613 	deadline := backend.Timestamp{Seconds: time.Now().Add(time.Minute).Unix()}
    614 	choices := []backend.OrderChoice{{Amount: "KUDOS:0.5"}, {Amount: "KUDOS:10"}, {Amount: "KUDOS:0"}}
    615 	for index, want := range []string{"KUDOS:0.5", "KUDOS:10", ""} {
    616 		choiceIndex := index
    617 		got, ok := app.refundAmount(backend.OrderStatusResponse{
    618 			ChoiceIndex: &choiceIndex,
    619 			ContractTerms: backend.ContractTerms{
    620 				Version: 1, Choices: choices, RefundDeadline: deadline,
    621 			},
    622 		})
    623 		if got != want || ok != (want != "") {
    624 			t.Errorf("choice %d refund = %q, %v; want %q", index, got, ok, want)
    625 		}
    626 	}
    627 	invalid := len(choices)
    628 	if amount, ok := app.refundAmount(backend.OrderStatusResponse{
    629 		ChoiceIndex: &invalid,
    630 		ContractTerms: backend.ContractTerms{
    631 			Version: 1, Choices: choices, RefundDeadline: deadline,
    632 		},
    633 	}); ok || amount != "" {
    634 		t.Fatalf("invalid choice refund = %q, %v", amount, ok)
    635 	}
    636 }