+
{{ $t('auth.login.no_account') }}
{{ $t('auth.login.sign_up') }}
diff --git a/routes/auth.php b/routes/auth.php
index da4e5de0..f50f4a3a 100644
--- a/routes/auth.php
+++ b/routes/auth.php
@@ -18,8 +18,10 @@
Route::get('/invites/{invite}', [AcceptInviteController::class, 'show'])->name('app.invites.show');
Route::middleware(['guest'])->group(function () {
- Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
- Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store');
+ Route::middleware('registration.enabled')->group(function () {
+ Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
+ Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store');
+ });
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->name('login.store');
diff --git a/tests/Feature/AcceptInviteControllerTest.php b/tests/Feature/AcceptInviteControllerTest.php
index 6ad786d1..6d78840d 100644
--- a/tests/Feature/AcceptInviteControllerTest.php
+++ b/tests/Feature/AcceptInviteControllerTest.php
@@ -19,7 +19,9 @@
]);
});
-test('show invite displays invite details for guest', function () {
+test('show invite displays invite details for guest when not self_hosted', function () {
+ config()->set('trypost.self_hosted', false);
+
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
@@ -39,6 +41,26 @@
);
});
+test('show invite displays invite details for guest when self_hosted (page renders, gate happens on /register)', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $invite = Invite::factory()->create([
+ 'account_id' => $this->account->id,
+ 'invited_by' => $this->owner->id,
+ 'email' => 'newuser@example.com',
+ 'workspaces' => [$this->workspace->id],
+ ]);
+
+ $response = $this->get(route('app.invites.show', $invite));
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) => $page
+ ->component('auth/AcceptInvite', false)
+ ->has('invite')
+ ->where('invite.id', $invite->id)
+ );
+});
+
test('show invite displays invite details for authenticated user', function () {
$user = User::factory()->create([
'email' => 'invitee@example.com',
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php
index 060b1218..8a49a676 100644
--- a/tests/Feature/Auth/AuthenticationTest.php
+++ b/tests/Feature/Auth/AuthenticationTest.php
@@ -12,6 +12,26 @@
$response->assertOk();
});
+test('login page exposes selfHosted as false when SELF_HOSTED is off', function () {
+ config()->set('trypost.self_hosted', false);
+
+ $response = $this->get(route('login'));
+
+ $response->assertOk();
+ $page = $response->original->getData()['page'];
+ expect($page['props']['selfHosted'])->toBeFalse();
+});
+
+test('login page exposes selfHosted as true when SELF_HOSTED is on', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $response = $this->get(route('login'));
+
+ $response->assertOk();
+ $page = $response->original->getData()['page'];
+ expect($page['props']['selfHosted'])->toBeTrue();
+});
+
test('users can authenticate using the login screen', function () {
$user = User::factory()->create();
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php
index 7382ce13..495791ca 100644
--- a/tests/Feature/Auth/RegistrationTest.php
+++ b/tests/Feature/Auth/RegistrationTest.php
@@ -4,6 +4,8 @@
use App\Models\User;
+beforeEach(fn () => config()->set('trypost.self_hosted', false));
+
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
@@ -111,3 +113,87 @@
expect($user->email_verified_at)->not->toBeNull();
});
+
+test('register page returns 404 when self_hosted and no pending invite in session', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $response = $this->get(route('register'));
+
+ $response->assertNotFound();
+});
+
+test('register POST returns 404 when self_hosted and no pending invite in session', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $response = $this->post(route('register.store'), [
+ 'name' => 'Test User',
+ 'email' => 'test@example.com',
+ 'password' => 'Password123!',
+ ]);
+
+ $response->assertNotFound();
+ expect(User::where('email', 'test@example.com')->exists())->toBeFalse();
+});
+
+test('register page renders when self_hosted but session has pending invite', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $response = $this
+ ->withSession(['pending_invite_id' => 'invite-abc'])
+ ->get(route('register'));
+
+ $response->assertOk();
+});
+
+test('register page renders when self_hosted with invite query param and persists it to session', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $response = $this->get(route('register', ['invite' => 'invite-xyz']));
+
+ $response->assertOk();
+ $response->assertSessionHas('pending_invite_id', 'invite-xyz');
+});
+
+test('signup clears pending_invite_id from session', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $this->withSession(['pending_invite_id' => 'invite-abc'])
+ ->post(route('register.store'), [
+ 'name' => 'Invitee',
+ 'email' => 'invitee@example.com',
+ 'password' => 'Password123!',
+ ]);
+
+ expect(session('pending_invite_id'))->toBeNull();
+ expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
+});
+
+test('register POST passes when self_hosted with invite query param even without prior session', function () {
+ config()->set('trypost.self_hosted', true);
+
+ $response = $this->post(route('register.store', ['invite' => 'invite-xyz']), [
+ 'name' => 'Invitee',
+ 'email' => 'invitee@example.com',
+ 'password' => 'Password123!',
+ ]);
+
+ $response->assertSessionHasNoErrors();
+ expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
+});
+
+test('register works normally when not self_hosted even with pending invite in session', function () {
+ config()->set('trypost.self_hosted', false);
+
+ $response = $this
+ ->withSession(['pending_invite_id' => 'invite-abc'])
+ ->post(route('register.store'), [
+ 'name' => 'Invitee',
+ 'email' => 'invitee@example.com',
+ 'password' => 'Password123!',
+ ]);
+
+ $response->assertSessionHasNoErrors();
+ expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
+ // Cleared regardless of mode, since signup consumes the marker.
+ expect(session('pending_invite_id'))->toBeNull();
+});
diff --git a/tests/Feature/Auth/SignupUtmTrackingTest.php b/tests/Feature/Auth/SignupUtmTrackingTest.php
index ab346071..9a80e10e 100644
--- a/tests/Feature/Auth/SignupUtmTrackingTest.php
+++ b/tests/Feature/Auth/SignupUtmTrackingTest.php
@@ -6,6 +6,8 @@
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
+beforeEach(fn () => config()->set('trypost.self_hosted', false));
+
test('email registration saves utm parameters from the register page query string', function () {
$utms = [
'utm_source' => 'peerlist',
diff --git a/tests/Feature/Database/UserSeederTest.php b/tests/Feature/Database/UserSeederTest.php
new file mode 100644
index 00000000..b8a98dfa
--- /dev/null
+++ b/tests/Feature/Database/UserSeederTest.php
@@ -0,0 +1,26 @@
+toBe(0);
+
+ $this->seed(UserSeeder::class);
+
+ $admin = User::where('email', 'admin@trypost.it')->first();
+
+ expect($admin)->not->toBeNull();
+ expect($admin->account_id)->not->toBeNull();
+ expect($admin->workspaces()->count())->toBe(1);
+});
+
+test('seeder is idempotent when a user already exists', function () {
+ User::factory()->create();
+
+ $this->seed(UserSeeder::class);
+
+ expect(User::where('email', 'admin@trypost.it')->exists())->toBeFalse();
+});
diff --git a/tests/Feature/GitHubAuthToggleTest.php b/tests/Feature/GitHubAuthToggleTest.php
index 8d24829f..4defbd5b 100644
--- a/tests/Feature/GitHubAuthToggleTest.php
+++ b/tests/Feature/GitHubAuthToggleTest.php
@@ -2,6 +2,8 @@
declare(strict_types=1);
+beforeEach(fn () => config()->set('trypost.self_hosted', false));
+
test('login page shares github auth enabled prop as false when disabled', function () {
config(['trypost.github_auth_enabled' => false]);
@@ -52,3 +54,16 @@
// Should redirect to login on failure (no OAuth code), not 404
$response->assertRedirect(route('login'));
});
+
+test('register page still shares github auth enabled prop when self_hosted (via pending invite)', function () {
+ config()->set('trypost.self_hosted', true);
+ config()->set('trypost.github_auth_enabled', true);
+
+ $response = $this
+ ->withSession(['pending_invite_id' => 'invite-abc'])
+ ->get(route('register'));
+
+ $response->assertOk();
+ $page = $response->original->getData()['page'];
+ expect($page['props']['githubAuthEnabled'])->toBeTrue();
+});
diff --git a/tests/Feature/GoogleAuthToggleTest.php b/tests/Feature/GoogleAuthToggleTest.php
index bb01975e..05b42d13 100644
--- a/tests/Feature/GoogleAuthToggleTest.php
+++ b/tests/Feature/GoogleAuthToggleTest.php
@@ -2,6 +2,8 @@
declare(strict_types=1);
+beforeEach(fn () => config()->set('trypost.self_hosted', false));
+
test('login page loads when google auth is disabled', function () {
config(['trypost.google_auth_enabled' => false]);
@@ -69,3 +71,16 @@
// Should redirect to login on failure (no OAuth code), not 404
$response->assertRedirect(route('login'));
});
+
+test('register page still shares google auth enabled prop when self_hosted (via pending invite)', function () {
+ config()->set('trypost.self_hosted', true);
+ config()->set('trypost.google_auth_enabled', true);
+
+ $response = $this
+ ->withSession(['pending_invite_id' => 'invite-abc'])
+ ->get(route('register'));
+
+ $response->assertOk();
+ $page = $response->original->getData()['page'];
+ expect($page['props']['googleAuthEnabled'])->toBeTrue();
+});
diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php
index 1955360a..60786517 100644
--- a/tests/Feature/Settings/ProfileUpdateTest.php
+++ b/tests/Feature/Settings/ProfileUpdateTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
+use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
@@ -250,3 +251,65 @@
$response->assertRedirect(route('login'));
});
+
+test('member deleting profile does NOT destroy the shared account', function (bool $selfHosted) {
+ config()->set('trypost.self_hosted', $selfHosted);
+
+ $owner = User::factory()->create();
+ $member = User::factory()->create(['account_id' => $owner->account_id]);
+
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $owner->account_id,
+ 'user_id' => $owner->id,
+ ]);
+ $owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
+ $member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
+
+ $this->actingAs($member)->delete(route('app.profile.destroy'), [
+ 'password' => 'password',
+ ]);
+
+ expect($member->fresh())->toBeNull();
+ expect(Account::find($owner->account_id))->not->toBeNull();
+ expect(Workspace::find($workspace->id))->not->toBeNull();
+ expect($owner->fresh())->not->toBeNull();
+})->with([true, false]);
+
+test('member deleting profile detaches them from workspaces', function (bool $selfHosted) {
+ config()->set('trypost.self_hosted', $selfHosted);
+
+ $owner = User::factory()->create();
+ $member = User::factory()->create(['account_id' => $owner->account_id]);
+
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $owner->account_id,
+ 'user_id' => $owner->id,
+ ]);
+ $member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
+
+ $this->actingAs($member)->delete(route('app.profile.destroy'), [
+ 'password' => 'password',
+ ]);
+
+ expect($workspace->fresh()->members()->where('users.id', $member->id)->exists())->toBeFalse();
+})->with([true, false]);
+
+test('owner deleting profile destroys the account and cascades', function (bool $selfHosted) {
+ config()->set('trypost.self_hosted', $selfHosted);
+
+ $owner = User::factory()->create();
+ $accountId = $owner->account_id;
+
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $accountId,
+ 'user_id' => $owner->id,
+ ]);
+ $owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
+
+ $this->actingAs($owner)->delete(route('app.profile.destroy'), [
+ 'password' => 'password',
+ ]);
+
+ expect(Account::find($accountId))->toBeNull();
+ expect(Workspace::find($workspace->id))->toBeNull();
+})->with([true, false]);