299 lines
15 KiB
PHP
299 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Admin\Cms;
|
|
use App\Livewire\SupportDesk;
|
|
use App\Models\User;
|
|
use App\Services\LicenseService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
class CommerceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->seed();
|
|
Notification::fake();
|
|
config(['tarnax.paddle.webhook_secret' => 'test-secret', 'tarnax.paddle.price_id' => 'pri_test']);
|
|
}
|
|
|
|
private function customer(array $attributes = []): User
|
|
{
|
|
return User::factory()->create(['account_token' => (string) Str::uuid(), ...$attributes]);
|
|
}
|
|
|
|
private function event(User $user, string $id = 'evt_1'): array
|
|
{
|
|
return ['event_id' => $id, 'event_type' => 'transaction.completed', 'occurred_at' => now()->toISOString(), 'data' => ['id' => 'txn_1', 'status' => 'completed', 'custom_data' => ['account_token' => $user->account_token], 'items' => [['price' => ['id' => 'pri_test'], 'quantity' => 1]], 'details' => ['totals' => ['grand_total' => '4900']], 'currency_code' => 'USD']];
|
|
}
|
|
|
|
private function deliver(array $event, ?int $timestamp = null, ?string $signature = null)
|
|
{
|
|
$body = json_encode($event);
|
|
$ts = $timestamp ?? time();
|
|
$sig = $signature ?? hash_hmac('sha256', $ts.':'.$body, 'test-secret');
|
|
|
|
return $this->call('POST', '/api/webhooks/paddle', [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_PADDLE_SIGNATURE' => 'ts='.$ts.';h1='.$sig], $body);
|
|
}
|
|
|
|
private function license(User $user): object
|
|
{
|
|
$this->deliver($this->event($user))->assertOk();
|
|
|
|
return DB::table('licenses')->first();
|
|
}
|
|
|
|
public function test_public_pages_and_authentication_views_render(): void
|
|
{
|
|
foreach (['/', '/releases', '/pages/privacy', '/pages/terms', '/pages/features', '/login', '/register', '/forgot-password'] as $url) {
|
|
$this->get($url)->assertOk();
|
|
}$this->get('/account')->assertRedirect('/login');
|
|
}
|
|
|
|
public function test_registration_cannot_grant_administration(): void
|
|
{
|
|
$this->post('/register', ['name' => 'Customer', 'email' => 'new@example.com', 'password' => 'StrongPass123!', 'password_confirmation' => 'StrongPass123!', 'is_admin' => true])->assertRedirect();
|
|
$this->assertFalse(User::where('email', 'new@example.com')->first()->is_admin);
|
|
}
|
|
|
|
public function test_verified_payment_issues_only_one_license_on_retries(): void
|
|
{
|
|
$user = $this->customer();
|
|
$event = $this->event($user);
|
|
$this->deliver($event)->assertOk();
|
|
$this->deliver($event)->assertOk();
|
|
$event['event_id'] = 'evt_2';
|
|
$this->deliver($event)->assertOk();
|
|
$this->assertDatabaseCount('purchases', 1);
|
|
$this->assertDatabaseCount('licenses', 1);
|
|
}
|
|
|
|
public function test_invalid_signature_and_stale_replay_never_grant_licenses(): void
|
|
{
|
|
$user = $this->customer();
|
|
$this->deliver($this->event($user), null, 'invalid')->assertUnauthorized();
|
|
$this->deliver($this->event($user), time() - 600)->assertUnauthorized();
|
|
$this->assertDatabaseCount('licenses', 0);
|
|
}
|
|
|
|
public function test_wrong_product_does_not_grant_license(): void
|
|
{
|
|
$event = $this->event($this->customer());
|
|
$event['data']['items'][0]['price']['id'] = 'pri_other';
|
|
$this->deliver($event)->assertOk();
|
|
$this->assertDatabaseCount('licenses', 0);
|
|
}
|
|
|
|
public function test_unknown_customer_rolls_back_event_for_retry(): void
|
|
{
|
|
$event = $this->event($this->customer());
|
|
$event['data']['custom_data']['account_token'] = (string) Str::uuid();
|
|
$this->deliver($event)->assertUnprocessable();
|
|
$this->assertDatabaseCount('webhook_events', 0);
|
|
}
|
|
|
|
public function test_activation_limits_validation_and_deactivation(): void
|
|
{
|
|
$license = $this->license($this->customer());
|
|
$key = Crypt::decryptString($license->key_encrypted);
|
|
$token = $this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('a', 20)])->assertOk()->json('activation_token');
|
|
$this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('b', 20)])->assertOk();
|
|
$this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('c', 20)])->assertConflict();
|
|
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertOk()->assertJson(['valid' => true]);
|
|
$this->withToken($token)->postJson('/api/v1/licenses/deactivate')->assertOk();
|
|
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();
|
|
}
|
|
|
|
public function test_refund_revokes_license_and_prevents_regrant(): void
|
|
{
|
|
$user = $this->customer();
|
|
$license = $this->license($user);
|
|
$key = Crypt::decryptString($license->key_encrypted);
|
|
$token = app(LicenseService::class)->activate($key, str_repeat('a', 20))['activation_token'];
|
|
$refund = ['event_id' => 'evt_refund', 'event_type' => 'adjustment.updated', 'occurred_at' => now()->addSecond()->toISOString(), 'data' => ['status' => 'approved', 'action' => 'refund', 'transaction_id' => 'txn_1']];
|
|
$this->deliver($refund)->assertOk();
|
|
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();
|
|
$this->deliver($this->event($user, 'evt_late'))->assertOk();
|
|
$this->assertDatabaseHas('licenses', ['status' => 'revoked']);
|
|
}
|
|
|
|
public function test_checkout_uses_server_selected_product_and_customer(): void
|
|
{
|
|
$user = $this->customer();
|
|
config(['tarnax.paddle.api_key' => 'secret', 'tarnax.paddle.client_token' => 'token']);
|
|
Http::fake(['*' => Http::response(['data' => ['id' => 'txn_checkout']])]);
|
|
$this->actingAs($user)->postJson('/checkout', ['price_id' => 'attacker', 'custom_data' => ['account_token' => 'other']])->assertOk()->assertJson(['transaction_id' => 'txn_checkout']);
|
|
Http::assertSent(fn ($r) => $r['items'][0]['price_id'] === 'pri_test' && $r['custom_data']['account_token'] === $user->account_token);
|
|
}
|
|
|
|
public function test_unconfigured_checkout_fails_closed(): void
|
|
{
|
|
config(['tarnax.paddle.api_key' => null]);
|
|
$this->actingAs($this->customer())->postJson('/checkout')->assertStatus(503);
|
|
}
|
|
|
|
public function test_downloads_require_license_signature_and_matching_customer(): void
|
|
{
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('releases/app.zip', 'binary');
|
|
$release = DB::table('releases')->insertGetId(['version' => '1.0.0', 'channel' => 'stable', 'platform' => 'macOS', 'notes' => 'Release', 'path' => 'releases/app.zip', 'sha256' => hash('sha256', 'binary'), 'bytes' => 6, 'published' => true, 'created_at' => now(), 'updated_at' => now()]);
|
|
$user = $this->customer();
|
|
$other = $this->customer();
|
|
$this->actingAs($user)->get('/downloads/'.$release)->assertForbidden();
|
|
$this->license($user);
|
|
$this->get('/downloads/'.$release)->assertRedirect();
|
|
$url = URL::temporarySignedRoute('download.file', now()->addMinutes(5), ['release' => $release, 'user' => $user->id]);
|
|
$this->get($url)->assertOk();
|
|
$this->actingAs($other)->get($url)->assertForbidden();
|
|
$expired = URL::temporarySignedRoute('download.file', now()->subMinute(), ['release' => $release, 'user' => $user->id]);
|
|
$this->actingAs($user)->get($expired)->assertForbidden();
|
|
}
|
|
|
|
public function test_support_is_scoped_and_does_not_accept_another_ticket(): void
|
|
{
|
|
$owner = $this->customer();
|
|
$other = $this->customer();
|
|
$ticket = DB::table('tickets')->insertGetId(['user_id' => $owner->id, 'subject' => 'Private conversation', 'status' => 'open', 'created_at' => now(), 'updated_at' => now()]);
|
|
Livewire::actingAs($other)->test(SupportDesk::class)->assertDontSee('Private conversation')->call('select', $ticket)->assertForbidden();
|
|
}
|
|
|
|
public function test_customer_can_create_support_ticket(): void
|
|
{
|
|
Livewire::actingAs($this->customer())->test(SupportDesk::class)->set('subject', 'Connection help')->set('body', 'Please help me connect my server.')->call('create')->assertHasNoErrors();
|
|
$this->assertDatabaseCount('tickets', 1);
|
|
$this->assertDatabaseCount('ticket_messages', 1);
|
|
}
|
|
|
|
public function test_admin_requires_role_and_confirmed_two_factor(): void
|
|
{
|
|
$this->actingAs($this->customer())->get('/admin')->assertForbidden();
|
|
$admin = $this->customer(['is_admin' => true]);
|
|
$this->actingAs($admin)->get('/admin')->assertRedirect('/account/security');
|
|
Livewire::actingAs($admin)->test(Cms::class)->assertForbidden();
|
|
}
|
|
|
|
public function test_admin_can_edit_cms_and_mutations_are_audited(): void
|
|
{
|
|
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
|
|
Livewire::actingAs($admin)->test(Cms::class)->set('form', ['slug' => 'new-page', 'title' => 'New page', 'body' => 'Safe content', 'published' => true])->call('save')->assertHasNoErrors();
|
|
$this->assertDatabaseHas('pages', ['slug' => 'new-page']);
|
|
$this->assertDatabaseCount('audit_logs', 1);
|
|
}
|
|
|
|
public function test_consent_records_explicit_choices(): void
|
|
{
|
|
$this->postJson('/consent', ['analytics' => false, 'marketing' => false])->assertOk();
|
|
$this->assertDatabaseHas('consents', ['analytics' => false, 'marketing' => false]);
|
|
}
|
|
|
|
public function test_apple_verification_fails_closed_without_root(): void
|
|
{
|
|
config(['tarnax.apple.root_certificate' => null]);
|
|
$this->actingAs($this->customer())->postJson('/account/apple', ['signed_transaction' => 'untrusted.payload.signature'])->assertStatus(503);
|
|
$this->assertDatabaseCount('purchases', 0);
|
|
}
|
|
|
|
public function test_account_support_and_security_render_for_verified_customer(): void
|
|
{
|
|
$this->actingAs($this->customer())->withSession(['auth.password_confirmed_at' => time()]);
|
|
foreach (['/account', '/support', '/account/security'] as $url) {
|
|
$this->get($url)->assertOk();
|
|
}
|
|
}
|
|
|
|
public function test_erasure_revokes_access_and_anonymises_profile(): void
|
|
{
|
|
$user = $this->customer();
|
|
$this->license($user);
|
|
$id = DB::table('privacy_requests')->insertGetId(['user_id' => $user->id, 'type' => 'deletion', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
|
|
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
|
|
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'privacy_requests')->call('fulfillDeletion', $id)->assertHasNoErrors();
|
|
$this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Deleted customer']);
|
|
$this->assertDatabaseHas('licenses', ['user_id' => $user->id, 'status' => 'revoked']);
|
|
$this->assertDatabaseHas('privacy_requests', ['id' => $id, 'status' => 'fulfilled']);
|
|
}
|
|
|
|
public function test_refund_before_purchase_prevents_late_grant(): void
|
|
{
|
|
$user = $this->customer();
|
|
$this->deliver(['event_id' => 'early_refund', 'event_type' => 'adjustment.created', 'occurred_at' => now()->toISOString(), 'data' => ['status' => 'approved', 'action' => 'refund', 'transaction_id' => 'txn_1']])->assertOk();
|
|
$this->deliver($this->event($user))->assertOk();
|
|
$this->assertDatabaseCount('licenses', 0);
|
|
}
|
|
|
|
public function test_subscription_events_revoke_and_restore_and_ignore_stale_events(): void
|
|
{
|
|
$user = $this->customer();
|
|
$event = $this->event($user);
|
|
$event['data']['subscription_id'] = 'sub_1';
|
|
$this->deliver($event)->assertOk();
|
|
$send = function ($id, $status, $seconds) {
|
|
return $this->deliver(['event_id' => $id, 'event_type' => 'subscription.updated', 'occurred_at' => now()->addSeconds($seconds)->toISOString(), 'data' => ['id' => 'sub_1', 'status' => $status]]);
|
|
};
|
|
$send('pause', 'paused', 10)->assertOk();
|
|
$this->assertDatabaseHas('licenses', ['status' => 'revoked']);
|
|
$send('resume', 'active', 20)->assertOk();
|
|
$this->assertDatabaseHas('licenses', ['status' => 'active']);
|
|
$send('old_pause', 'paused', 5)->assertOk();
|
|
$this->assertDatabaseHas('licenses', ['status' => 'active']);
|
|
}
|
|
|
|
public function test_admin_can_manage_navigation_and_reject_external_redirects(): void
|
|
{
|
|
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
|
|
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'navigation')->set('form', ['label' => 'Features', 'path' => '/pages/features', 'position' => 1, 'published' => true])->call('saveResource')->assertHasNoErrors();
|
|
$this->assertDatabaseHas('navigation', ['label' => 'Features']);
|
|
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'redirects')->set('form', ['source' => '/old', 'destination' => '//evil.example'])->call('saveResource')->assertHasErrors('form.destination');
|
|
}
|
|
|
|
public function test_published_page_html_is_escaped(): void
|
|
{
|
|
DB::table('pages')->where('slug', 'features')->update(['body' => '<script>alert(1)</script>']);
|
|
$this->get('/pages/features')->assertSee('<script>', false)->assertDontSee('<script>alert(1)</script>', false);
|
|
}
|
|
|
|
public function test_apple_notification_fails_closed_without_configuration(): void
|
|
{
|
|
config(['tarnax.apple.root_certificate' => null]);
|
|
$this->postJson('/api/webhooks/apple', ['signedPayload' => 'untrusted.payload.signature'])->assertStatus(503);
|
|
}
|
|
|
|
public function test_erased_customer_cannot_use_an_old_activation_token(): void
|
|
{
|
|
$user = $this->customer();
|
|
$license = $this->license($user);
|
|
$token = app(LicenseService::class)->activate(Crypt::decryptString($license->key_encrypted), str_repeat('x', 20))['activation_token'];
|
|
$id = DB::table('privacy_requests')->insertGetId(['user_id' => $user->id, 'type' => 'deletion', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
|
|
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
|
|
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'privacy_requests')->call('fulfillDeletion', $id);
|
|
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();
|
|
}
|
|
|
|
public function test_unverified_accounts_cannot_checkout_or_open_portal(): void
|
|
{
|
|
$user = $this->customer(['email_verified_at' => null]);
|
|
$this->actingAs($user)->get('/account')->assertRedirect('/email/verify');
|
|
$this->postJson('/checkout')->assertForbidden();
|
|
}
|
|
|
|
public function test_private_media_is_safely_served(): void
|
|
{
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('media/test.png','test image');
|
|
$id = DB::table('media')->insertGetId(['title' => 'App', 'alt' => 'Icon', 'path' => 'media/test.png', 'mime' => 'image/png', 'bytes' => 10, 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->get('/media/'.$id)->assertOk()->assertHeader('X-Content-Type-Options','nosniff');
|
|
}
|
|
}
|