feat(auth): add dashboard-managed Coolify users
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m26s

This commit is contained in:
rosslh 2026-06-14 11:47:50 -04:00
parent 4ca700dfe7
commit e3cb2675dd
25 changed files with 893 additions and 164 deletions

View file

@ -2,7 +2,6 @@
namespace App\Actions\Fortify;
use App\Jobs\NotifySetupCompleteJob;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@ -36,14 +35,6 @@ public function create(array $input): User
])->validate();
if (User::count() == 0) {
// MapleDeploy: validate setup token for first user registration
if ($settings->setup_token) {
$providedToken = $input['setup_token'] ?? null;
if (! $providedToken || ! hash_equals($settings->setup_token, $providedToken)) {
abort(403);
}
}
// If this is the first user, make them the root user
// Team is already created in the database/seeders/ProductionSeeder.php
$user = (new User)->forceFill([
@ -58,18 +49,7 @@ public function create(array $input): User
// Disable registration after first user is created
$settings = instanceSettings();
$settings->is_registration_enabled = false;
// MapleDeploy: notify control plane that setup is complete
// Capture token before clearing so the job can authenticate with it
$callbackUrl = $settings->setup_callback_url;
$token = $settings->setup_token;
$settings->setup_token = null;
$settings->setup_callback_url = null;
$settings->save();
if ($callbackUrl && $token) {
NotifySetupCompleteJob::dispatch($token, $callbackUrl);
}
} else {
$user = User::create([
'name' => $input['name'],

View file

@ -6,6 +6,7 @@
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
@ -17,6 +18,13 @@ class ResetUserPassword implements ResetsUserPasswords
*/
public function reset(User $user, array $input): void
{
if ($user->isMapledeployRevoked()) {
// MapleDeploy branding: dashboard-managed revocation is restored only by mapledeploy:user:set-password.
throw ValidationException::withMessages([
'email' => [trans('passwords.user')],
]);
}
Validator::make($input, [
'password' => ['required', Password::defaults(), 'confirmed'],
])->validate();

View file

@ -0,0 +1,140 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserCreate extends Command
{
protected $signature = 'mapledeploy:user:create
{--email= : User email address}
{--name= : User display name}
{--admin : Create the first root admin user}
{--team-role=member : Root team role for non-admin users}';
protected $description = 'Create a Coolify user for MapleDeploy dashboard access management';
public function handle(): int
{
$password = $this->readPassword();
$input = [
'email' => $this->option('email'),
'name' => $this->option('name'),
'password' => $password,
'team_role' => $this->option('team-role'),
];
$validator = Validator::make($input, [
'email' => ['required', 'string', 'email', 'max:255'],
'name' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:8'],
'team_role' => ['required', Rule::in([Role::ADMIN->value, Role::MEMBER->value])],
]);
if ($validator->fails()) {
return $this->failWith('INVALID_INPUT');
}
$input['email'] = Str::lower((string) $input['email']);
if (User::whereEmail($input['email'])->exists()) {
return $this->failWith('EMAIL_EXISTS');
}
if ($this->option('admin')) {
return $this->createAdmin($input);
}
return $this->createMember($input);
}
private function createAdmin(array $input): int
{
if (User::count() !== 0) {
return $this->failWith('USERS_ALREADY_EXIST');
}
$user = DB::transaction(function () use ($input) {
$user = (new User)->forceFill([
'id' => 0,
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->save();
$user->markEmailAsVerified();
$settings = instanceSettings();
$settings->is_registration_enabled = false;
$attributes = $settings->getAttributes();
if (array_key_exists('setup_token', $attributes)) {
$settings->setup_token = null;
}
if (array_key_exists('setup_callback_url', $attributes)) {
$settings->setup_callback_url = null;
}
$settings->save();
return $user;
});
return $this->succeedWithUser($user);
}
private function createMember(array $input): int
{
$rootTeam = Team::find(0);
if (! $rootTeam) {
return $this->failWith('ROOT_TEAM_MISSING');
}
$user = DB::transaction(function () use ($input, $rootTeam) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->markEmailAsVerified();
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => $input['team_role']],
]);
return $user;
});
return $this->succeedWithUser($user);
}
private function readPassword(): string
{
return rtrim((string) stream_get_contents(STDIN), "\n");
}
private function succeedWithUser(User $user): int
{
$this->line(json_encode([
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
class UserList extends Command
{
protected $signature = 'mapledeploy:user:list';
protected $description = 'List Coolify users for MapleDeploy dashboard access management';
public function handle(): int
{
$users = User::with('teams')
->orderBy('id')
->get()
->map(fn (User $user) => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'created_at' => $user->created_at?->toISOString(),
'teams' => $user->teams
->map(fn ($team) => [
'id' => $team->id,
'name' => $team->name,
'role' => $team->pivot?->role,
])
->values()
->all(),
])
->values()
->all();
$this->line(json_encode(['users' => $users], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserRevoke extends Command
{
protected $signature = 'mapledeploy:user:revoke {user_id : Coolify user id}';
protected $description = 'Revoke a Coolify user login for MapleDeploy dashboard access management';
public function handle(): int
{
$userId = (int) $this->argument('user_id');
if ($userId === 0) {
return $this->failWith('CANNOT_REVOKE_ROOT_USER');
}
$user = User::find($userId);
if (! $user) {
return $this->failWith('USER_NOT_FOUND');
}
$user->forceFill([
'password' => Hash::make(Str::random(64)),
// MapleDeploy branding: OAuth login matches by email, so keep a
// persistent marker that the callback can reject after revocation.
'remember_token' => 'mapledeploy-revoked:'.Str::random(40),
])->save();
$user->tokens()->delete();
// MapleDeploy branding: revocation must end any active browser sessions.
DB::table('sessions')->where('user_id', $user->id)->delete();
$this->line(json_encode([
'revoked' => [
'id' => $user->id,
'email' => $user->email,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UserSetPassword extends Command
{
protected $signature = 'mapledeploy:user:set-password
{user_id : Coolify user id}
{--email= : New user email address}
{--name= : New user display name}';
protected $description = 'Set a Coolify user password for MapleDeploy dashboard access management';
public function handle(): int
{
$password = rtrim((string) stream_get_contents(STDIN), "\n");
$updatesOwner = $this->option('email') !== null || $this->option('name') !== null;
$input = [
'password' => $password,
'email' => $this->option('email'),
'name' => $this->option('name'),
];
$rules = ['password' => ['required', 'string', 'min:8']];
if ($updatesOwner) {
$rules['email'] = ['required', 'string', 'email', 'max:255'];
$rules['name'] = ['required', 'string', 'max:255'];
}
$validator = Validator::make($input, $rules);
if ($validator->fails()) {
return $this->failWith('INVALID_INPUT');
}
$user = User::find($this->argument('user_id'));
if (! $user) {
return $this->failWith('USER_NOT_FOUND');
}
$changes = [
'password' => Hash::make($password),
// MapleDeploy branding: clear the revocation marker when the
// dashboard intentionally restores this Coolify login.
'remember_token' => null,
];
if ($updatesOwner) {
$email = Str::lower((string) $input['email']);
if (User::whereEmail($email)->whereKeyNot($user->id)->exists()) {
return $this->failWith('EMAIL_EXISTS');
}
// MapleDeploy branding: claiming root admin transfers the Coolify
// account identity so the previous email holder cannot recover it.
$changes['email'] = $email;
$changes['name'] = $input['name'];
}
$user->forceFill($changes)->save();
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
// MapleDeploy branding: password resets from the dashboard should end
// any browser sessions authenticated with the previous password.
DB::table('sessions')->where('user_id', $user->id)->delete();
$this->line(json_encode([
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -78,6 +78,11 @@ public function forgot_password(Request $request)
return response()->json(['message' => 'Transactional emails are not active'], 400);
}
$request->validate([Fortify::email() => 'required|email']);
$user = User::where('email', $request->input(Fortify::email()))->first();
if ($user?->isMapledeployRevoked()) {
// MapleDeploy branding: only the dashboard set-password path can restore revoked users.
return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]);
}
$status = Password::broker(config('fortify.passwords'))->sendResetLink(
$request->only(Fortify::email())
);

View file

@ -25,6 +25,12 @@ public function callback(string $provider)
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
// MapleDeploy branding: dashboard revocation scrambles passwords,
// clears sessions, and marks the user so email-matched OAuth cannot
// reopen access.
if ($user?->isMapledeployRevoked()) {
abort(403, 'User access has been revoked');
}
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {

View file

@ -14,6 +14,7 @@
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\RejectMapledeployRevokedUser;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
@ -70,6 +71,7 @@ class Kernel extends HttpKernel
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
RejectMapledeployRevokedUser::class,
CheckForcePasswordReset::class,
DecideWhatToDoWithUser::class,

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RejectMapledeployRevokedUser
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = auth()->user();
if (! $user?->isMapledeployRevoked()) {
return $next($request);
}
// MapleDeploy branding: revocation is marked on the user row so old
// browser sessions are rejected even when SESSION_DRIVER is not database.
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->routeIs('login') || $request->path() === 'login') {
return $next($request);
}
return redirect()->route('login')->withErrors([
'email' => __('auth.failed'),
]);
}
}

View file

@ -1,55 +0,0 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Notify MapleDeploy that the first user has registered on this Coolify instance.
*
* Sends the setup token as a Bearer token so MapleDeploy can verify authenticity
* and clear its stored copy. The token acts as a one-time shared secret.
*/
class NotifySetupCompleteJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 5;
public array $backoff = [10, 30, 60, 120, 300];
public int $maxExceptions = 5;
public function __construct(
public string $setupToken,
public string $callbackUrl
) {
$this->onQueue('high');
}
public function handle(): void
{
$response = Http::withToken($this->setupToken)
->timeout(15)
->post($this->callbackUrl);
if (! $response->successful()) {
Log::warning('Setup-complete callback failed', [
'status' => $response->status(),
'url' => $this->callbackUrl,
]);
// Throw so the job retries
throw new \RuntimeException(
"Setup-complete callback returned HTTP {$response->status()}"
);
}
}
}

View file

@ -269,9 +269,19 @@ public function sendVerificationEmail()
public function sendPasswordResetNotification($token): void
{
if ($this->isMapledeployRevoked()) {
return;
}
$this?->notify(new TransactionalEmailsResetPassword($token));
}
public function isMapledeployRevoked(): bool
{
// MapleDeploy branding: dashboard-managed revocation stores a persistent marker.
return str_starts_with((string) $this->remember_token, 'mapledeploy-revoked:');
}
public function isAdmin()
{
return $this->role() === 'admin' || $this->role() === 'owner';

View file

@ -7,6 +7,7 @@
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\OauthSetting;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@ -51,17 +52,8 @@ public function boot(): void
$isFirstUser = User::count() === 0;
// MapleDeploy: token-gated registration for first user
if ($isFirstUser && $settings->setup_token) {
$token = request()->query('setup_token');
if (! $token || ! hash_equals($settings->setup_token, $token)) {
abort(403);
}
}
return view('auth.register', [
'isFirstUser' => $isFirstUser,
'setupToken' => request()->query('setup_token'),
]);
});
@ -69,16 +61,11 @@ public function boot(): void
$settings = instanceSettings();
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
if ($users == 0) {
// MapleDeploy: don't redirect to register if setup token is required
if ($settings->setup_token) {
return view('auth.login', [
'setup_pending' => true,
'is_registration_enabled' => false,
'enabled_oauth_providers' => collect(),
]);
}
// MapleDeploy branding: public registration is disabled by default
// because the dashboard creates the first admin over SSH. Do not
// redirect to /register in that fail-closed state, or fresh/failed
// provisioning loops between login and registration.
if ($users == 0 && $settings->is_registration_enabled) {
return redirect()->route('register');
}
@ -99,7 +86,7 @@ public function boot(): void
$user->save();
// Check if user has a pending invitation they haven't accepted yet
$invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
$invitation = TeamInvitation::whereEmail($email)->first();
if ($invitation && $invitation->isValid()) {
// User is logging in for the first time after being invited
// Attach them to the invited team if not already attached

View file

@ -48,14 +48,14 @@
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'options' => [
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
],
'options' => defined('\PDO::PGSQL_ATTR_DISABLE_PREPARES')
? [PDO::PGSQL_ATTR_DISABLE_PREPARES => env('DB_DISABLE_PREPARES', false)]
: [],
],
'testing' => [
'driver' => 'sqlite',
'database' => ':memory:',
'database' => env('DB_DATABASE', ':memory:'),
'prefix' => '',
'foreign_key_constraints' => true,
],

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('setup_token')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('setup_token');
});
}
};

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('setup_callback_url')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('setup_callback_url');
});
}
};

View file

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$columns = array_values(array_filter(
['setup_token', 'setup_callback_url'],
fn (string $column) => Schema::hasColumn('instance_settings', $column),
));
if ($columns === []) {
return;
}
Schema::table('instance_settings', function (Blueprint $table) use ($columns) {
// MapleDeploy branding: remove legacy one-time setup columns from customer instances.
$table->dropColumn($columns);
});
}
public function down(): void
{
$missingColumns = array_values(array_filter(
['setup_token', 'setup_callback_url'],
fn (string $column) => ! Schema::hasColumn('instance_settings', $column),
));
if ($missingColumns === []) {
return;
}
Schema::table('instance_settings', function (Blueprint $table) use ($missingColumns) {
if (in_array('setup_token', $missingColumns, true)) {
$table->text('setup_token')->nullable();
}
if (in_array('setup_callback_url', $missingColumns, true)) {
$table->text('setup_callback_url')->nullable();
}
});
}
};

View file

@ -15,7 +15,9 @@ public function run(): void
{
InstanceSettings::create([
'id' => 0,
'is_registration_enabled' => true,
// MapleDeploy branding: dashboard provisioning creates the first
// admin over SSH, so public registration must fail closed.
'is_registration_enabled' => false,
'is_api_enabled' => isDev(),
'smtp_enabled' => true,
'smtp_host' => 'coolify-mail',

View file

@ -47,6 +47,9 @@ public function run(): void
if (InstanceSettings::find(0) == null) {
InstanceSettings::create([
'id' => 0,
// MapleDeploy branding: dashboard provisioning creates the
// first admin over SSH, so public registration must fail closed.
'is_registration_enabled' => false,
]);
}

View file

@ -18,7 +18,7 @@
"auth.register_now": "Register",
"auth.logout": "Logout",
"auth.register": "Register",
"auth.registration_disabled": "Registration is disabled. Please contact the administrator.",
"auth.registration_disabled": "Set up server access in the MapleDeploy dashboard.",
"auth.reset_password": "Reset password",
"auth.failed": "These credentials do not match our records.",
"auth.failed.callback": "Failed to process callback from login provider.",
@ -41,4 +41,4 @@
"resource.delete_configurations": "Permanently delete all configuration files from the server.",
"database.delete_backups_locally": "All backups will be permanently deleted from local storage.",
"warning.sslipdomain": "Your configuration is saved, but sslip domain with https is <span class='dark:text-red-500 text-red-500 font-bold'>NOT</span> recommended, because Let's Encrypt servers with this public domain are rate limited (SSL certificate validation will fail). <br><br>Use your own domain instead."
}
}

View file

@ -17,7 +17,7 @@
"auth.register_now": "S'enregistrer",
"auth.logout": "Déconnexion",
"auth.register": "S'enregistrer",
"auth.registration_disabled": "L'enregistrement est désactivé. Merci de contacter l'administrateur.",
"auth.registration_disabled": "Configurez laccès au serveur dans le tableau de bord MapleDeploy.",
"auth.reset_password": "Réinitialiser le mot de passe",
"auth.failed": "Aucune correspondance n'a été trouvée pour les informations d'identification renseignées.",
"auth.failed.callback": "Erreur lors du processus de retour de la plateforme de connexion.",
@ -40,4 +40,4 @@
"resource.delete_configurations": "Supprimer définitivement tous les fichiers de configuration du serveur.",
"database.delete_backups_locally": "Toutes les sauvegardes seront définitivement supprimées du stockage local.",
"warning.sslipdomain": "Votre configuration est enregistrée, mais l'utilisation du domaine sslip avec https <span class='dark:text-red-500 text-red-500 font-bold'>N'EST PAS</span> recommandée, car les serveurs Let's Encrypt avec ce domaine public sont limités en taux (la validation du certificat SSL échouera). <br><br>Utilisez plutôt votre propre domaine."
}
}

View file

@ -53,9 +53,6 @@ function getOldOrLocal($key, $localValue)
<form action="/register" method="POST" class="flex flex-col gap-4">
@csrf
@if (isset($setupToken))
<input type="hidden" name="setup_token" value="{{ $setupToken }}" />
@endif
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}"
label="{{ __('input.name') }}" />
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"
@ -100,4 +97,4 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
</div>
</div>
</section>
</x-layout-simple>
</x-layout-simple>

View file

@ -0,0 +1,32 @@
<?php
use App\Models\InstanceSettings;
use Database\Seeders\InstanceSettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
uses(RefreshDatabase::class);
test('MapleDeploy instance settings seeder disables public registration by default', function () {
$this->seed(InstanceSettingsSeeder::class);
expect((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse();
});
test('login page does not redirect to registration when no users exist and registration is disabled', function () {
config()->set('app.maintenance.driver', 'file');
$this->app->instance(Vite::class, new class
{
public function __invoke(): string
{
return '';
}
});
$this->seed(InstanceSettingsSeeder::class);
$response = $this->get(route('login'));
$response->assertOk();
$response->assertViewIs('auth.login');
$response->assertViewHas('is_registration_enabled', false);
});

View file

@ -0,0 +1,91 @@
<?php
use App\Actions\Fortify\ResetUserPassword;
use App\Models\InstanceSettings;
use App\Models\User;
use App\Notifications\TransactionalEmails\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Once;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(function () {
InstanceSettings::query()->create([
'id' => 0,
'smtp_enabled' => true,
'smtp_from_address' => 'test@example.com',
'smtp_from_name' => 'MapleDeploy',
'smtp_host' => 'localhost',
'smtp_port' => 1025,
]);
});
Once::flush();
});
test('forgot password does not create a reset token for MapleDeploy revoked users', function () {
$user = User::factory()->create([
'email' => 'revoked@example.com',
'remember_token' => 'mapledeploy-revoked:abc123',
]);
$response = $this->post('/forgot-password', [
'email' => 'revoked@example.com',
]);
$response->assertSessionHas('status');
expect(DB::table('password_reset_tokens')->where('email', $user->email)->exists())->toBeFalse();
Notification::assertNothingSent();
});
test('forgot password still sends reset links for active users', function () {
$user = User::factory()->create([
'email' => 'active@example.com',
'remember_token' => null,
]);
$response = $this->post('/forgot-password', [
'email' => 'active@example.com',
]);
$response->assertSessionHas('status');
expect(DB::table('password_reset_tokens')->where('email', $user->email)->exists())->toBeTrue();
Notification::assertSentTo($user, ResetPassword::class);
});
test('reset password refuses MapleDeploy revoked users even with an existing token', function () {
$user = User::factory()->create([
'password' => Hash::make('old-password'),
'remember_token' => 'mapledeploy-revoked:abc123',
]);
expect(fn () => app(ResetUserPassword::class)->reset($user, [
'password' => 'new-password',
'password_confirmation' => 'new-password',
]))->toThrow(ValidationException::class);
expect(Hash::check('old-password', $user->fresh()->password))->toBeTrue()
->and($user->fresh()->remember_token)->toBe('mapledeploy-revoked:abc123');
});
test('revoked users are logged out even when sessions are not database backed', function () {
$user = User::factory()->create([
'remember_token' => 'mapledeploy-revoked:abc123',
'email_verified_at' => now(),
]);
$response = $this->actingAs($user)->get('/');
$response->assertRedirect(route('login'));
$this->assertGuest();
});

View file

@ -0,0 +1,310 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\Process\Process;
beforeEach(function () {
$this->originalDatabaseConfig = [
'default' => config('database.default'),
'testing_database' => config('database.connections.testing.database'),
];
$this->originalDatabaseEnvironment = [
'DB_CONNECTION' => [
'env' => $_ENV['DB_CONNECTION'] ?? null,
'server' => $_SERVER['DB_CONNECTION'] ?? null,
'process' => getenv('DB_CONNECTION') === false ? null : getenv('DB_CONNECTION'),
],
'DB_DATABASE' => [
'env' => $_ENV['DB_DATABASE'] ?? null,
'server' => $_SERVER['DB_DATABASE'] ?? null,
'process' => getenv('DB_DATABASE') === false ? null : getenv('DB_DATABASE'),
],
];
$this->databasePath = storage_path('framework/testing-mapledeploy-user-mgmt-'.bin2hex(random_bytes(6)).'.sqlite');
touch($this->databasePath);
config([
'database.default' => 'testing',
'database.connections.testing.database' => $this->databasePath,
]);
$_ENV['DB_CONNECTION'] = 'testing';
$_SERVER['DB_CONNECTION'] = 'testing';
$_ENV['DB_DATABASE'] = $this->databasePath;
$_SERVER['DB_DATABASE'] = $this->databasePath;
putenv('DB_CONNECTION=testing');
putenv("DB_DATABASE={$this->databasePath}");
$GLOBALS['mapledeployUserMgmtDatabasePath'] = $this->databasePath;
DB::purge('testing');
DB::reconnect('testing');
Artisan::call('migrate:fresh', ['--database' => 'testing']);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
});
afterEach(function () {
DB::disconnect('testing');
DB::purge('testing');
config([
'database.default' => $this->originalDatabaseConfig['default'] ?? null,
'database.connections.testing.database' => $this->originalDatabaseConfig['testing_database'] ?? null,
]);
if (isset($this->databasePath) && file_exists($this->databasePath)) {
unlink($this->databasePath);
}
foreach (($this->originalDatabaseEnvironment ?? []) as $key => $values) {
if ($values['env'] === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $values['env'];
}
if ($values['server'] === null) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $values['server'];
}
if ($values['process'] === null) {
putenv($key);
} else {
putenv("{$key}={$values['process']}");
}
}
unset($GLOBALS['mapledeployUserMgmtDatabasePath']);
});
function runMapledeployUserCommand(array $arguments, string $stdin = ''): array
{
$process = new Process(
[PHP_BINARY, 'artisan', ...$arguments],
base_path(),
[
'APP_ENV' => 'testing',
'APP_KEY' => config('app.key'),
'DB_CONNECTION' => 'testing',
'DB_DATABASE' => $GLOBALS['mapledeployUserMgmtDatabasePath'],
'CACHE_DRIVER' => 'array',
'SESSION_DRIVER' => 'database',
'QUEUE_CONNECTION' => 'sync',
'MAIL_MAILER' => 'array',
'SELF_HOSTED' => 'true',
],
);
$process->setInput($stdin);
$process->setTimeout(30);
$process->run();
$jsonLine = collect(explode("\n", $process->getOutput()))
->map(fn (string $line) => trim($line))
->first(fn (string $line) => str_starts_with($line, '{') && str_ends_with($line, '}'));
return [
'exitCode' => $process->getExitCode(),
'json' => $jsonLine ? json_decode($jsonLine, true, flags: JSON_THROW_ON_ERROR) : null,
'stdout' => $process->getOutput(),
'stderr' => $process->getErrorOutput(),
];
}
test('MapleDeploy user management commands create, list, reset, and revoke users', function () {
$admin = runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=Owner@Example.com',
'--name=Owner',
], "owner-password\n");
expect($admin['exitCode'])->toBe(0)
->and($admin['json']['user'])->toMatchArray([
'id' => 0,
'email' => 'owner@example.com',
'name' => 'Owner',
])
->and((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse()
->and(Hash::check('owner-password', User::findOrFail(0)->password))->toBeTrue();
$duplicateOwner = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=OWNER@Example.com',
'--name=Duplicate Owner',
], "duplicate-password\n");
expect($duplicateOwner['exitCode'])->toBe(1)
->and($duplicateOwner['json'])->toBe(['error' => 'EMAIL_EXISTS']);
$member = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=Member@Example.com',
'--name=Member',
'--team-role=admin',
], "member-password\n");
expect($member['exitCode'])->toBe(0)
->and($member['json']['user']['email'])->toBe('member@example.com');
$memberUser = User::whereEmail('member@example.com')->firstOrFail();
expect($memberUser->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin');
$list = runMapledeployUserCommand(['mapledeploy:user:list']);
expect($list['exitCode'])->toBe(0)
->and(collect($list['json']['users'])->pluck('email')->all())
->toBe(['owner@example.com', 'member@example.com']);
$resetOtherUser = User::factory()->create();
DB::table('sessions')->insert([
[
'id' => 'member-reset-session',
'user_id' => $memberUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('member-reset-payload'),
'last_activity' => now()->timestamp,
],
[
'id' => 'other-reset-session',
'user_id' => $resetOtherUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('other-reset-payload'),
'last_activity' => now()->timestamp,
],
]);
$reset = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $memberUser->id,
], "new-member-password\n");
expect($reset['exitCode'])->toBe(0)
->and(Hash::check('new-member-password', $memberUser->fresh()->password))->toBeTrue();
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0)
->and(DB::table('sessions')->where('user_id', $resetOtherUser->id)->count())->toBe(1);
DB::table('personal_access_tokens')->insert([
'tokenable_type' => User::class,
'tokenable_id' => $memberUser->id,
'name' => 'e2e-token',
'token' => hash('sha256', 'e2e-token'),
'team_id' => '0',
'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
expect($memberUser->tokens()->count())->toBe(1);
$otherUser = User::factory()->create();
DB::table('sessions')->insert([
[
'id' => 'member-session',
'user_id' => $memberUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('member-payload'),
'last_activity' => now()->timestamp,
],
[
'id' => 'other-session',
'user_id' => $otherUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('other-payload'),
'last_activity' => now()->timestamp,
],
]);
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(1);
$revokeRoot = runMapledeployUserCommand(['mapledeploy:user:revoke', '0']);
expect($revokeRoot['exitCode'])->toBe(1)
->and($revokeRoot['json'])->toBe(['error' => 'CANNOT_REVOKE_ROOT_USER']);
$revoke = runMapledeployUserCommand([
'mapledeploy:user:revoke',
(string) $memberUser->id,
]);
expect($revoke['exitCode'])->toBe(0)
->and($revoke['json']['revoked']['email'])->toBe('member@example.com')
->and($memberUser->fresh()->tokens()->count())->toBe(0);
expect(str_starts_with((string) $memberUser->fresh()->remember_token, 'mapledeploy-revoked:'))->toBeTrue();
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0)
->and(DB::table('sessions')->where('user_id', $otherUser->id)->count())->toBe(1);
$restore = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $memberUser->id,
], "restored-member-password\n");
expect($restore['exitCode'])->toBe(0)
->and(Hash::check('restored-member-password', $memberUser->fresh()->password))->toBeTrue()
->and($memberUser->fresh()->remember_token)->toBeNull();
});
test('MapleDeploy password command can transfer root ownership identity', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=old-owner@example.com',
'--name=Old Owner',
], "old-owner-password\n");
DB::table('sessions')->insert([
'id' => 'root-session',
'user_id' => 0,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('root-payload'),
'last_activity' => now()->timestamp,
]);
$claim = runMapledeployUserCommand([
'mapledeploy:user:set-password',
'0',
'--email=New.Owner@Example.com',
'--name=New Owner',
], "new-owner-password\n");
$root = User::findOrFail(0);
expect($claim['exitCode'])->toBe(0)
->and($claim['json']['user'])->toMatchArray([
'id' => 0,
'email' => 'new.owner@example.com',
'name' => 'New Owner',
])
->and($root->email)->toBe('new.owner@example.com')
->and($root->name)->toBe('New Owner')
->and(Hash::check('new-owner-password', $root->password))->toBeTrue()
->and($root->remember_token)->toBeNull()
->and($root->email_verified_at)->not->toBeNull();
expect(User::whereEmail('old-owner@example.com')->exists())->toBeFalse();
expect(DB::table('sessions')->where('user_id', 0)->count())->toBe(0);
});
test('MapleDeploy password command rejects ownership transfer to an existing email', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=old-owner@example.com',
'--name=Old Owner',
], "old-owner-password\n");
$existing = User::factory()->create(['email' => 'new.owner@example.com']);
$claim = runMapledeployUserCommand([
'mapledeploy:user:set-password',
'0',
'--email=new.owner@example.com',
'--name=New Owner',
], "new-owner-password\n");
expect($claim['exitCode'])->toBe(1)
->and($claim['json'])->toBe(['error' => 'EMAIL_EXISTS'])
->and(User::findOrFail(0)->email)->toBe('old-owner@example.com')
->and($existing->fresh()->email)->toBe('new.owner@example.com');
});
test('MapleDeploy user creation command reports validation errors as JSON', function () {
$invalid = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=not-an-email',
'--name=Invalid',
], "short\n");
expect($invalid['exitCode'])->toBe(1)
->and($invalid['json'])->toBe(['error' => 'INVALID_INPUT']);
});