feat: capture signup UTMs/IP and add GitHub OAuth login

Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):

- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
  the register/redirect GET, retrieves them on the POST/callback —
  surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer

Adds GitHub as a second OAuth provider:

- GitHubController mirroring the Google one (now renamed from
  SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
  on Login/Register, rendering each enabled provider plus a single
  "or continue with" divider

UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
This commit is contained in:
Paulo Castellano 2026-05-04 18:42:25 -03:00
parent 99179b65af
commit 2e9e0f716b
25 changed files with 716 additions and 51 deletions

View file

@ -11,11 +11,12 @@
class CreateUser
{
/**
* @param array{name: string, email: string, password?: string, google_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool} $data
* @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null} $data
* @param array<string, string> $utmParameters
*/
public static function execute(array $data): User
public static function execute(array $data, array $utmParameters = []): User
{
return DB::transaction(function () use ($data): User {
return DB::transaction(function () use ($data, $utmParameters): User {
$isInviteRegistration = data_get($data, 'is_invite', false);
$account = Account::create([
@ -23,14 +24,16 @@ public static function execute(array $data): User
'billing_email' => data_get($data, 'email'),
]);
$user = User::create([
$user = User::create(array_merge([
'name' => data_get($data, 'name'),
'email' => data_get($data, 'email'),
'password' => data_get($data, 'password'),
'google_id' => data_get($data, 'google_id'),
'github_id' => data_get($data, 'github_id'),
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
'account_id' => $account->id,
]);
'registration_ip' => data_get($data, 'registration_ip'),
], $utmParameters));
$account->update(['owner_id' => $user->id]);

View file

@ -17,7 +17,7 @@
class AuthenticationController extends Controller
{
private const array PROVIDERS = ['google'];
private const array PROVIDERS = ['google', 'github'];
public function edit(Request $request): Response
{
@ -115,6 +115,7 @@ private function getConnectedAccounts(User $user): array
{
$labels = [
'google' => 'Google',
'github' => 'GitHub',
];
return collect(self::PROVIDERS)->map(fn (string $provider) => [

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth\Concerns;
use Illuminate\Http\Request;
trait PreservesUtmParameters
{
private const array UTM_KEYS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
/**
* @return array<string, string>
*/
private function extractUtmParameters(Request $request): array
{
return array_filter(
array_map(
fn (string $value) => mb_substr($value, 0, 255),
array_filter($request->only(self::UTM_KEYS), 'is_string'),
),
);
}
private function storeUtmParameters(Request $request): void
{
$utms = $this->extractUtmParameters($request);
if ($utms !== []) {
$request->session()->put('utm_parameters', $utms);
}
}
/**
* @return array<string, string>
*/
private function retrieveUtmParameters(): array
{
return session()->pull('utm_parameters', []);
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Actions\User\CreateUser;
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
class GitHubController extends Controller
{
use PreservesUtmParameters;
public function redirect(Request $request): RedirectResponse
{
$this->storeUtmParameters($request);
return Socialite::driver('github')
->scopes(['read:user', 'user:email'])
->redirect();
}
public function callback(): RedirectResponse
{
try {
$githubUser = Socialite::driver('github')->user();
} catch (\Exception) {
return redirect()->route('login');
}
$user = User::where('github_id', (string) $githubUser->getId())
->when($githubUser->getEmail(), fn ($query, $email) => $query->orWhere('email', $email))
->first();
if ($user) {
return $this->loginExistingUser($user, (string) $githubUser->getId());
}
if (! $githubUser->getEmail()) {
return redirect()->route('login')->withErrors([
'email' => __('auth.github_email_unavailable'),
]);
}
return $this->registerNewUser($githubUser);
}
private function loginExistingUser(User $user, string $githubId): RedirectResponse
{
if (! $user->github_id) {
$user->update(['github_id' => $githubId]);
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
Auth::login($user, remember: true);
$this->retrieveUtmParameters();
return redirect()->route('app.home');
}
private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): RedirectResponse
{
$utmParameters = $this->retrieveUtmParameters();
$user = CreateUser::execute([
'name' => $githubUser->getName() ?? $githubUser->getNickname() ?? explode('@', $githubUser->getEmail())[0],
'email' => $githubUser->getEmail(),
'github_id' => (string) $githubUser->getId(),
'email_verified_at' => now(),
'registration_ip' => request()->ip(),
], $utmParameters);
event(new Registered($user));
Auth::login($user, remember: true);
session()->flash('auth_provider', 'github');
return redirect()->route('register.success', $utmParameters);
}
}

View file

@ -5,17 +5,23 @@
namespace App\Http\Controllers\Auth;
use App\Actions\User\CreateUser;
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
class SocialLoginController extends Controller
class GoogleController extends Controller
{
public function redirect(): RedirectResponse
use PreservesUtmParameters;
public function redirect(Request $request): RedirectResponse
{
$this->storeUtmParameters($request);
return Socialite::driver('google-auth')->redirect();
}
@ -50,17 +56,22 @@ private function loginExistingUser(User $user, string $googleId): RedirectRespon
Auth::login($user, remember: true);
$this->retrieveUtmParameters();
return redirect()->route('app.home');
}
private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse
{
$utmParameters = $this->retrieveUtmParameters();
$user = CreateUser::execute([
'name' => $googleUser->getName(),
'email' => $googleUser->getEmail(),
'google_id' => $googleUser->getId(),
'email_verified_at' => now(),
]);
'registration_ip' => request()->ip(),
], $utmParameters);
event(new Registered($user));
@ -68,6 +79,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser):
session()->flash('auth_provider', 'google');
return redirect()->route('register.success');
return redirect()->route('register.success', $utmParameters);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Http\Controllers\Auth;
use App\Actions\User\CreateUser;
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Rules\Timezone;
@ -18,8 +19,12 @@
class RegisteredUserController extends Controller
{
use PreservesUtmParameters;
public function create(Request $request): Response
{
$this->storeUtmParameters($request);
return Inertia::render('auth/Register', [
'email' => $request->query('email'),
'redirect' => $request->query('redirect'),
@ -37,13 +42,16 @@ public function store(Request $request): RedirectResponse
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
$utmParameters = $this->retrieveUtmParameters();
$user = CreateUser::execute([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
'timezone' => $request->input('timezone', 'UTC'),
'is_invite' => $isInviteRegistration,
]);
'registration_ip' => $request->ip(),
], $utmParameters);
event(new Registered($user));
@ -57,6 +65,6 @@ public function store(Request $request): RedirectResponse
session()->flash('auth_provider', 'email');
return redirect()->route('register.success');
return redirect()->route('register.success', $utmParameters);
}
}

View file

@ -59,6 +59,7 @@ public function share(Request $request): array
'aiEnabled' => ! empty(config('services.gemini.api_key')) || ! empty(config('services.openai.api_key')),
'selfHosted' => $isSelfHosted,
'googleAuthEnabled' => config('trypost.google_auth_enabled'),
'githubAuthEnabled' => config('trypost.github_auth_enabled'),
'trialDays' => config('cashier.trial_days'),
];
}

View file

@ -32,9 +32,16 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
'email',
'password',
'google_id',
'github_id',
'account_id',
'current_workspace_id',
'email_verified_at',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'registration_ip',
];
/**

View file

@ -76,6 +76,13 @@
'redirect' => env('GOOGLE_AUTH_CALLBACK'),
],
// GitHub OAuth (used for login/signup)
'github' => [
'client_id' => env('GITHUB_CLIENT_ID'),
'client_secret' => env('GITHUB_CLIENT_SECRET'),
'redirect' => env('GITHUB_AUTH_CALLBACK'),
],
// Facebook Pages
'facebook' => [
'client_id' => env('FACEBOOK_CLIENT_ID'),

View file

@ -46,6 +46,8 @@
'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false),
'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false),
/*
|--------------------------------------------------------------------------
| Social Platforms

View file

@ -33,12 +33,19 @@ public function definition(): array
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'google_id' => null,
'github_id' => null,
'remember_token' => Str::random(10),
'account_id' => Account::factory(),
'current_workspace_id' => null,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
'utm_source' => null,
'utm_medium' => null,
'utm_campaign' => null,
'utm_term' => null,
'utm_content' => null,
'registration_ip' => null,
];
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('utm_source')->nullable()->after('current_workspace_id');
$table->string('utm_medium')->nullable()->after('utm_source');
$table->string('utm_campaign')->nullable()->after('utm_medium');
$table->string('utm_term')->nullable()->after('utm_campaign');
$table->string('utm_content')->nullable()->after('utm_term');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn(['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']);
});
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('github_id')->nullable()->unique()->after('google_id');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('github_id');
});
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->ipAddress('registration_ip')->nullable()->after('utm_content');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('registration_ip');
});
}
};

View file

@ -54,6 +54,9 @@
'or_continue_with' => 'Or continue with',
'google_login' => 'Log in with Google',
'google_signup' => 'Sign up with Google',
'github_login' => 'Log in with GitHub',
'github_signup' => 'Sign up with GitHub',
'github_email_unavailable' => 'Unable to retrieve your email from GitHub. Make your GitHub email public or grant the email scope, then try again.',
'signup_success' => [
'page_title' => 'Welcome',

View file

@ -42,6 +42,9 @@
'or_continue_with' => 'O continuar con',
'google_login' => 'Iniciar sesión con Google',
'google_signup' => 'Registrarse con Google',
'github_login' => 'Iniciar sesión con GitHub',
'github_signup' => 'Registrarse con GitHub',
'github_email_unavailable' => 'No fue posible obtener tu correo de GitHub. Haz tu correo público en GitHub o concede el permiso de correo y vuelve a intentar.',
'signup_success' => [
'page_title' => 'Bienvenido',

View file

@ -54,6 +54,9 @@
'or_continue_with' => 'Ou continue com',
'google_login' => 'Entrar com Google',
'google_signup' => 'Cadastrar com Google',
'github_login' => 'Entrar com GitHub',
'github_signup' => 'Cadastrar com GitHub',
'github_email_unavailable' => 'Não foi possível obter seu e-mail do GitHub. Torne seu e-mail público ou conceda a permissão de e-mail e tente novamente.',
'signup_success' => [
'page_title' => 'Bem-vindo',

View file

@ -1,29 +0,0 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { redirect as googleRedirect } from '@/routes/auth/google';
defineProps<{
label: string;
}>();
const page = usePage();
const isEnabled = computed(() => page.props.googleAuthEnabled);
</script>
<template>
<template v-if="isEnabled">
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ label }}
</Button>
<div
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
</div>
</template>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconBrandGithub } from '@tabler/icons-vue';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { redirect as githubRedirect } from '@/routes/auth/github';
import { redirect as googleRedirect } from '@/routes/auth/google';
defineProps<{
mode: 'login' | 'signup';
}>();
const page = usePage();
const googleEnabled = computed(() => Boolean(page.props.googleAuthEnabled));
const githubEnabled = computed(() => Boolean(page.props.githubAuthEnabled));
const hasSocial = computed(() => googleEnabled.value || githubEnabled.value);
</script>
<template>
<template v-if="hasSocial">
<Button v-if="googleEnabled" variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ mode === 'login' ? $t('auth.google_login') : $t('auth.google_signup') }}
</Button>
<Button v-if="githubEnabled" variant="outline" class="w-full" as="a" :href="githubRedirect.url()">
<IconBrandGithub class="size-4" />
{{ mode === 'login' ? $t('auth.github_login') : $t('auth.github_signup') }}
</Button>
<div
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
</div>
</template>
</template>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
import SocialLogin from '@/components/auth/SocialLogin.vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
@ -31,7 +31,7 @@ defineProps<{
</div>
<div class="flex flex-col gap-6">
<GoogleAuthButton :label="$t('auth.google_login')" />
<SocialLogin mode="login" />
<Form v-bind="store.form()" :reset-on-success="['password']" v-slot="{ errors, processing }"
class="flex flex-col gap-6">

View file

@ -3,7 +3,7 @@ import { Form, Head } from '@inertiajs/vue3';
import { IconEye, IconEyeOff } from '@tabler/icons-vue';
import { ref } from 'vue';
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
import SocialLogin from '@/components/auth/SocialLogin.vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
@ -39,7 +39,7 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
<Head :title="$t('auth.register.page_title')" />
<div class="flex flex-col gap-6">
<GoogleAuthButton :label="$t('auth.google_signup')" />
<SocialLogin mode="signup" />
<Form
v-bind="store.form()"

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { IconDeviceDesktop, IconDeviceMobile } from '@tabler/icons-vue';
import { IconBrandGithub, IconDeviceDesktop, IconDeviceMobile } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
@ -29,6 +29,7 @@ import { settings as settingsHub } from '@/routes/app';
import { edit as editAuthentication } from '@/routes/app/authentication';
import { preferences as notificationPreferences } from '@/routes/app/notifications';
import { edit as editProfile } from '@/routes/app/profile';
import { redirect as githubRedirect } from '@/routes/auth/github';
import { redirect as googleRedirect } from '@/routes/auth/google';
import type { BreadcrumbItem } from '@/types';
@ -41,7 +42,7 @@ type Session = {
};
type ConnectedAccount = {
provider: 'google';
provider: 'google' | 'github';
label: string;
connected: boolean;
can_disconnect: boolean;
@ -67,6 +68,7 @@ const tabs = computed(() => [
const providerRedirects: Record<ConnectedAccount['provider'], () => { url: string }> = {
google: googleRedirect,
github: githubRedirect,
};
const passwordHeading = computed(() =>
@ -296,10 +298,15 @@ const logoutDialogOpen = ref(false);
>
<div class="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-muted">
<img
:src="`/images/social/${account.provider}.svg`"
v-if="account.provider === 'google'"
src="/images/social/google.svg"
:alt="account.label"
class="size-5"
/>
<IconBrandGithub
v-else-if="account.provider === 'github'"
class="size-5"
/>
</div>
<div class="flex-1 space-y-0.5">
<div class="text-sm font-medium">{{ account.label }}</div>

View file

@ -6,11 +6,12 @@
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\EmailVerificationPromptController;
use App\Http\Controllers\Auth\GitHubController;
use App\Http\Controllers\Auth\GoogleController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\SignupSuccessController;
use App\Http\Controllers\Auth\SocialLoginController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
@ -29,8 +30,11 @@
Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset');
Route::post('/reset-password', [NewPasswordController::class, 'store'])->name('password.store');
Route::get('/auth/google/redirect', [SocialLoginController::class, 'redirect'])->name('auth.google.redirect');
Route::get('/auth/google/callback', [SocialLoginController::class, 'callback'])->name('auth.google.callback');
Route::get('/auth/google/redirect', [GoogleController::class, 'redirect'])->name('auth.google.redirect');
Route::get('/auth/google/callback', [GoogleController::class, 'callback'])->name('auth.google.callback');
Route::get('/auth/github/redirect', [GitHubController::class, 'redirect'])->name('auth.github.redirect');
Route::get('/auth/github/callback', [GitHubController::class, 'callback'])->name('auth.github.callback');
});
Route::middleware(['auth'])->group(function () {

View file

@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
test('email registration saves utm parameters from the register page query string', function () {
$utms = [
'utm_source' => 'peerlist',
'utm_medium' => 'social',
'utm_campaign' => 'spring-launch',
'utm_term' => 'social-platform',
'utm_content' => 'cta-button',
];
$this->get(route('register', $utms));
$this->post(route('register.store'), [
'name' => 'UTM User',
'email' => 'utm@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'utm@example.com',
...$utms,
]);
});
test('email registration without utm parameters saves null utm columns and redirects without query string', function () {
$this->post(route('register.store'), [
'name' => 'No UTM User',
'email' => 'no-utm@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'no-utm@example.com',
'utm_source' => null,
'utm_medium' => null,
'utm_campaign' => null,
'utm_term' => null,
'utm_content' => null,
]);
});
test('email registration strips non-utm query params from the success redirect', function () {
$this->get(route('register', [
'utm_source' => 'peerlist',
'foo' => 'bar',
'ref' => '123',
]));
$this->post(route('register.store'), [
'name' => 'Strip Test',
'email' => 'strip@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', ['utm_source' => 'peerlist'], absolute: false));
});
test('google registration saves utm parameters captured before the oauth round-trip', function () {
$utms = [
'utm_source' => 'twitter',
'utm_medium' => 'paid',
'utm_campaign' => 'q1-growth',
];
$this->get(route('auth.google.redirect', $utms));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-utm';
$socialiteUser->name = 'Google UTM';
$socialiteUser->email = 'google-utm@example.com';
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))
->assertRedirect(route('register.success', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'google-utm@example.com',
...$utms,
'utm_term' => null,
'utm_content' => null,
]);
});
test('utm parameters captured on the register page survive a google oauth round-trip', function () {
$utms = ['utm_source' => 'newsletter', 'utm_medium' => 'email'];
$this->get(route('register', $utms));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-cross';
$socialiteUser->name = 'Cross Flow';
$socialiteUser->email = 'cross-flow@example.com';
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))
->assertRedirect(route('register.success', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'cross-flow@example.com',
...$utms,
]);
});
test('existing google user login consumes the utm session so utms do not leak to a later signup', function () {
User::factory()->create([
'email' => 'existing@example.com',
'google_id' => 'g-existing',
]);
$this->get(route('auth.google.redirect', ['utm_source' => 'twitter']));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-existing';
$socialiteUser->name = 'Existing User';
$socialiteUser->email = 'existing@example.com';
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))
->assertRedirect(route('app.home'));
expect(session()->get('utm_parameters'))->toBeNull();
});
test('invitation registration does not include utm parameters in its redirect', function () {
$this->get(route('register', ['utm_source' => 'email']));
$this->post(route('register.store'), [
'name' => 'Invited User',
'email' => 'invited@example.com',
'password' => 'Password123!',
'redirect' => '/invites/some-token',
])
->assertRedirect('/invites/some-token');
});
test('utm values longer than 255 characters are truncated before being stored', function () {
$longValue = str_repeat('a', 300);
$this->get(route('register', ['utm_source' => $longValue]));
$this->post(route('register.store'), [
'name' => 'Long UTM User',
'email' => 'long-utm@example.com',
'password' => 'Password123!',
]);
$user = User::where('email', 'long-utm@example.com')->first();
expect(mb_strlen($user->utm_source))->toBe(255);
});
test('email registration captures the requesting ip address', function () {
$this->post(route('register.store'), [
'name' => 'IP User',
'email' => 'ip@example.com',
'password' => 'Password123!',
]);
$user = User::where('email', 'ip@example.com')->first();
expect($user->registration_ip)->not->toBeNull();
});
test('google registration captures the requesting ip address', function () {
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-ip';
$socialiteUser->name = 'Google IP';
$socialiteUser->email = 'google-ip@example.com';
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'));
$user = User::where('email', 'google-ip@example.com')->first();
expect($user->registration_ip)->not->toBeNull();
});
test('github registration saves utm parameters captured before the oauth round-trip', function () {
$utms = [
'utm_source' => 'hackernews',
'utm_medium' => 'organic',
'utm_campaign' => 'launch-week',
];
$this->get(route('auth.github.redirect', $utms));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-utm';
$socialiteUser->name = 'GitHub UTM';
$socialiteUser->email = 'github-utm@example.com';
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('scopes')
->andReturnSelf();
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.github.callback'))
->assertRedirect(route('register.success', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'github-utm@example.com',
...$utms,
'utm_term' => null,
'utm_content' => null,
]);
});
test('github registration captures the requesting ip address', function () {
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-ip';
$socialiteUser->name = 'GitHub IP';
$socialiteUser->email = 'github-ip@example.com';
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('scopes')
->andReturnSelf();
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.github.callback'));
$user = User::where('email', 'github-ip@example.com')->first();
expect($user->registration_ip)->not->toBeNull();
expect($user->github_id)->toBe('gh-ip');
});
test('github registration without email redirects to login with error', function () {
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-no-email';
$socialiteUser->name = 'No Email';
$socialiteUser->email = null;
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('scopes')
->andReturnSelf();
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.github.callback'))
->assertRedirect(route('login'))
->assertSessionHasErrors('email');
$this->assertGuest();
});
test('existing github user login consumes the utm session and skips signup success', function () {
User::factory()->create([
'email' => 'existing-gh@example.com',
'github_id' => 'gh-existing',
]);
$this->get(route('auth.github.redirect', ['utm_source' => 'hackernews']));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-existing';
$socialiteUser->name = 'Existing GitHub';
$socialiteUser->email = 'existing-gh@example.com';
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('scopes')
->andReturnSelf();
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.github.callback'))
->assertRedirect(route('app.home'));
expect(session()->get('utm_parameters'))->toBeNull();
});

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
test('login page shares github auth enabled prop as false when disabled', function () {
config(['trypost.github_auth_enabled' => false]);
$response = $this->get(route('login'));
$response->assertOk();
$page = $response->original->getData()['page'];
expect($page['props']['githubAuthEnabled'])->toBeFalse();
});
test('login page shares github auth enabled prop as true when enabled', function () {
config(['trypost.github_auth_enabled' => true]);
$response = $this->get(route('login'));
$response->assertOk();
$page = $response->original->getData()['page'];
expect($page['props']['githubAuthEnabled'])->toBeTrue();
});
test('register page shares github auth enabled prop', function () {
config(['trypost.github_auth_enabled' => true]);
$response = $this->get(route('register'));
$response->assertOk();
$page = $response->original->getData()['page'];
expect($page['props']['githubAuthEnabled'])->toBeTrue();
});
test('github auth redirect route exists', function () {
config(['services.github.client_id' => 'test-id']);
config(['services.github.client_secret' => 'test-secret']);
config(['services.github.redirect' => 'https://app.trypost.test/auth/github/callback']);
$response = $this->get(route('auth.github.redirect'));
// Should redirect to GitHub OAuth, not 404
$response->assertRedirect();
});
test('github auth callback route exists', function () {
$response = $this->get(route('auth.github.callback'));
// Should redirect to login on failure (no OAuth code), not 404
$response->assertRedirect(route('login'));
});