diff --git a/.env.example b/.env.example index b44409b..b4b0eda 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ SENTRY_ENVIRONMENT=local SENTRY_TRACES_SAMPLE_RATE=0.0 SENTRY_PROFILES_SAMPLE_RATE=0.0 SENTRY_RELEASE= +SENTRY_AUTH_TOKEN= +SENTRY_ORG= +SENTRY_PROJECT= +SENTRY_URL=https://logsder.fotospiel.app VITE_SENTRY_DSN= VITE_SENTRY_ENV=local diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index b559fd5..e46fe5d 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -105,6 +105,10 @@ class Handler extends ExceptionHandler private function configureSentryScope(): void { + if (! function_exists('\Sentry\configureScope')) { + return; + } + \Sentry\configureScope(function (Scope $scope): void { $user = Auth::user(); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 0db529c..9c25ff1 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -34,11 +34,14 @@ class Kernel extends HttpKernel \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\EnsureXsrfCookie::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \App\Http\Middleware\SetLocaleFromRequest::class, \App\Http\Middleware\HandleInertiaRequests::class, \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, + \App\Http\Middleware\ContentSecurityPolicy::class, + \App\Http\Middleware\ResponseSecurityHeaders::class, ], 'api' => [ diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php new file mode 100644 index 0000000..c90a22b --- /dev/null +++ b/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + 'XSRF-TOKEN', + ]; +} diff --git a/app/Http/Middleware/EnsureXsrfCookie.php b/app/Http/Middleware/EnsureXsrfCookie.php new file mode 100644 index 0000000..fac721c --- /dev/null +++ b/app/Http/Middleware/EnsureXsrfCookie.php @@ -0,0 +1,34 @@ +isMethod('GET') && ! $request->cookies->has('XSRF-TOKEN')) { + $response->headers->setCookie( + cookie( + name: 'XSRF-TOKEN', + value: csrf_token(), + minutes: 120, + path: '/', + domain: null, + secure: $request->isSecure(), + httpOnly: false, + raw: false, + sameSite: 'lax' + ) + ); + } + + return $response; + } +} diff --git a/app/Http/Middleware/ResponseSecurityHeaders.php b/app/Http/Middleware/ResponseSecurityHeaders.php index ba8db74..13eea80 100644 --- a/app/Http/Middleware/ResponseSecurityHeaders.php +++ b/app/Http/Middleware/ResponseSecurityHeaders.php @@ -26,7 +26,9 @@ class ResponseSecurityHeaders } } - if ($request->isSecure() && ! app()->environment(['local', 'testing'])) { + $forceHsts = (bool) config('security_headers.force_hsts', false); + + if ($forceHsts || ($request->isSecure() && ! app()->environment(['local', 'testing']))) { $hsts = 'max-age=31536000; includeSubDomains'; if (! $response->headers->has('Strict-Transport-Security')) { $response->headers->set('Strict-Transport-Security', $hsts); diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index bcc920c..4cdbd1d 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -11,6 +11,10 @@ x-app-env: &app-env SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-0.0} SENTRY_PROFILES_SAMPLE_RATE: ${SENTRY_PROFILES_SAMPLE_RATE:-0.0} SENTRY_RELEASE: ${SENTRY_RELEASE:-} + SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN:-} + SENTRY_ORG: ${SENTRY_ORG:-} + SENTRY_PROJECT: ${SENTRY_PROJECT:-} + SENTRY_URL: ${SENTRY_URL:-https://logsder.fotospiel.app} VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-} VITE_SENTRY_ENV: ${VITE_SENTRY_ENV:-} VITE_SENTRY_TRACES_SAMPLE_RATE: ${VITE_SENTRY_TRACES_SAMPLE_RATE:-} diff --git a/docs/ops/monitoring-glitchtip.md b/docs/ops/monitoring-glitchtip.md index be78569..7a0bb78 100644 --- a/docs/ops/monitoring-glitchtip.md +++ b/docs/ops/monitoring-glitchtip.md @@ -26,6 +26,7 @@ Notes: - DSN is Sentry-format; projects are created in GlitchTip UI. Use per-environment DSNs when possible. - Keep sampling conservative in production to avoid noise and cost. - If you do **not** want PII, set `SENTRY_SEND_DEFAULT_PII=false` in Laravel config (or handle in `config/sentry.php`). +- For source map uploads via the Vite plugin, also set: `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_URL=https://logsder.fotospiel.app`. ## 2) Backend (Laravel 12, PHP 8.3) diff --git a/playwright-report/data/5178eafb1d619d71ea69fb93e7880ef9fb70cf4e.md b/playwright-report/data/5178eafb1d619d71ea69fb93e7880ef9fb70cf4e.md new file mode 100644 index 0000000..b76649a --- /dev/null +++ b/playwright-report/data/5178eafb1d619d71ea69fb93e7880ef9fb70cf4e.md @@ -0,0 +1,183 @@ +# Page snapshot + +```yaml +- generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: + - link "Fotospiel App Logo Die Fotospiel App" [ref=e6] [cursor=pointer]: + - /url: /de + - img "Fotospiel App Logo" [ref=e7] + - generic [ref=e8]: Die Fotospiel App + - navigation [ref=e9]: + - link "So funktioniert's" [ref=e10] [cursor=pointer]: + - /url: /de/so-funktionierts + - link "Packages" [ref=e11] [cursor=pointer]: + - /url: /de/packages + - generic [ref=e13]: + - text: Anlässe + - img [ref=e14] + - link "Blog" [ref=e16] [cursor=pointer]: + - /url: /de/blog + - link "Kontakt" [ref=e17] [cursor=pointer]: + - /url: /de/kontakt + - generic [ref=e18]: + - link "Jetzt ausprobieren" [ref=e19] [cursor=pointer]: + - /url: /de/demo + - button "Einstellungen" [ref=e20]: + - img + - generic [ref=e21]: Einstellungen + - main [ref=e22]: + - generic [ref=e25]: + - generic [ref=e26]: + - button "Weiter" [disabled] + - generic [ref=e27]: + - progressbar [ref=e28] + - generic [ref=e30]: + - generic [ref=e31]: + - generic [ref=e32]: "1" + - generic [ref=e33]: + - paragraph [ref=e34]: Paket wählen + - paragraph [ref=e35]: Auswahl und Vergleich + - generic [ref=e36]: + - generic [ref=e37]: "2" + - generic [ref=e38]: + - paragraph [ref=e39]: Konto + - paragraph [ref=e40]: Anmelden oder Registrieren + - paragraph [ref=e41]: Erstellen Sie ein Konto oder melden Sie sich an, um mit dem Kauf fortzufahren. + - generic [ref=e42]: + - generic [ref=e43]: "3" + - generic [ref=e44]: + - paragraph [ref=e45]: Zahlung + - paragraph [ref=e46]: Sichere Zahlung + - generic [ref=e47]: + - generic [ref=e48]: "4" + - generic [ref=e49]: + - paragraph [ref=e50]: Bestätigung + - paragraph [ref=e51]: Alles erledigt! + - generic [ref=e53]: + - generic [ref=e55]: + - button "Registrieren" [ref=e56] + - button "Anmelden" [ref=e57] + - generic [ref=e58]: + - button "Mit Google fortfahren" [ref=e59]: + - img + - text: Mit Google fortfahren + - button "Warum Google?" [ref=e60]: + - text: Warum Google? + - img [ref=e61] + - generic [ref=e64]: + - generic [ref=e65]: + - generic [ref=e66]: + - generic [ref=e67]: Vorname * + - generic [ref=e68]: + - img [ref=e69] + - textbox "Vorname *" [ref=e72]: + - /placeholder: Vorname + - text: Play + - generic [ref=e73]: + - generic [ref=e74]: Nachname * + - generic [ref=e75]: + - img [ref=e76] + - textbox "Nachname *" [ref=e79]: + - /placeholder: Nachname + - text: Wright + - generic [ref=e80]: + - generic [ref=e81]: E-Mail-Adresse * + - generic [ref=e82]: + - img [ref=e83] + - textbox "E-Mail-Adresse *" [ref=e86]: + - /placeholder: beispiel@email.de + - text: playwright-buyer@example.com + - generic [ref=e87]: + - generic [ref=e88]: Adresse * + - generic [ref=e89]: + - img [ref=e90] + - textbox "Adresse *" [ref=e93]: + - /placeholder: Straße Hausnummer, PLZ Ort + - text: Teststrasse 1, 12345 Berlin + - generic [ref=e94]: + - generic [ref=e95]: Telefonnummer * + - generic [ref=e96]: + - img [ref=e97] + - textbox "Telefonnummer *" [ref=e99]: + - /placeholder: +49 170 1234567 + - text: "+49123456789" + - generic [ref=e100]: + - generic [ref=e101]: Username * + - generic [ref=e102]: + - img [ref=e103] + - textbox "Username *" [ref=e106]: + - /placeholder: z. B. hochzeit_julia + - text: playwright-buyer + - generic [ref=e107]: + - generic [ref=e108]: Passwort * + - generic [ref=e109]: + - img [ref=e110] + - textbox "Passwort *" [ref=e113]: + - /placeholder: Mindestens 8 Zeichen + - text: Password123! + - generic [ref=e114]: + - generic [ref=e115]: Passwort bestätigen * + - generic [ref=e116]: + - img [ref=e117] + - textbox "Passwort bestätigen *" [active] [ref=e120]: + - /placeholder: Passwort erneut eingeben + - text: Password123! + - generic [ref=e121]: + - checkbox "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten. Datenschutzerklärung ." [ref=e122] + - generic [ref=e123]: + - text: Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten. + - button "Datenschutzerklärung" [ref=e124] [cursor=pointer] + - text: . + - button "Registrieren" [disabled] [ref=e125] + - generic [ref=e126]: + - button "Zurück" [ref=e127] + - button "Weiter" [disabled] + - contentinfo [ref=e128]: + - generic [ref=e129]: + - generic [ref=e130]: + - generic [ref=e131]: + - generic [ref=e132]: + - img "Fotospiel App Logo" [ref=e133] + - generic [ref=e134]: + - link "Die Fotospiel App" [ref=e135] [cursor=pointer]: + - /url: /de + - paragraph [ref=e136]: S.E.B. Fotografie + - generic [ref=e137]: + - paragraph [ref=e138]: Gutscheine + - link "Gutscheine" [ref=e139] [cursor=pointer]: + - /url: /de/gutschein + - generic [ref=e140]: + - heading "Rechtliches" [level=3] [ref=e141] + - list [ref=e142]: + - listitem [ref=e143]: + - link "Impressum" [ref=e144] [cursor=pointer]: + - /url: /de/impressum + - listitem [ref=e145]: + - link "Datenschutzerklärung" [ref=e146] [cursor=pointer]: + - /url: /de/datenschutz + - listitem [ref=e147]: + - link "Allgemeine Geschäftsbedingungen" [ref=e148] [cursor=pointer]: + - /url: /de/agb + - listitem [ref=e149]: + - link "Widerrufsbelehrung" [ref=e150] [cursor=pointer]: + - /url: /de/widerrufsbelehrung + - listitem [ref=e151]: + - button "Cookie-Einstellungen" [ref=e152] + - generic [ref=e153]: + - heading "Social" [level=3] [ref=e154] + - list [ref=e155]: + - listitem [ref=e156]: + - link "Kontakt" [ref=e157] [cursor=pointer]: + - /url: /de/kontakt + - listitem [ref=e158]: + - link "Instagram" [ref=e159] [cursor=pointer]: + - /url: "#" + - listitem [ref=e160]: + - link "Facebook" [ref=e161] [cursor=pointer]: + - /url: "#" + - listitem [ref=e162]: + - link "YouTube" [ref=e163] [cursor=pointer]: + - /url: "#" + - generic [ref=e164]: © 2025 Die Fotospiel App – Alle Rechte vorbehalten. +``` \ No newline at end of file diff --git a/playwright-report/data/5c5c999ecdc2c5044c78e0bc5111b537bb29f8ea.webm b/playwright-report/data/5c5c999ecdc2c5044c78e0bc5111b537bb29f8ea.webm new file mode 100644 index 0000000..2bf036b Binary files /dev/null and b/playwright-report/data/5c5c999ecdc2c5044c78e0bc5111b537bb29f8ea.webm differ diff --git a/playwright-report/data/6240a68b897dd5f45413f9c5c6fa24e3a5cc05fa.png b/playwright-report/data/6240a68b897dd5f45413f9c5c6fa24e3a5cc05fa.png new file mode 100644 index 0000000..45f7029 Binary files /dev/null and b/playwright-report/data/6240a68b897dd5f45413f9c5c6fa24e3a5cc05fa.png differ diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..8ca3daf --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index fb96743..e98ed5b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: 'http://fotospiel-app.test', + baseURL: process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app', trace: 'on-first-retry', headless: true, screenshot: 'only-on-failure', diff --git a/tests/Feature/PaddleWebhookControllerTest.php b/tests/Feature/PaddleWebhookControllerTest.php index 2a739e6..430b089 100644 --- a/tests/Feature/PaddleWebhookControllerTest.php +++ b/tests/Feature/PaddleWebhookControllerTest.php @@ -68,6 +68,45 @@ class PaddleWebhookControllerTest extends TestCase ); } + public function test_duplicate_transaction_is_idempotent(): void + { + config(['paddle.webhook_secret' => 'test_secret']); + + [$tenant, $package, $session] = $this->prepareSession(); + + $payload = [ + 'event_type' => 'transaction.completed', + 'data' => [ + 'id' => 'txn_dup', + 'status' => 'completed', + 'checkout_id' => 'chk_dup', + 'metadata' => [ + 'checkout_session_id' => $session->id, + 'tenant_id' => (string) $tenant->id, + 'package_id' => (string) $package->id, + ], + ], + ]; + + $signature = hash_hmac('sha256', json_encode($payload), 'test_secret'); + + $first = $this->withHeader('Paddle-Webhook-Signature', $signature) + ->postJson('/paddle/webhook', $payload); + + $first->assertOk()->assertJson(['status' => 'processed']); + + $second = $this->withHeader('Paddle-Webhook-Signature', $signature) + ->postJson('/paddle/webhook', $payload); + + $second->assertStatus(200)->assertJson(['status' => 'processed']); + + $this->assertSame(1, PackagePurchase::query()->count()); + + $session->refresh(); + $this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status); + $this->assertEquals('txn_dup', Arr::get($session->provider_metadata, 'paddle_transaction_id')); + } + public function test_rejects_invalid_signature(): void { config(['paddle.webhook_secret' => 'secret']); diff --git a/tests/Feature/SecurityHeadersTest.php b/tests/Feature/SecurityHeadersTest.php new file mode 100644 index 0000000..e0d60c5 --- /dev/null +++ b/tests/Feature/SecurityHeadersTest.php @@ -0,0 +1,103 @@ +environment(); + app()->detectEnvironment(fn () => 'production'); + config([ + 'app.debug' => false, + 'app.url' => 'https://test-y0k0.fotospiel.app', + 'security_headers.force_hsts' => true, + 'cors.allowed_origins' => ['https://test-y0k0.fotospiel.app'], + 'cors.paths' => ['*'], + ]); + + Route::middleware('web')->get('/__test/marketing', fn () => response('ok')); + Route::middleware('web')->get('/__test/auth', fn () => response('ok')); + + try { + $response = $this->withServerVariables([ + 'HTTPS' => 'on', + 'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app', + ])->get('/__test/marketing'); + + $response->assertOk(); + $response->assertHeader('X-Frame-Options', 'SAMEORIGIN'); + $response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + $response->assertHeader('Content-Security-Policy'); + $response->assertHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + $response->assertCookie('XSRF-TOKEN'); + + $login = $this->withServerVariables([ + 'HTTPS' => 'on', + 'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app', + ])->get('/__test/auth'); + + $login->assertOk(); + $login->assertHeader('Content-Security-Policy'); + $login->assertHeader('X-Frame-Options', 'SAMEORIGIN'); + $login->assertCookie('XSRF-TOKEN'); + } finally { + app()->detectEnvironment(fn () => $originalEnv); + } + } + + public function test_cors_headers_present_for_api_requests_and_error_responses_keep_headers(): void + { + $originalEnv = app()->environment(); + app()->detectEnvironment(fn () => 'production'); + $corsConfig = config('cors'); + $corsConfig['paths'] = ['*']; + $corsConfig['allowed_origins'] = ['https://test-y0k0.fotospiel.app']; + + config([ + 'app.debug' => false, + 'app.url' => 'https://test-y0k0.fotospiel.app', + 'cors' => $corsConfig, + ]); + + Route::middleware('web')->get('/__test/error', fn () => throw new \RuntimeException('boom')); + + try { + $corsMiddleware = new HandleCors(app(), new CorsService(config('cors'))); + $this->assertContains('*', config('cors.paths')); + $this->assertContains('https://test-y0k0.fotospiel.app', config('cors.allowed_origins')); + $corsRequest = Request::create( + uri: '/api/__test/cors', + method: 'OPTIONS', + server: [ + 'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app', + 'HTTPS' => 'on', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ], + ); + + $this->assertSame('https://test-y0k0.fotospiel.app', $corsRequest->headers->get('Origin')); + + $corsResponse = $corsMiddleware->handle($corsRequest, fn () => response()->noContent()); + + $this->assertSame('https://test-y0k0.fotospiel.app', $corsResponse->headers->get('Access-Control-Allow-Origin')); + + $error = $this->withServerVariables([ + 'HTTPS' => 'on', + ])->get('/__test/error'); + + $error->assertStatus(500); + $error->assertHeader('Content-Security-Policy'); + $error->assertHeader('X-Frame-Options', 'SAMEORIGIN'); + } finally { + app()->detectEnvironment(fn () => $originalEnv); + } + } +} diff --git a/tests/Feature/SentryReportingTest.php b/tests/Feature/SentryReportingTest.php new file mode 100644 index 0000000..4aa1fcb --- /dev/null +++ b/tests/Feature/SentryReportingTest.php @@ -0,0 +1,58 @@ + 'https://example@sentry.test/1']); + } + + public function test_reports_server_errors_to_sentry(): void + { + $fake = new class + { + public int $captured = 0; + + public function captureException(mixed $exception = null): void + { + $this->captured++; + } + }; + + $this->app->instance('sentry', $fake); + + $handler = $this->app->make(\App\Exceptions\Handler::class); + + $handler->report(new \RuntimeException('boom')); + + $this->assertSame(1, $fake->captured, 'Sentry should receive server errors'); + } + + public function test_ignores_client_errors_for_sentry(): void + { + $fake = new class + { + public int $captured = 0; + + public function captureException(mixed $exception = null): void + { + $this->captured++; + } + }; + + $this->app->instance('sentry', $fake); + + $handler = $this->app->make(\App\Exceptions\Handler::class); + + $handler->report(new HttpException(404)); + + $this->assertSame(0, $fake->captured, 'Sentry should ignore 4xx errors'); + } +} diff --git a/tests/ui/auth/login-bruteforce.test.ts b/tests/ui/auth/login-bruteforce.test.ts new file mode 100644 index 0000000..4fb9005 --- /dev/null +++ b/tests/ui/auth/login-bruteforce.test.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; + +const shouldRun = process.env.E2E_BRUTEFORCE === '1'; + +test.describe('Login brute-force throttle', () => { + test.skip(!shouldRun, 'Set E2E_BRUTEFORCE=1 to run brute-force throttle check against the live/staging site.'); + + test('repeated bad logins eventually trigger throttle', async ({ request }) => { + const attemptPayload = { + email: 'nonexistent-user@example.com', + password: 'WrongPass123!', + }; + + const statuses: number[] = []; + const bodies: string[] = []; + + for (let i = 0; i < 8; i += 1) { + const response = await request.post('/login', { + form: attemptPayload, + failOnStatusCode: false, + }); + statuses.push(response.status()); + bodies.push(await response.text()); + } + + const hitThrottle = statuses.includes(429) || bodies.some((body) => /too many.+attempt/i.test(body)); + + expect(hitThrottle).toBeTruthy(); + }); +}); diff --git a/tests/ui/purchase/contact-form-spam.test.ts b/tests/ui/purchase/contact-form-spam.test.ts new file mode 100644 index 0000000..d94b883 --- /dev/null +++ b/tests/ui/purchase/contact-form-spam.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; + +const shouldRun = process.env.E2E_CONTACT_SPAM === '1'; +const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; + +test.describe('Marketing contact form spam/throttle', () => { + test.skip(!shouldRun, 'Set E2E_CONTACT_SPAM=1 to run contact spam/throttle check on staging.'); + + test('honeypot rejects bot submission and throttling kicks in', async ({ page }) => { + await page.goto(`${baseUrl}/de#contact`); + + const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i }); + if (await acceptCookies.isVisible()) { + await acceptCookies.click(); + } + + // Fill visible fields + await page.fill('input[name="name"]', 'Spam Bot'); + await page.fill('input[name="email"]', 'spam@example.com'); + await page.fill('textarea[name="message"]', 'Test spam message'); + + // Trip honeypot + await page.$eval('input[name="nickname"]', (el: HTMLInputElement) => { + el.value = 'bot-field'; + }); + + const submit = page.getByRole('button', { name: /senden|absenden|submit/i }).first(); + await submit.click(); + + await expect(page.locator('text=/error|ungültig|invalid/i')).toBeVisible(); + + // Rapid resubmits to trigger throttle (best-effort) + for (let i = 0; i < 5; i += 1) { + await submit.click(); + } + + // Either error message or no success flash should be present + const success = page.locator('text=/Danke|Erfolg|success/i'); + await expect(success).not.toBeVisible({ timeout: 1000 }); + }); +}); diff --git a/tests/ui/purchase/marketing-package-flow.test.ts b/tests/ui/purchase/marketing-package-flow.test.ts deleted file mode 100644 index 620e14f..0000000 --- a/tests/ui/purchase/marketing-package-flow.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { execSync } from 'child_process'; // Für artisan seed - -test.describe('Marketing Package Flow: Auswahl → Registrierung → Kauf (Free & Paid)', () => { - test.beforeAll(async () => { - // Seed Test-Tenant (einmalig) - execSync('php artisan tenant:add-dummy --email=test@example.com --password=password123 --first_name=Test --last_name=User --address="Teststr. 1" --phone="+49123"'); - // Mock Verifizierung: Update DB (in Test-Env) - execSync('php artisan tinker --execute="App\\Models\\User::where(\'email\', \'test@example.com\')->update([\'email_verified_at\' => now()]);"'); - }); - - test('Free-Paket-Flow mit Wizard (ID=1, Starter, eingeloggter User)', async ({ page }) => { - // Login first - await page.goto('http://localhost:8000/de/login'); - await page.fill('[name="email"]', 'test@example.com'); - await page.fill('[name="password"]', 'password123'); - await page.getByRole('button', { name: 'Anmelden' }).click(); - await expect(page).toHaveURL(/\/dashboard/); - - // Go to Wizard - await page.goto('http://localhost:8000/purchase-wizard/10'); - await expect(page.locator('text=Sie sind bereits eingeloggt')).toBeVisible(); - await page.getByRole('button', { name: 'Weiter zum Zahlungsschritt' }).click(); - await expect(page).toHaveURL(/\/purchase-wizard\/1/); // Next step - await page.screenshot({ path: 'wizard-logged-in.png', fullPage: true }); - - // Payment (Free: Success) - await expect(page.locator('text=Free package assigned')).toBeVisible(); - await page.screenshot({ path: 'wizard-free-success.png', fullPage: true }); - }); - - test('Wizard Login-Fehler mit Toast', async ({ page }) => { - await page.goto('http://localhost:8000/purchase-wizard/10'); - // Switch to Login - await page.getByRole('button', { name: 'Anmelden' }).click(); - await page.fill('[name="email"]', 'wrong@example.com'); - await page.fill('[name="password"]', 'wrong'); - await page.getByRole('button', { name: 'Anmelden' }).click(); - await expect(page.locator('[data-testid="toast"]')).toBeVisible(); // Toast for error - await expect(page.locator('text=Ungültige Anmeldedaten')).toBeVisible(); // Inline error - await page.screenshot({ path: 'wizard-login-error.png', fullPage: true }); - }); - - test('Wizard Registrierung-Fehler mit Toast', async ({ page }) => { - await page.goto('http://localhost:8000/purchase-wizard/10'); - // Reg form with invalid data - await page.fill('[name="email"]', 'invalid'); - await page.getByRole('button', { name: 'Registrieren' }).click(); - await expect(page.locator('[data-testid="toast"]')).toBeVisible(); - await expect(page.locator('text=Das E-Mail muss eine gültige E-Mail-Adresse sein')).toBeVisible(); - await page.screenshot({ path: 'wizard-reg-error.png', fullPage: true }); - }); - - test('Wizard Erfolgreiche Reg mit Success-Message', async ({ page }) => { - await page.goto('http://localhost:8000/purchase-wizard/10'); - // Fill valid reg data (use unique email) - await page.fill('[name="first_name"]', 'TestReg'); - await page.fill('[name="last_name"]', 'User'); - await page.fill('[name="email"]', 'testreg@example.com'); - await page.fill('[name="username"]', 'testreguser'); - await page.fill('[name="address"]', 'Teststr. 1'); - await page.fill('[name="phone"]', '+49123'); - await page.fill('[name="password"]', 'Password123!'); - await page.fill('[name="password_confirmation"]', 'Password123!'); - await page.check('[name="privacy_consent"]'); - await page.getByRole('button', { name: 'Registrieren' }).click(); - await expect(page.locator('text=Sie sind nun eingeloggt')).toBeVisible(); // Success message - await page.waitForTimeout(2000); // Auto-next - await expect(page).toHaveURL(/\/purchase-wizard\/1/); // Payment step - await page.screenshot({ path: 'wizard-reg-success.png', fullPage: true }); - }); - - test('Paid-Paket-Flow (ID=2, Pro mit Paddle)', async ({ page }) => { - // Ähnlich wie Free, aber package_id=2 - await page.goto('http://localhost:8000/de/packages'); - await page.getByRole('button', { name: 'Details anzeigen' }).nth(1).click(); // Zweites Paket (Paid) - // ... (Modal, Register/Login wie oben) - await expect(page).toHaveURL(/\/buy-packages\/2/); - - await expect(page.getByAltText('Paddle')).toBeVisible(); - }); -}); diff --git a/tests/ui/purchase/marketing-smoke.test.ts b/tests/ui/purchase/marketing-smoke.test.ts new file mode 100644 index 0000000..5b1a135 --- /dev/null +++ b/tests/ui/purchase/marketing-smoke.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Marketing frontend smoke', () => { + test('landing renders without console errors and CTA leads to packages', async ({ page }) => { + const consoleErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + const response = await page.goto('/'); + expect(response?.ok()).toBeTruthy(); + + const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i }); + if (await acceptCookies.isVisible()) { + await acceptCookies.click(); + } + + await expect(page.getByRole('heading', { level: 1, name: /Dein Event|Fotospiel/i })).toBeVisible(); + const heroCta = page.getByRole('link', { name: /paket|packages|starten|ausprobieren/i }).first(); + await expect(heroCta).toBeVisible(); + + await heroCta.click(); + await expect(page).toHaveURL(/\/packages/); + + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + + expect(consoleErrors).toEqual([]); + }); + + test('packages page lists packages and register CTAs', async ({ page }) => { + const response = await page.goto('/packages'); + expect(response?.ok()).toBeTruthy(); + + const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i }); + if (await acceptCookies.isVisible()) { + await acceptCookies.click(); + } + + const packageCards = page.locator('section >> text=/Starter|Standard|Premium/'); + await expect(packageCards.first()).toBeVisible(); + }); +}); diff --git a/tests/ui/purchase/paddle-sandbox-checkout.test.ts b/tests/ui/purchase/paddle-sandbox-checkout.test.ts new file mode 100644 index 0000000..00a3855 --- /dev/null +++ b/tests/ui/purchase/paddle-sandbox-checkout.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; + +const shouldRun = process.env.E2E_PADDLE_SANDBOX === '1'; + +test.describe('Paddle sandbox checkout (staging)', () => { + test.skip(!shouldRun, 'Set E2E_PADDLE_SANDBOX=1 to run live sandbox checkout on staging.'); + + test('creates Paddle checkout session from packages page', async ({ page }) => { + const base = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; + + await page.goto(`${base}/packages`); + + const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i }); + if (await acceptCookies.isVisible()) { + await acceptCookies.click(); + } + + const checkoutButtons = page.locator('a:has-text("Paket") , a:has-text("Checkout"), a:has-text("Jetzt"), button:has-text("Checkout")'); + const count = await checkoutButtons.count(); + + if (count === 0) { + test.skip('No checkout CTA found on packages page'); + } + + const [requestPromise] = await Promise.all([ + page.waitForRequest('**/paddle/create-checkout'), + checkoutButtons.first().click(), + ]); + + const checkoutRequest = await requestPromise.response(); + expect(checkoutRequest, 'Expected paddle/create-checkout request to resolve').toBeTruthy(); + expect(checkoutRequest!.status()).toBeLessThan(400); + + const body = await checkoutRequest!.json(); + const checkoutUrl = body.checkout_url ?? body.url ?? ''; + expect(checkoutUrl).toContain('paddle'); + }); +}); diff --git a/tests/ui/purchase/paddle-sandbox-full.test.ts b/tests/ui/purchase/paddle-sandbox-full.test.ts new file mode 100644 index 0000000..36a2347 --- /dev/null +++ b/tests/ui/purchase/paddle-sandbox-full.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test'; +import { test as base } from '../helpers/test-fixtures'; + +const shouldRun = process.env.E2E_PADDLE_SANDBOX === '1'; +const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; +const sandboxEmail = process.env.E2E_PADDLE_EMAIL ?? 'playwright-buyer@example.com'; + +test.describe('Paddle sandbox full flow (staging)', () => { + test.skip(!shouldRun, 'Set E2E_PADDLE_SANDBOX=1 to run live sandbox checkout on staging.'); + + test('create checkout, simulate webhook completion, and verify session completion', async ({ page, request }) => { + // Jump directly into wizard for Standard package (2) + await page.goto(`${baseUrl}/purchase-wizard/2`); + + const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i }); + if (await acceptCookies.isVisible()) { + await acceptCookies.click(); + } + + // If login/register step is present, choose guest path or continue + const continueButtons = page.getByRole('button', { name: /weiter|continue/i }); + if (await continueButtons.first().isVisible()) { + await continueButtons.first().click(); + } + + // Fill minimal registration form to reach payment step + await page.fill('input[name="first_name"]', 'Play'); + await page.fill('input[name="last_name"]', 'Wright'); + await page.fill('input[name="email"]', sandboxEmail); + await page.fill('input[name="address"]', 'Teststrasse 1, 12345 Berlin'); + await page.fill('input[name="phone"]', '+49123456789'); + await page.fill('input[name="username"]', 'playwright-buyer'); + await page.fill('input[name="password"]', 'Password123!'); + await page.fill('input[name="password_confirmation"]', 'Password123!'); + + const checkoutCta = page.getByRole('button', { name: /weiter zum zahlungsschritt|continue to payment|Weiter/i }).first(); + await expect(checkoutCta).toBeVisible({ timeout: 20000 }); + + const [apiResponse] = await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/paddle/create-checkout') && resp.status() < 500), + checkoutCta.click(), + ]); + + const checkoutPayload = await apiResponse.json(); + const checkoutUrl: string = checkoutPayload.checkout_url ?? checkoutPayload.url ?? ''; + + expect(checkoutUrl).toContain('paddle'); + + // Navigate to checkout to ensure it loads (hosted page). Use sandbox card data if needed later. + await page.goto(checkoutUrl); + await expect(page).toHaveURL(/paddle/); + + // Fetch latest session for this buyer + const latestSession = await request.get('/api/_testing/checkout/sessions/latest', { + params: { email: sandboxEmail }, + }); + + expect(latestSession.status()).toBe(200); + const sessionJson = await latestSession.json(); + const sessionId: string | undefined = sessionJson?.data?.id; + expect(sessionId, 'checkout session id').toBeTruthy(); + + // Simulate Paddle webhook completion + const simulate = await request.post(`/api/_testing/checkout/sessions/${sessionId}/simulate-paddle`, { + data: { + status: 'completed', + transaction_id: 'txn_playwright_' + Date.now(), + }, + }); + + expect(simulate.status()).toBe(200); + + // Confirm session is marked completed + const latestCompleted = await request.get('/api/_testing/checkout/sessions/latest', { + params: { status: 'completed', email: sandboxEmail }, + }); + expect(latestCompleted.status()).toBe(200); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 0b275f8..1b8cf89 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,9 +2,10 @@ import { wayfinder } from '@laravel/vite-plugin-wayfinder'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import laravel from 'laravel-vite-plugin'; -import { defineConfig } from 'vite'; +import { defineConfig, type PluginOption } from 'vite'; import path from 'path'; import { tamaguiPlugin } from '@tamagui/vite-plugin'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; const devServerHost = process.env.VITE_DEV_SERVER_HOST ?? 'fotospiel-app.test'; const devServerPort = Number.parseInt(process.env.VITE_DEV_SERVER_PORT ?? '5173', 10); @@ -12,6 +13,49 @@ const devServerOrigin = process.env.VITE_DEV_SERVER_URL ?? `http://fotospiel-app const parsedOrigin = new URL(devServerOrigin); const hmrPort = parsedOrigin.port === '' ? devServerPort : Number.parseInt(parsedOrigin.port, 10); const appUrl = process.env.APP_URL ?? 'http://fotospiel-app.test'; +const sentryEnabled = Boolean( + process.env.SENTRY_AUTH_TOKEN && + process.env.SENTRY_ORG && + process.env.SENTRY_PROJECT && + process.env.SENTRY_URL +); + +const plugins: PluginOption[] = [ + laravel({ + input: ['resources/css/app.css','resources/js/app.js', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'], + ssr: 'resources/js/ssr.tsx', + refresh: [ + 'resources/views/**/*.blade.php', + 'resources/lang/**/*.php', + 'app/Http/Livewire/**', // falls genutzt + // NICHT beobachten: storage/logs, vendor, public/build, etc. + ], + }), + react(), + tailwindcss(), + wayfinder({ + formVariants: true, + }), + tamaguiPlugin({ + config: './tamagui.config.ts', + components: ['@tamagui/core', '@tamagui/stacks', '@tamagui/text', '@tamagui/button'], + optimize: false, + disableExtraction: true, + }), +]; + +if (sentryEnabled) { + plugins.push( + sentryVitePlugin({ + org: process.env.SENTRY_ORG as string, + project: process.env.SENTRY_PROJECT as string, + authToken: process.env.SENTRY_AUTH_TOKEN as string, + url: process.env.SENTRY_URL as string, + release: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE, + telemetry: false, + }) + ); +} export default defineConfig({ server: { @@ -64,29 +108,7 @@ export default defineConfig({ }, }, }, - plugins: [ - laravel({ - input: ['resources/css/app.css','resources/js/app.js', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'], - ssr: 'resources/js/ssr.tsx', - refresh: [ - 'resources/views/**/*.blade.php', - 'resources/lang/**/*.php', - 'app/Http/Livewire/**', // falls genutzt - // NICHT beobachten: storage/logs, vendor, public/build, etc. - ], - }), - react(), - tailwindcss(), - wayfinder({ - formVariants: true, - }), - tamaguiPlugin({ - config: './tamagui.config.ts', - components: ['@tamagui/core', '@tamagui/stacks', '@tamagui/text', '@tamagui/button'], - optimize: false, - disableExtraction: true, - }), - ], + plugins, esbuild: { jsx: 'automatic', }, @@ -110,7 +132,7 @@ export default defineConfig({ // Build-Optionen wirken vor allem bei `vite build`, schaden aber nicht: build: { - sourcemap: false, + sourcemap: sentryEnabled, target: 'es2020', rollupOptions: { // keine externen Monster-Globs