53 - Collapse the fake user layer

This commit is contained in:
myrmidex 2026-08-15 23:47:03 +02:00
parent d2dfe44693
commit c34269d0e9
21 changed files with 161 additions and 453 deletions

View file

@ -13,7 +13,6 @@ APP_MAINTENANCE_DRIVER=file
PHP_CLI_SERVER_WORKERS=4 PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single LOG_STACK=single
@ -27,7 +26,7 @@ DB_DATABASE=incr
DB_USERNAME=incr_user DB_USERNAME=incr_user
DB_PASSWORD=change_me_in_production DB_PASSWORD=change_me_in_production
SESSION_DRIVER=database SESSION_DRIVER=cookie
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=true SESSION_ENCRYPT=true
SESSION_PATH=/ SESSION_PATH=/

View file

@ -1,60 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
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 Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Show the registration page.
*/
public function create(): Response
{
if (User::exists()) {
abort(403, 'Registration is disabled.');
}
return Inertia::render('auth/register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
if (User::exists()) {
abort(403, 'Registration is disabled.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::forceCreate([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
event(new Registered($user));
Auth::login($user);
return redirect()->intended(route('dashboard', absolute: false));
}
}

View file

@ -4,7 +4,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User; use App\Models\Tracker;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -12,7 +12,7 @@ class CounterController extends Controller
{ {
public function increment(): JsonResponse public function increment(): JsonResponse
{ {
$tracker = User::default()->tracker; $tracker = Tracker::current();
if (! $tracker) { if (! $tracker) {
return response()->json(['error' => 'No counter found.'], 404); return response()->json(['error' => 'No counter found.'], 404);
@ -30,7 +30,7 @@ public function update(Request $request): JsonResponse
'count' => 'required|integer|min:0|max:4294967295', 'count' => 'required|integer|min:0|max:4294967295',
]); ]);
$tracker = User::default()->tracker; $tracker = Tracker::current();
if (! $tracker) { if (! $tracker) {
return response()->json(['error' => 'No counter found.'], 404); return response()->json(['error' => 'No counter found.'], 404);

View file

@ -4,7 +4,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User; use App\Models\Tracker;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -12,7 +12,7 @@ class TrackerController extends Controller
{ {
public function show(): JsonResponse public function show(): JsonResponse
{ {
$tracker = User::default()->tracker; $tracker = Tracker::current();
if (! $tracker) { if (! $tracker) {
return response()->json(['exists' => false]); return response()->json(['exists' => false]);
@ -28,13 +28,11 @@ public function store(Request $request): JsonResponse
'unit' => 'sometimes|string|max:50', 'unit' => 'sometimes|string|max:50',
]); ]);
$user = User::default(); if (Tracker::current()) {
if ($user->tracker) {
return response()->json(['error' => 'Tracker already exists.'], 409); return response()->json(['error' => 'Tracker already exists.'], 409);
} }
$tracker = $user->tracker()->create([ $tracker = Tracker::create([
'label' => $validated['label'] ?? 'Counter', 'label' => $validated['label'] ?? 'Counter',
'unit' => $validated['unit'] ?? 'units', 'unit' => $validated['unit'] ?? 'units',
]); ]);
@ -49,7 +47,7 @@ public function update(Request $request): JsonResponse
'unit' => 'sometimes|string|max:50', 'unit' => 'sometimes|string|max:50',
]); ]);
$tracker = User::default()->tracker; $tracker = Tracker::current();
if (! $tracker) { if (! $tracker) {
return response()->json(['error' => 'No counter found.'], 404); return response()->json(['error' => 'No counter found.'], 404);

View file

@ -43,9 +43,6 @@ public function share(Request $request): array
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'quote' => ['message' => trim($message), 'author' => trim($author)], 'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user()?->only(['id', 'name', 'email']),
],
'ziggy' => fn (): array => [ 'ziggy' => fn (): array => [
...(new Ziggy)->toArray(), ...(new Ziggy)->toArray(),
'location' => $request->url(), 'location' => $request->url(),

View file

@ -1,86 +0,0 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View file

@ -7,7 +7,6 @@
use Database\Factories\TrackerFactory; use Database\Factories\TrackerFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Tracker extends Model class Tracker extends Model
{ {
@ -15,7 +14,6 @@ class Tracker extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'user_id',
'label', 'label',
'unit', 'unit',
'count', 'count',
@ -28,11 +26,8 @@ protected function casts(): array
]; ];
} }
/** public static function current(): ?self
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return self::orderBy('id')->first();
} }
} }

View file

@ -1,52 +0,0 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/**
* @return HasOne<Tracker, $this>
*/
public function tracker(): HasOne
{
return $this->hasOne(Tracker::class);
}
public static function default(): self
{
return self::firstWhere('email', 'user@incr.local')
?? self::forceCreate([
'email' => 'user@incr.local',
'name' => 'Default User',
'password' => bcrypt(Str::random(32)),
]);
}
}

View file

@ -1,117 +0,0 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the amount of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View file

@ -18,7 +18,7 @@
| |
*/ */
'driver' => env('SESSION_DRIVER', 'database'), 'driver' => env('SESSION_DRIVER', 'cookie'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -5,7 +5,6 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Tracker; use App\Models\Tracker;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -21,7 +20,6 @@ class TrackerFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'user_id' => fn () => User::default()->id,
'label' => 'Counter', 'label' => 'Counter',
'unit' => 'units', 'unit' => 'units',
'count' => 0, 'count' => 0,

View file

@ -1,45 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View file

@ -44,7 +44,7 @@ public function down(): void
}); });
Schema::table('trackers', function (Blueprint $table): void { Schema::table('trackers', function (Blueprint $table): void {
$table->foreignId('asset_id')->nullable()->after('user_id')->constrained()->nullOnDelete(); $table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('price_tracking_enabled')->default(false); $table->boolean('price_tracking_enabled')->default(false);
}); });
} }

View file

@ -0,0 +1,57 @@
<?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('trackers', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
}
// Lossy by design: the app has no users, so nothing is restored into these tables.
public function down(): void
{
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table): void {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
// Nullable, unlike the original: existing trackers have no user to point at,
// so a NOT NULL foreign key cannot be added back.
Schema::table('trackers', function (Blueprint $table): void {
$table->foreignId('user_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
}
};

View file

@ -2,8 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User; use App\Models\Tracker;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
@ -13,11 +12,10 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); Tracker::firstOrCreate([], [
'label' => 'Counter',
User::factory()->create([ 'unit' => 'units',
'name' => 'Test User', 'count' => 0,
'email' => 'test@example.com',
]); ]);
} }
} }

View file

@ -1,8 +0,0 @@
<?php
use App\Http\Controllers\Auth\RegisteredUserController;
use Illuminate\Support\Facades\Route;
// First-run setup only — gated by User::exists() in the controller
Route::get('register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);

View file

@ -21,5 +21,3 @@
// Counter routes // Counter routes
Route::post('/increment', [CounterController::class, 'increment'])->name('counter.increment'); Route::post('/increment', [CounterController::class, 'increment'])->name('counter.increment');
Route::patch('/count', [CounterController::class, 'update'])->name('counter.update'); Route::patch('/count', [CounterController::class, 'update'])->name('counter.update');
require __DIR__.'/auth.php';

View file

@ -32,16 +32,7 @@ private function revertToLedger(): void
private function makeTracker(): int private function makeTracker(): int
{ {
$userId = DB::table('users')->insertGetId([
'name' => 'Test',
'email' => 'ledger@example.test',
'password' => 'x',
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('trackers')->insertGetId([ return DB::table('trackers')->insertGetId([
'user_id' => $userId,
'label' => 'Counter', 'label' => 'Counter',
'unit' => 'units', 'unit' => 'units',
'created_at' => now(), 'created_at' => now(),

View file

@ -53,16 +53,7 @@ public function test_dropping_assets_does_not_disturb_the_counter(): void
{ {
$this->migration()->down(); $this->migration()->down();
$userId = DB::table('users')->insertGetId([
'name' => 'Test',
'email' => 'assets@example.test',
'password' => 'x',
'created_at' => now(),
'updated_at' => now(),
]);
$trackerId = DB::table('trackers')->insertGetId([ $trackerId = DB::table('trackers')->insertGetId([
'user_id' => $userId,
'label' => 'Counter', 'label' => 'Counter',
'unit' => 'units', 'unit' => 'units',
'count' => 31, 'count' => 31,

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DropUsersMigrationTest extends TestCase
{
use RefreshDatabase;
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): Migration
{
return require __DIR__.'/../../database/migrations/2026_08_15_000004_drop_users_and_sessions.php';
}
public function test_the_migrated_schema_has_no_user_tables(): void
{
$this->assertFalse(Schema::hasTable('users'));
$this->assertFalse(Schema::hasTable('sessions'));
$this->assertFalse(Schema::hasTable('password_reset_tokens'));
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
}
public function test_down_restores_the_tables_and_column(): void
{
$this->migration()->down();
$this->assertTrue(Schema::hasTable('users'));
$this->assertTrue(Schema::hasTable('sessions'));
$this->assertTrue(Schema::hasTable('password_reset_tokens'));
$this->assertTrue(Schema::hasColumn('trackers', 'user_id'));
}
public function test_restored_user_id_is_nullable_so_existing_trackers_survive(): void
{
DB::table('trackers')->insert([
'label' => 'Counter',
'unit' => 'units',
'count' => 3,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->down();
$nullable = DB::selectOne(
'select is_nullable from information_schema.columns
where table_schema = database() and table_name = ? and column_name = ?',
['trackers', 'user_id']
);
$this->assertSame('YES', $nullable->is_nullable ?? $nullable->IS_NULLABLE);
}
public function test_up_drops_them_again_respecting_foreign_keys(): void
{
$this->migration()->down();
$this->migration()->up();
$this->assertFalse(Schema::hasTable('users'));
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
}
public function test_the_counter_survives_the_round_trip(): void
{
$trackerId = DB::table('trackers')->insertGetId([
'label' => 'Counter',
'unit' => 'units',
'count' => 77,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->down();
$this->migration()->up();
$this->assertSame(77, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
}
}