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.
319 lines
9.4 KiB
PHP
319 lines
9.4 KiB
PHP
<?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();
|
|
});
|