taler-merchant-demos

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

app_test.go (23730B)


      1 package web
      2 
      3 import (
      4 	"encoding/json"
      5 	"net/http"
      6 	"net/http/httptest"
      7 	"net/url"
      8 	"strings"
      9 	"testing"
     10 	"time"
     11 
     12 	"git.taler.net/taler-merchant-demos/internal/backend"
     13 )
     14 
     15 func TestLandingRoutesAndEmbeddedStatic(t *testing.T) {
     16 	app, err := New(Options{Shop: "landing", Currency: "KUDOS"})
     17 	if err != nil {
     18 		t.Fatal(err)
     19 	}
     20 	request := httptest.NewRequest(http.MethodGet, "/", nil)
     21 	request.Header.Set("Accept-Language", "de-DE,de;q=0.9,en;q=0.5")
     22 	response := httptest.NewRecorder()
     23 	app.ServeHTTP(response, request)
     24 	if response.Code != http.StatusFound || response.Header().Get("Location") != "/de/" {
     25 		t.Fatalf("root response = %d, location %q", response.Code, response.Header().Get("Location"))
     26 	}
     27 	request = httptest.NewRequest(http.MethodGet, "/", nil)
     28 	request.Header.Set("Accept-Language", "fr;q=0,en;q=1")
     29 	response = httptest.NewRecorder()
     30 	app.ServeHTTP(response, request)
     31 	if response.Header().Get("Location") != "/en/" {
     32 		t.Fatalf("quality-weighted location = %q", response.Header().Get("Location"))
     33 	}
     34 	response = httptest.NewRecorder()
     35 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/en", nil))
     36 	if response.Code != http.StatusPermanentRedirect || response.Header().Get("Location") != "/en/" {
     37 		t.Fatalf("canonical language redirect = %d, %q", response.Code, response.Header().Get("Location"))
     38 	}
     39 
     40 	response = httptest.NewRecorder()
     41 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/en/", nil))
     42 	body := response.Body.String()
     43 	if response.Code != http.StatusOK || !strings.Contains(body, "Step 1: Install the wallet") {
     44 		t.Fatalf("landing response = %d, %q", response.Code, response.Body.String())
     45 	}
     46 	for _, expected := range []string{
     47 		`class="site-header"`, `class="heading-lockup"`, `class="demo-nav"`, `class="step-list"`,
     48 		`taler-logo-light.svg`, `taler-logo-dark.svg`, `aria-current="page"`, `class="link-icon"`,
     49 	} {
     50 		if !strings.Contains(body, expected) {
     51 			t.Errorf("landing response missing branded shell marker %q", expected)
     52 		}
     53 	}
     54 	if strings.Contains(body, "pure.css") || strings.Contains(body, "style=") {
     55 		t.Errorf("landing response still includes legacy presentation markup")
     56 	}
     57 	if response.Header().Get("Cache-Control") != "private, no-store" {
     58 		t.Fatalf("dynamic cache policy = %q", response.Header().Get("Cache-Control"))
     59 	}
     60 
     61 	response = httptest.NewRecorder()
     62 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/static/demo.css", nil))
     63 	if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "text/css; charset=utf-8" {
     64 		t.Fatalf("static response = %d, type %q", response.Code, response.Header().Get("Content-Type"))
     65 	}
     66 	if strings.Contains(response.Body.String(), "url(/static/") {
     67 		t.Fatalf("static stylesheet bypasses the forwarded prefix: %q", response.Body.String())
     68 	}
     69 	response = httptest.NewRecorder()
     70 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/static/blog.css", nil))
     71 	if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "url(/static/") {
     72 		t.Fatalf("blog stylesheet response = %d, body %q", response.Code, response.Body.String())
     73 	}
     74 	for _, asset := range []string{"/static/taler-logo-light.svg", "/static/taler-logo-dark.svg"} {
     75 		response = httptest.NewRecorder()
     76 		app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, asset, nil))
     77 		if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "image/svg+xml" {
     78 			t.Errorf("logo response for %s = %d, type %q", asset, response.Code, response.Header().Get("Content-Type"))
     79 		}
     80 	}
     81 
     82 	response = httptest.NewRecorder()
     83 	app.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/en/", nil))
     84 	if response.Code != http.StatusMethodNotAllowed {
     85 		t.Fatalf("POST language index response = %d", response.Code)
     86 	}
     87 }
     88 
     89 func TestPagesUseConfiguredPublicURLs(t *testing.T) {
     90 	app, err := New(Options{
     91 		Shop: "landing", Currency: "KUDOS",
     92 		PublicURLs: PublicURLs{
     93 			Landing: "https://landing.example/", Bank: "https://bank.example/",
     94 			Blog: "https://blog.example/", Donations: "https://donations.example/",
     95 		},
     96 	})
     97 	if err != nil {
     98 		t.Fatal(err)
     99 	}
    100 	response := httptest.NewRecorder()
    101 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/", nil))
    102 	if response.Code != http.StatusOK {
    103 		t.Fatalf("response status = %d", response.Code)
    104 	}
    105 	body := response.Body.String()
    106 	for _, expected := range []string{
    107 		`href="https://landing.example/de/"`,
    108 		`href="https://bank.example/?lang=de"`,
    109 		`href="https://bank.example"`,
    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 TestUnsupportedLocalesAreNotNegotiatedOrRouted(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.StatusNotFound {
    240 		t.Fatalf("unsupported locale response = %d", response.Code)
    241 	}
    242 }
    243 
    244 func TestErrorsKeepLocalizedSummary(t *testing.T) {
    245 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    246 		w.WriteHeader(http.StatusInternalServerError)
    247 		_, _ = w.Write([]byte(`{"hint":"technical backend detail","code":42}`))
    248 	}))
    249 	defer merchant.Close()
    250 	client, err := backend.New(merchant.URL, "secret-token:test")
    251 	if err != nil {
    252 		t.Fatal(err)
    253 	}
    254 	app, err := New(Options{
    255 		Shop: "donations", Currency: "KUDOS",
    256 		DonationBackends: map[string]*backend.Client{"gnunet": client, "taler": client, "tor": client},
    257 	})
    258 	if err != nil {
    259 		t.Fatal(err)
    260 	}
    261 	response := httptest.NewRecorder()
    262 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/donation/taler?order_id=order-1", nil))
    263 	body := response.Body.String()
    264 	if response.Code != http.StatusBadGateway || !strings.Contains(body, "<p>Backend-Anfrage fehlgeschlagen</p>") {
    265 		t.Fatalf("localized backend error response = %d, body %q", response.Code, body)
    266 	}
    267 	if strings.Contains(body, "<p>technical backend detail</p>") {
    268 		t.Fatalf("technical backend detail replaced localized summary: %q", body)
    269 	}
    270 	if !strings.Contains(body, "<pre>technical backend detail</pre>") {
    271 		t.Fatalf("technical backend detail was not rendered: %q", body)
    272 	}
    273 
    274 	response = httptest.NewRecorder()
    275 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/checkout", nil))
    276 	if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "Der Parameter donation_receiver ist erforderlich.") {
    277 		t.Fatalf("localized missing parameter response = %d, body %q", response.Code, response.Body.String())
    278 	}
    279 }
    280 
    281 func TestBlogStartsCookieCheckBeforeBackend(t *testing.T) {
    282 	merchant := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
    283 		t.Fatal("backend called before cookie check completed")
    284 	}))
    285 	defer merchant.Close()
    286 	client, err := backend.New(merchant.URL, "secret-token:test")
    287 	if err != nil {
    288 		t.Fatal(err)
    289 	}
    290 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client})
    291 	if err != nil {
    292 		t.Fatal(err)
    293 	}
    294 	articles := sortedArticles(app.articles["en"])
    295 	if len(articles) == 0 {
    296 		t.Fatal("no English articles loaded")
    297 	}
    298 	request := httptest.NewRequest(http.MethodGet, "/en/essay/"+articles[0].Slug, nil)
    299 	response := httptest.NewRecorder()
    300 	app.ServeHTTP(response, request)
    301 	if response.Code != http.StatusFound || !strings.Contains(response.Header().Get("Location"), "expect_state=yes") {
    302 		t.Fatalf("article response = %d, location %q", response.Code, response.Header().Get("Location"))
    303 	}
    304 	if !strings.Contains(response.Header().Get("Set-Cookie"), sessionCookieName+"=") {
    305 		t.Fatalf("session cookie missing: %q", response.Header().Get("Set-Cookie"))
    306 	}
    307 }
    308 
    309 func TestBlogCreatesCurrentMerchantOrder(t *testing.T) {
    310 	var posted backend.PostOrderRequest
    311 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    312 		switch {
    313 		case r.Method == http.MethodGet && r.URL.Path == "/private/orders":
    314 			_ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{}})
    315 		case r.Method == http.MethodPost && r.URL.Path == "/private/orders":
    316 			_ = json.NewDecoder(r.Body).Decode(&posted)
    317 			_ = json.NewEncoder(w).Encode(map[string]any{"order_id": "order-1", "token": "claim-1"})
    318 		case r.Method == http.MethodGet && r.URL.Path == "/private/orders/order-1":
    319 			_ = json.NewEncoder(w).Encode(map[string]any{
    320 				"order_status": "paid", "refunded": false,
    321 				"contract_terms": map[string]any{
    322 					"extra":           map[string]any{"article_name": r.Header.Get("X-Test-Article")},
    323 					"refund_deadline": map[string]any{"t_s": time.Now().Add(time.Minute).Unix()},
    324 				},
    325 			})
    326 		default:
    327 			http.Error(w, "unexpected request", http.StatusNotFound)
    328 		}
    329 	}))
    330 	defer merchant.Close()
    331 	client, err := backend.New(merchant.URL, "secret-token:test")
    332 	if err != nil {
    333 		t.Fatal(err)
    334 	}
    335 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client, EnableTokens: true})
    336 	if err != nil {
    337 		t.Fatal(err)
    338 	}
    339 	article := sortedArticles(app.articles["de"])[0]
    340 	articlePath := "/de/essay/" + article.Slug
    341 	request := httptest.NewRequest(http.MethodGet, articlePath, nil)
    342 	request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "browser-session"})
    343 	response := httptest.NewRecorder()
    344 	app.ServeHTTP(response, request)
    345 	if response.Code != http.StatusFound || !strings.Contains(response.Header().Get("Location"), "/orders/order-1?") {
    346 		t.Fatalf("purchase response = %d, location %q", response.Code, response.Header().Get("Location"))
    347 	}
    348 	if posted.SessionID != "browser-session" || !posted.CreateToken {
    349 		t.Fatalf("post-order envelope = %#v", posted)
    350 	}
    351 	if posted.Order.Version != 1 {
    352 		t.Fatalf("order version = %d", posted.Order.Version)
    353 	}
    354 	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" {
    355 		t.Fatalf("localized order choices = %#v", posted.Order.Choices)
    356 	}
    357 	if posted.RefundDelay.Microseconds != time.Hour.Microseconds() {
    358 		t.Fatalf("refund delay = %d microseconds", posted.RefundDelay.Microseconds)
    359 	}
    360 	if posted.Order.WireTransferDeadline.Seconds <= posted.Order.PayDeadline.Seconds+int64(time.Hour/time.Second) {
    361 		t.Fatalf("wire deadline does not allow the advertised refund window: %#v", posted.Order)
    362 	}
    363 }
    364 
    365 func TestDonationReceiptOrderAndFulfillment(t *testing.T) {
    366 	var posted backend.PostOrderRequest
    367 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    368 		switch r.Method {
    369 		case http.MethodPost:
    370 			_ = json.NewDecoder(r.Body).Decode(&posted)
    371 			_ = json.NewEncoder(w).Encode(map[string]any{"order_id": "donation-1"})
    372 		case http.MethodGet:
    373 			_ = json.NewEncoder(w).Encode(map[string]any{
    374 				"order_status": "paid",
    375 				"contract_terms": map[string]any{"extra": map[string]any{
    376 					"receiver": "taler", "amount": "KUDOS:1", "donor": "<script>alert(1)</script>",
    377 				}},
    378 			})
    379 		}
    380 	}))
    381 	defer merchant.Close()
    382 	client, err := backend.New(merchant.URL, "secret-token:test")
    383 	if err != nil {
    384 		t.Fatal(err)
    385 	}
    386 	app, err := New(Options{
    387 		Shop: "donations", Currency: "KUDOS", DonauURL: "https://donau.example/",
    388 		DonationBackends: map[string]*backend.Client{"gnunet": client, "taler": client, "tor": client},
    389 	})
    390 	if err != nil {
    391 		t.Fatal(err)
    392 	}
    393 	request := httptest.NewRequest(http.MethodGet, "http://demo.example/en/donate?donation_receiver=taler&donation_amount=KUDOS%3A1&donation_donor=Alice&payment_system=taler", nil)
    394 	request.Header.Set("X-Forwarded-Prefix", "/merchant")
    395 	request.Header.Set("X-Forwarded-Proto", "https")
    396 	response := httptest.NewRecorder()
    397 	app.ServeHTTP(response, request)
    398 	if response.Code != http.StatusFound || response.Header().Get("Location") != "/merchant/en/donation/taler?order_id=donation-1" {
    399 		t.Fatalf("donation response = %d, location %q", response.Code, response.Header().Get("Location"))
    400 	}
    401 	if !strings.HasPrefix(posted.Order.FulfillmentURL, "https://demo.example/merchant/en/donation/taler?") {
    402 		t.Fatalf("fulfillment URL = %q", posted.Order.FulfillmentURL)
    403 	}
    404 
    405 	request = httptest.NewRequest(http.MethodGet, "http://demo.example/de/donate?donation_receiver=taler&donation_amount=KUDOS%3A1&donation_donor=Alice&payment_system=taler", nil)
    406 	response = httptest.NewRecorder()
    407 	app.ServeHTTP(response, request)
    408 	if response.Code != http.StatusFound || posted.Order.Summary != "Spende an taler (mit Spendenquittung)" {
    409 		t.Fatalf("localized donation order response = %d, order = %#v", response.Code, posted.Order)
    410 	}
    411 	output := posted.Order.Choices[0].Outputs[0]
    412 	if output.Type != "tax-receipt" || output.TokenFamilySlug != "" || output.Amount != "" {
    413 		t.Fatalf("tax receipt output = %#v", output)
    414 	}
    415 
    416 	request = httptest.NewRequest(http.MethodGet, "http://demo.example/en/donation/taler?order_id=donation-1", nil)
    417 	response = httptest.NewRecorder()
    418 	app.ServeHTTP(response, request)
    419 	body := response.Body.String()
    420 	if response.Code != http.StatusOK || strings.Contains(body, "<script>alert(1)</script>") || !strings.Contains(body, "&lt;script&gt;alert(1)&lt;/script&gt;") {
    421 		t.Fatalf("fulfillment response = %d, body %q", response.Code, response.Body.String())
    422 	}
    423 	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`} {
    424 		if !strings.Contains(body, marker) {
    425 			t.Errorf("fulfillment response missing receipt action %q", marker)
    426 		}
    427 	}
    428 }
    429 
    430 func TestArticleReferencesBundledSupplementalData(t *testing.T) {
    431 	var articleName string
    432 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    433 		if r.Method != http.MethodGet {
    434 			http.Error(w, "unexpected request", http.StatusMethodNotAllowed)
    435 			return
    436 		}
    437 		_ = json.NewEncoder(w).Encode(backend.OrderStatusResponse{
    438 			OrderStatus: "paid",
    439 			ContractTerms: backend.ContractTerms{
    440 				Extra:          backend.OrderExtra{ArticleName: articleName},
    441 				RefundDeadline: backend.Timestamp{Seconds: time.Now().Add(time.Minute).Unix()},
    442 			},
    443 		})
    444 	}))
    445 	defer merchant.Close()
    446 	client, err := backend.New(merchant.URL, "secret-token:test")
    447 	if err != nil {
    448 		t.Fatal(err)
    449 	}
    450 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client})
    451 	if err != nil {
    452 		t.Fatal(err)
    453 	}
    454 	var article Article
    455 	for _, candidate := range app.articles["en"] {
    456 		if candidate.ExtraFiles["category.png"] != "" {
    457 			article = candidate
    458 			break
    459 		}
    460 	}
    461 	if article.Slug == "" {
    462 		t.Fatal("no English article references category.png")
    463 	}
    464 	articleName = article.Slug
    465 	articlePath := "/en/essay/" + url.PathEscape(article.Slug)
    466 	request := httptest.NewRequest(http.MethodGet, articlePath, nil)
    467 	request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "browser-session"})
    468 	request.AddCookie(&http.Cookie{Name: orderCookieName, Value: "paid-order"})
    469 	response := httptest.NewRecorder()
    470 	app.ServeHTTP(response, request)
    471 	if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), url.PathEscape(article.Slug)+"/data/category.png") {
    472 		t.Fatalf("article response = %d, supplemental reference missing", response.Code)
    473 	}
    474 
    475 	response = httptest.NewRecorder()
    476 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, articlePath+"/data/category.png", nil))
    477 	if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "image/png" || !strings.HasPrefix(response.Body.String(), "\x89PNG") {
    478 		t.Fatalf("supplemental response = %d, type %q", response.Code, response.Header().Get("Content-Type"))
    479 	}
    480 
    481 	response = httptest.NewRecorder()
    482 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, articlePath+"/data/unused.jpg", nil))
    483 	if response.Code != http.StatusNotFound {
    484 		t.Fatalf("unreferenced supplemental response = %d", response.Code)
    485 	}
    486 }
    487 
    488 func TestBlogRefundUsesMethodAwareRoute(t *testing.T) {
    489 	var refund backend.RefundRequest
    490 	choiceIndex := 0
    491 	merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    492 		switch {
    493 		case r.Method == http.MethodGet && r.URL.Path == "/private/orders/order-1":
    494 			_ = json.NewEncoder(w).Encode(backend.OrderStatusResponse{
    495 				OrderStatus:    "paid",
    496 				OrderStatusURL: "https://merchant.example/orders/order-1",
    497 				ChoiceIndex:    &choiceIndex,
    498 				ContractTerms: backend.ContractTerms{
    499 					Version:        1,
    500 					Choices:        []backend.OrderChoice{{Amount: "KUDOS:0.5"}, {Amount: "KUDOS:10"}, {Amount: "KUDOS:0"}},
    501 					RefundDeadline: backend.Timestamp{Seconds: time.Now().Add(time.Minute).Unix()},
    502 				},
    503 			})
    504 		case r.Method == http.MethodPost && r.URL.Path == "/private/orders/order-1/refund":
    505 			_ = json.NewDecoder(r.Body).Decode(&refund)
    506 			_ = json.NewEncoder(w).Encode(map[string]any{})
    507 		default:
    508 			http.Error(w, "unexpected request", http.StatusNotFound)
    509 		}
    510 	}))
    511 	defer merchant.Close()
    512 	client, err := backend.New(merchant.URL, "secret-token:test")
    513 	if err != nil {
    514 		t.Fatal(err)
    515 	}
    516 	app, err := New(Options{Shop: "blog", Currency: "KUDOS", BlogBackend: client})
    517 	if err != nil {
    518 		t.Fatal(err)
    519 	}
    520 	request := httptest.NewRequest(http.MethodPost, "/de/refund/order-1", nil)
    521 	request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "browser-session"})
    522 	response := httptest.NewRecorder()
    523 	app.ServeHTTP(response, request)
    524 	if response.Code != http.StatusFound || response.Header().Get("Location") != "https://merchant.example/orders/order-1" {
    525 		t.Fatalf("refund response = %d, location %q", response.Code, response.Header().Get("Location"))
    526 	}
    527 	if refund.Refund != "KUDOS:0.5" || refund.Reason != "Rückerstattung in der Demo" {
    528 		t.Fatalf("refund request = %#v", refund)
    529 	}
    530 
    531 	response = httptest.NewRecorder()
    532 	app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/de/refund/order-1", nil))
    533 	if response.Code != http.StatusMethodNotAllowed {
    534 		t.Fatalf("GET refund response = %d", response.Code)
    535 	}
    536 }
    537 
    538 func TestRefundUsesSelectedOrderChoice(t *testing.T) {
    539 	app := &App{opts: Options{Currency: "KUDOS"}}
    540 	deadline := backend.Timestamp{Seconds: time.Now().Add(time.Minute).Unix()}
    541 	choices := []backend.OrderChoice{{Amount: "KUDOS:0.5"}, {Amount: "KUDOS:10"}, {Amount: "KUDOS:0"}}
    542 	for index, want := range []string{"KUDOS:0.5", "KUDOS:10", ""} {
    543 		choiceIndex := index
    544 		got, ok := app.refundAmount(backend.OrderStatusResponse{
    545 			ChoiceIndex: &choiceIndex,
    546 			ContractTerms: backend.ContractTerms{
    547 				Version: 1, Choices: choices, RefundDeadline: deadline,
    548 			},
    549 		})
    550 		if got != want || ok != (want != "") {
    551 			t.Errorf("choice %d refund = %q, %v; want %q", index, got, ok, want)
    552 		}
    553 	}
    554 	invalid := len(choices)
    555 	if amount, ok := app.refundAmount(backend.OrderStatusResponse{
    556 		ChoiceIndex: &invalid,
    557 		ContractTerms: backend.ContractTerms{
    558 			Version: 1, Choices: choices, RefundDeadline: deadline,
    559 		},
    560 	}); ok || amount != "" {
    561 		t.Fatalf("invalid choice refund = %q, %v", amount, ok)
    562 	}
    563 }