Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/Console/Commands/RemoveExpiredGitHubAccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public function handle(): int
->get();

foreach ($users as $user) {
// Check if user still has Max access (direct or sub-license)
if (! $user->hasMaxAccess()) {
// Check if user still qualifies for mobile repo access
if (! $user->hasMobileRepoAccess()) {
// Remove from repository
$success = $github->removeFromMobileRepo($user->github_username);

Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/GitHubIntegrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public function requestRepoAccess(): RedirectResponse
return back()->with('error', 'Please connect your GitHub account first.');
}

if (! $user->hasMaxAccess()) {
if (! $user->hasMobileRepoAccess()) {
return back()->with('error', 'You need an active Max license to access the mobile repository.');
}

Expand Down
20 changes: 20 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,26 @@ public function hasMaxAccess(): bool
return $this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense();
}

/**
* Check if the user should have access to the nativephp/mobile repository.
* Max license holders always have access. Ultra subscribers only qualify
* if their subscription was created before February 1, 2026.
*/
public function hasMobileRepoAccess(): bool
{
if ($this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense()) {
return true;
}

$subscription = $this->subscription();

if (! $subscription || ! $subscription->active()) {
return false;
}

return $subscription->created_at->lt('2026-02-01 00:00:00');
}

/**
* Check if the user's subscription is a comped (free) subscription.
* Covers both legacy comped (is_comped flag) and comped Ultra price.
Expand Down
11 changes: 7 additions & 4 deletions resources/views/livewire/customer/integrations.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@
<livewire:claude-plugins-access-banner :inline="true" />
</div>

@if(auth()->user()->hasMaxAccess())
<div class="space-y-6">
<div class="space-y-6">
@if(auth()->user()->hasMobileRepoAccess())
<livewire:git-hub-access-banner :inline="true" />
@endif

@if(auth()->user()->hasMaxAccess())
<livewire:discord-access-banner :inline="true" />
</div>
@endif
@endif
</div>
</div>
2 changes: 1 addition & 1 deletion resources/views/livewire/git-hub-access-banner.blade.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div>
@if(auth()->user()->hasMaxAccess())
@if(auth()->user()->hasMobileRepoAccess())
<div @class(['max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' => !$inline])>
<div class="bg-gradient-to-r from-gray-50 to-slate-100 dark:from-gray-800 dark:to-slate-900 border border-gray-300 dark:border-gray-600 rounded-lg p-6 h-full">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
Expand Down
235 changes: 235 additions & 0 deletions tests/Feature/MobileRepoAccessTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

namespace Tests\Feature;

use App\Models\License;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Laravel\Cashier\Subscription as CashierSubscription;
use Tests\TestCase;

class MobileRepoAccessTest extends TestCase
{
use RefreshDatabase;

private const MAX_PRICE_ID = 'price_test_max_yearly';

protected function setUp(): void
{
parent::setUp();

config(['subscriptions.plans.max.stripe_price_id' => self::MAX_PRICE_ID]);

Cache::flush();
}

// ========================================
// hasMobileRepoAccess() Unit Tests
// ========================================

public function test_user_with_active_max_license_has_mobile_repo_access(): void
{
$user = User::factory()->create();
License::factory()->create([
'user_id' => $user->id,
'policy_name' => 'max',
'expires_at' => now()->addDays(30),
'is_suspended' => false,
]);

$this->assertTrue($user->hasMobileRepoAccess());
}

public function test_ultra_subscriber_before_cutoff_has_mobile_repo_access(): void
{
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-01-15 00:00:00',
]);

$this->assertTrue($user->hasMobileRepoAccess());
}

public function test_ultra_subscriber_after_cutoff_does_not_have_mobile_repo_access(): void
{
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-02-01 00:00:00',
]);

$this->assertFalse($user->hasMobileRepoAccess());
}

public function test_ultra_subscriber_on_cutoff_date_does_not_have_mobile_repo_access(): void
{
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-02-01 12:00:00',
]);

$this->assertFalse($user->hasMobileRepoAccess());
}

public function test_user_without_license_or_subscription_does_not_have_mobile_repo_access(): void
{
$user = User::factory()->create();

$this->assertFalse($user->hasMobileRepoAccess());
}

public function test_user_with_inactive_subscription_does_not_have_mobile_repo_access(): void
{
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);

CashierSubscription::factory()->for($user)->canceled()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2025-12-01 00:00:00',
]);

$this->assertFalse($user->hasMobileRepoAccess());
}

// ========================================
// Integrations Page Visibility Tests
// ========================================

public function test_ultra_subscriber_before_cutoff_sees_mobile_repo_banner(): void
{
Http::fake(['api.github.com/*' => Http::response([], 404)]);

$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-01-15 00:00:00',
]);

$response = $this->actingAs($user)->get('/dashboard/integrations');

$response->assertStatus(200);
$response->assertSee('nativephp/mobile');
$response->assertSee('Repo Access');
}

public function test_ultra_subscriber_after_cutoff_does_not_see_mobile_repo_banner(): void
{
Http::fake(['api.github.com/*' => Http::response([], 404)]);

$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-02-15 00:00:00',
]);

$response = $this->actingAs($user)->get('/dashboard/integrations');

$response->assertStatus(200);
$response->assertDontSee('nativephp/mobile</a> Repo Access', false);
}

// ========================================
// Request Access Controller Tests
// ========================================

public function test_ultra_subscriber_after_cutoff_cannot_request_repo_access(): void
{
$user = User::factory()->create([
'stripe_id' => 'cus_'.uniqid(),
'github_username' => 'testuser',
'github_id' => '123456',
]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-02-15 00:00:00',
]);

$response = $this->actingAs($user)
->post('/dashboard/github/request-access');

$response->assertRedirect();
$response->assertSessionHas('error');
}

public function test_ultra_subscriber_before_cutoff_can_request_repo_access(): void
{
Http::fake([
'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 201),
]);

$user = User::factory()->create([
'stripe_id' => 'cus_'.uniqid(),
'github_username' => 'testuser',
'github_id' => '123456',
]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-01-15 00:00:00',
]);

$response = $this->actingAs($user)
->post('/dashboard/github/request-access');

$response->assertRedirect();
$response->assertSessionHas('success');
$this->assertNotNull($user->fresh()->mobile_repo_access_granted_at);
}

// ========================================
// Cleanup Command Tests
// ========================================

public function test_cleanup_command_removes_access_for_post_cutoff_ultra_subscriber(): void
{
Http::fake([
'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 204),
]);

$user = User::factory()->create([
'stripe_id' => 'cus_'.uniqid(),
'github_username' => 'testuser',
'github_id' => '123456',
'mobile_repo_access_granted_at' => now()->subDays(10),
]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-02-15 00:00:00',
]);

$this->artisan('github:remove-expired-access')
->assertExitCode(0);

$this->assertNull($user->fresh()->mobile_repo_access_granted_at);
}

public function test_cleanup_command_retains_access_for_pre_cutoff_ultra_subscriber(): void
{
$user = User::factory()->create([
'stripe_id' => 'cus_'.uniqid(),
'github_username' => 'testuser',
'github_id' => '123456',
'mobile_repo_access_granted_at' => now()->subDays(10),
]);

CashierSubscription::factory()->for($user)->active()->create([
'stripe_price' => self::MAX_PRICE_ID,
'created_at' => '2026-01-15 00:00:00',
]);

$this->artisan('github:remove-expired-access')
->assertExitCode(0);

$this->assertNotNull($user->fresh()->mobile_repo_access_granted_at);
}
}
Loading