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&donation_donor=Alice&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, "<script>alert(1)</script>") { 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 }