From 8c68b1913871028282589147718f0505711238d8 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:39:04 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20Super-Admin=20tools=20for=20m?= =?UTF-8?q?anaging=20any=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🛠️ Introduced generic Super-Admin MCP tools, including `list-models`, `describe-model`, `list-records`, `show-record`, `create-record`, and `update-record`. - 🛡️ Restricted modification of critical fields (e.g., passwords, roles, tokens) to enhance security. - ✅ Added extensive feature tests for Super-Admin functionality and access control. - 📜 Increased pagination length to accommodate new tools on a single page. - 🔗 Registered Super-Admin tools in `EinundzwanzigServer`. --- app/Http/Controllers/LnurlAuthController.php | 14 +- app/Mcp/Servers/EinundzwanzigServer.php | 27 ++- app/Mcp/Support/SuperAdminModels.php | 162 ++++++++++++++++++ .../Concerns/AuthorizesSuperAdmin.php | 95 ++++++++++ .../SuperAdmin/SuperAdminCreateRecordTool.php | 63 +++++++ .../SuperAdminDescribeModelTool.php | 64 +++++++ .../SuperAdmin/SuperAdminListModelsTool.php | 27 +++ .../SuperAdmin/SuperAdminListRecordsTool.php | 67 ++++++++ .../SuperAdmin/SuperAdminShowRecordTool.php | 51 ++++++ .../SuperAdmin/SuperAdminUpdateRecordTool.php | 67 ++++++++ tests/Feature/Auth/LnurlAuthTest.php | 21 +++ tests/Feature/Mcp/EinundzwanzigServerTest.php | 2 +- tests/Feature/Mcp/OAuthMcpTest.php | 23 +++ tests/Feature/Mcp/SuperAdminMcpTest.php | 133 ++++++++++++++ 14 files changed, 810 insertions(+), 6 deletions(-) create mode 100644 app/Mcp/Support/SuperAdminModels.php create mode 100644 app/Mcp/Tools/SuperAdmin/Concerns/AuthorizesSuperAdmin.php create mode 100644 app/Mcp/Tools/SuperAdmin/SuperAdminCreateRecordTool.php create mode 100644 app/Mcp/Tools/SuperAdmin/SuperAdminDescribeModelTool.php create mode 100644 app/Mcp/Tools/SuperAdmin/SuperAdminListModelsTool.php create mode 100644 app/Mcp/Tools/SuperAdmin/SuperAdminListRecordsTool.php create mode 100644 app/Mcp/Tools/SuperAdmin/SuperAdminShowRecordTool.php create mode 100644 app/Mcp/Tools/SuperAdmin/SuperAdminUpdateRecordTool.php create mode 100644 tests/Feature/Mcp/SuperAdminMcpTest.php diff --git a/app/Http/Controllers/LnurlAuthController.php b/app/Http/Controllers/LnurlAuthController.php index 78f7cfe..30d1f82 100644 --- a/app/Http/Controllers/LnurlAuthController.php +++ b/app/Http/Controllers/LnurlAuthController.php @@ -211,20 +211,28 @@ final class LnurlAuthController extends Controller // Auth::login() calls Session::migrate(destroy: true) internally, // which wipes the previous session payload. Capture lang_country - // before the login and restore it on the fresh session so the - // dashboard URL keeps the user's chosen locale. + // and the post-login intended URL before the login and restore them + // on the fresh session. The intended URL lets the OAuth2 flow resume: + // a guest who clicked "Connect" in an MCP client is bounced to login + // and, after logging in, is sent back to /oauth/authorize instead of + // landing on the dashboard. $langCountry = session('lang_country', config('app.domain_country')); + $intendedUrl = session('url.intended'); Auth::login($user); session(['lang_country' => $langCountry]); + if ($intendedUrl !== null) { + session(['url.intended' => $intendedUrl]); + } + $country = str($langCountry) ->after('-') ->lower() ->value(); - return redirect()->route('dashboard', ['country' => $country]); + return redirect()->intended(route('dashboard', ['country' => $country])); } /** diff --git a/app/Mcp/Servers/EinundzwanzigServer.php b/app/Mcp/Servers/EinundzwanzigServer.php index efc25f4..348abdb 100644 --- a/app/Mcp/Servers/EinundzwanzigServer.php +++ b/app/Mcp/Servers/EinundzwanzigServer.php @@ -30,6 +30,12 @@ use App\Mcp\Tools\Search\SearchCoursesTool; use App\Mcp\Tools\Search\SearchLecturersTool; use App\Mcp\Tools\Search\SearchMeetupsTool; use App\Mcp\Tools\Search\SearchVenuesTool; +use App\Mcp\Tools\SuperAdmin\SuperAdminCreateRecordTool; +use App\Mcp\Tools\SuperAdmin\SuperAdminDescribeModelTool; +use App\Mcp\Tools\SuperAdmin\SuperAdminListModelsTool; +use App\Mcp\Tools\SuperAdmin\SuperAdminListRecordsTool; +use App\Mcp\Tools\SuperAdmin\SuperAdminShowRecordTool; +use App\Mcp\Tools\SuperAdmin\SuperAdminUpdateRecordTool; use App\Mcp\Tools\Venue\CreateVenueTool; use App\Mcp\Tools\Venue\ListMyVenuesTool; use App\Mcp\Tools\Venue\ShowMyVenueTool; @@ -72,6 +78,14 @@ die ID des gewählten Eintrags übergeben – ebenfalls ohne den Nutzer nach der Die Tools lösen Namen serverseitig auf. Bei Mehrdeutigkeit oder fehlendem Treffer liefern sie eine Liste der passenden Einträge zurück – diese dem Nutzer zur Auswahl anbieten. Die *_id- Parameter sind nur ein optionaler Fallback, falls die ID bereits bekannt ist. + +Super-Admins sehen zusätzlich generische super-admin-* Tools, mit denen JEDES Model bearbeitet +werden kann (ohne Ownership-Beschränkung). Vorgehen: erst super-admin-list-models, dann +super-admin-describe-model (für die Felder), dann super-admin-list-records / -show-record zum +Finden und schließlich super-admin-create-record / -update-record zum Bearbeiten. +Sicherheitskritische Felder (Passwörter, Auth-Tokens, Rollen) lassen sich über diese Tools +NICHT setzen – Rollen und Passwörter werden ausschließlich über die dafür vorgesehenen Wege +verwaltet. TXT)] class EinundzwanzigServer extends Server { @@ -81,9 +95,9 @@ class EinundzwanzigServer extends Server * nextCursor nicht – dann fehlt die Hälfte der Tools. Wir heben die Seitengröße an, * sodass alle Tools auf eine Seite passen. */ - public int $maxPaginationLength = 100; + public int $maxPaginationLength = 1000; - public int $defaultPaginationLength = 100; + public int $defaultPaginationLength = 1000; /** * The tools registered with this MCP server. @@ -138,5 +152,14 @@ class EinundzwanzigServer extends Server SearchLecturersTool::class, SearchCoursesTool::class, ListCountriesTool::class, + + // Super-Admin: generische Tools für ALLE Models (nur für Super-Admins sichtbar, + // via shouldRegister; created_by/Ownership-Beschränkungen entfallen hier bewusst). + SuperAdminListModelsTool::class, + SuperAdminDescribeModelTool::class, + SuperAdminListRecordsTool::class, + SuperAdminShowRecordTool::class, + SuperAdminCreateRecordTool::class, + SuperAdminUpdateRecordTool::class, ]; } diff --git a/app/Mcp/Support/SuperAdminModels.php b/app/Mcp/Support/SuperAdminModels.php new file mode 100644 index 0000000..adcbd2b --- /dev/null +++ b/app/Mcp/Support/SuperAdminModels.php @@ -0,0 +1,162 @@ + + */ + public const PROTECTED_ATTRIBUTES = [ + 'password', + 'remember_token', + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + 'role', + 'roles', + 'token', + 'refresh_token', + 'secret', + 'k1', + 'email_verified_at', + 'nostr', + ]; + + /** + * Kanonische Map Modelklasse → Tabellenname. Einmal pro Prozess ermittelt. + * + * @var array, string>|null + */ + private static ?array $models = null; + + /** + * Normalisierter Name (Kurzname ODER Tabelle) → Modelklasse. Lazy aus $models gebaut. + * + * @var array>|null + */ + private static ?array $index = null; + + /** + * Liste aller bearbeitbaren Models. + * + * @return array, table: string}> + */ + public static function list(): array + { + $models = []; + + foreach (self::models() as $class => $table) { + $models[] = [ + 'key' => Str::kebab(class_basename($class)), + 'class' => $class, + 'table' => $table, + ]; + } + + usort($models, fn (array $a, array $b): int => $a['key'] <=> $b['key']); + + return $models; + } + + /** + * Löst einen Model-Namen (Kurzname, FQCN, kebab-case oder Tabellenname) zur Klasse auf. + * + * @return class-string|null + */ + public static function resolve(?string $name): ?string + { + if (! is_string($name) || trim($name) === '') { + return null; + } + + return self::index()[self::normalize(Str::of($name)->afterLast('\\')->value())] ?? null; + } + + /** + * Verfügbare Model-Keys (kebab-case) als Auswahlhilfe. + * + * @return array + */ + public static function keys(): array + { + return array_map(fn (array $m): string => $m['key'], self::list()); + } + + /** + * Ermittelt einmalig alle ladbaren, konkreten Models unter App\Models samt Tabellenname. + * + * @return array, string> + */ + private static function models(): array + { + if (self::$models !== null) { + return self::$models; + } + + $models = []; + + foreach (glob(app_path('Models').'/*.php') ?: [] as $file) { + $class = 'App\\Models\\'.basename($file, '.php'); + + try { + // Manche Model-Stubs erben von nicht (mehr) installierten Vendor-Klassen + // (z. B. Jetstream) und lassen sich nicht laden – diese überspringen wir. + if (! class_exists($class) || ! is_subclass_of($class, Model::class)) { + continue; + } + + if ((new ReflectionClass($class))->isAbstract()) { + continue; + } + + $models[$class] = (new $class)->getTable(); + } catch (\Throwable) { + continue; + } + } + + return self::$models = $models; + } + + /** + * Baut den Auflösungs-Index: Kurzname UND Tabellenname (jeweils normalisiert) → Klasse. + * + * @return array> + */ + private static function index(): array + { + if (self::$index !== null) { + return self::$index; + } + + $index = []; + + foreach (self::models() as $class => $table) { + $index[self::normalize(class_basename($class))] = $class; + $index[self::normalize($table)] = $class; + } + + return self::$index = $index; + } + + private static function normalize(string $value): string + { + return Str::of($value)->lower()->replace(['-', '_', ' '], '')->value(); + } +} diff --git a/app/Mcp/Tools/SuperAdmin/Concerns/AuthorizesSuperAdmin.php b/app/Mcp/Tools/SuperAdmin/Concerns/AuthorizesSuperAdmin.php new file mode 100644 index 0000000..bccead7 --- /dev/null +++ b/app/Mcp/Tools/SuperAdmin/Concerns/AuthorizesSuperAdmin.php @@ -0,0 +1,95 @@ +superAdmin($request) !== null; + } + + private function superAdmin(?Request $request): ?User + { + $user = $request?->user(); + + return $user instanceof User && $user->hasRole('super-admin') ? $user : null; + } + + private function denyUnlessSuperAdmin(Request $request): ?Response + { + return $this->superAdmin($request) === null + ? Response::error('Diese Funktion ist nur für Super-Admins verfügbar.') + : null; + } + + /** + * Löst den 'model'-Parameter zur Eloquent-Klasse auf oder liefert eine Auswahlliste. + * + * @return class-string|Response + */ + private function resolveModel(Request $request): string|Response + { + $class = SuperAdminModels::resolve($request->get('model')); + + if ($class === null) { + return Response::error( + 'Unbekanntes Model: "'.$request->get('model').'". Verfügbar: ' + .implode(', ', SuperAdminModels::keys()).'.' + ); + } + + return $class; + } + + /** + * Lehnt das Setzen sicherheitskritischer Felder (Passwörter, Auth-Tokens, Rollen) + * über die generischen Super-Admin-Tools ab. + * + * @param array $attributes + */ + private function rejectProtectedAttributes(array $attributes): ?Response + { + $blocked = array_values(array_filter( + array_keys($attributes), + fn (string $key): bool => in_array(strtolower($key), SuperAdminModels::PROTECTED_ATTRIBUTES, true) + )); + + if ($blocked !== []) { + return Response::error( + 'Diese Felder können nicht über die Super-Admin-Tools geändert werden: ' + .implode(', ', $blocked).'. Passwörter und Rollen werden über die dafür ' + .'vorgesehenen Wege verwaltet.' + ); + } + + return null; + } + + /** + * Gemeinsamer "model"-Parameter für die Super-Admin-Tools. + */ + private function modelParameter(JsonSchema $schema): Type + { + return $schema->string() + ->description('Model-Name, Kurzform oder Tabelle (z. B. "meetup", "Meetup" oder "meetups"). Siehe super-admin-list-models.') + ->required(); + } +} diff --git a/app/Mcp/Tools/SuperAdmin/SuperAdminCreateRecordTool.php b/app/Mcp/Tools/SuperAdmin/SuperAdminCreateRecordTool.php new file mode 100644 index 0000000..03d9c17 --- /dev/null +++ b/app/Mcp/Tools/SuperAdmin/SuperAdminCreateRecordTool.php @@ -0,0 +1,63 @@ +denyUnlessSuperAdmin($request)) { + return $denied; + } + + $class = $this->resolveModel($request); + + if ($class instanceof Response) { + return $class; + } + + $attributes = (array) ($request->get('attributes') ?? []); + + if ($attributes === []) { + return Response::error('Bitte "attributes" mit den zu setzenden Feldern angeben.'); + } + + if ($blocked = $this->rejectProtectedAttributes($attributes)) { + return $blocked; + } + + try { + /** @var Model $record */ + $record = new $class; + $record->forceFill($attributes)->save(); + } catch (Throwable $e) { + return Response::error('Anlegen fehlgeschlagen: '.$e->getMessage()); + } + + return Response::json($record->fresh()->toArray()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'model' => $this->modelParameter($schema), + 'attributes' => $schema->object()->description('Objekt {spalte: wert} mit den zu setzenden Feldern.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/SuperAdmin/SuperAdminDescribeModelTool.php b/app/Mcp/Tools/SuperAdmin/SuperAdminDescribeModelTool.php new file mode 100644 index 0000000..c88bf0b --- /dev/null +++ b/app/Mcp/Tools/SuperAdmin/SuperAdminDescribeModelTool.php @@ -0,0 +1,64 @@ +denyUnlessSuperAdmin($request)) { + return $denied; + } + + $class = $this->resolveModel($request); + + if ($class instanceof Response) { + return $class; + } + + /** @var Model $model */ + $model = new $class; + $table = $model->getTable(); + + $columns = collect(Schema::getColumns($table))->map(fn (array $column): array => [ + 'name' => $column['name'], + 'type' => $column['type_name'] ?? $column['type'] ?? null, + 'nullable' => $column['nullable'] ?? null, + 'default' => $column['default'] ?? null, + ])->values(); + + return Response::json([ + 'model' => class_basename($class), + 'class' => $class, + 'table' => $table, + 'primary_key' => $model->getKeyName(), + 'columns' => $columns, + 'casts' => $model->getCasts(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'model' => $this->modelParameter($schema), + ]; + } +} diff --git a/app/Mcp/Tools/SuperAdmin/SuperAdminListModelsTool.php b/app/Mcp/Tools/SuperAdmin/SuperAdminListModelsTool.php new file mode 100644 index 0000000..466bd55 --- /dev/null +++ b/app/Mcp/Tools/SuperAdmin/SuperAdminListModelsTool.php @@ -0,0 +1,27 @@ +denyUnlessSuperAdmin($request)) { + return $denied; + } + + return Response::json(SuperAdminModels::list()); + } +} diff --git a/app/Mcp/Tools/SuperAdmin/SuperAdminListRecordsTool.php b/app/Mcp/Tools/SuperAdmin/SuperAdminListRecordsTool.php new file mode 100644 index 0000000..af1e834 --- /dev/null +++ b/app/Mcp/Tools/SuperAdmin/SuperAdminListRecordsTool.php @@ -0,0 +1,67 @@ +denyUnlessSuperAdmin($request)) { + return $denied; + } + + $class = $this->resolveModel($request); + + if ($class instanceof Response) { + return $class; + } + + /** @var Model $model */ + $model = new $class; + $columns = Schema::getColumnListing($model->getTable()); + + $filters = collect((array) ($request->get('filters') ?? [])) + ->only($columns); + + $limit = max(1, min(100, (int) ($request->get('limit') ?? 25))); + + $records = $class::query() + ->where($filters->all()) + ->latest($model->getKeyName()) + ->limit($limit) + ->get(); + + return Response::json([ + 'model' => class_basename($class), + 'count' => $records->count(), + 'records' => $records->map->toArray()->all(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'model' => $this->modelParameter($schema), + 'filters' => $schema->object()->description('Optionale exakte Filter als Objekt {spalte: wert}. Unbekannte Spalten werden ignoriert.'), + 'limit' => $schema->integer()->description('Maximale Anzahl Datensätze (1–100, Default 25).'), + ]; + } +} diff --git a/app/Mcp/Tools/SuperAdmin/SuperAdminShowRecordTool.php b/app/Mcp/Tools/SuperAdmin/SuperAdminShowRecordTool.php new file mode 100644 index 0000000..6be5fb1 --- /dev/null +++ b/app/Mcp/Tools/SuperAdmin/SuperAdminShowRecordTool.php @@ -0,0 +1,51 @@ +denyUnlessSuperAdmin($request)) { + return $denied; + } + + $class = $this->resolveModel($request); + + if ($class instanceof Response) { + return $class; + } + + $record = $class::query()->find($request->get('id')); + + if ($record === null) { + return Response::error('Datensatz mit ID '.$request->get('id').' in '.class_basename($class).' nicht gefunden.'); + } + + return Response::json($record->toArray()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'model' => $this->modelParameter($schema), + 'id' => $schema->integer()->description('Primärschlüssel des Datensatzes.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/SuperAdmin/SuperAdminUpdateRecordTool.php b/app/Mcp/Tools/SuperAdmin/SuperAdminUpdateRecordTool.php new file mode 100644 index 0000000..119fa0e --- /dev/null +++ b/app/Mcp/Tools/SuperAdmin/SuperAdminUpdateRecordTool.php @@ -0,0 +1,67 @@ +denyUnlessSuperAdmin($request)) { + return $denied; + } + + $class = $this->resolveModel($request); + + if ($class instanceof Response) { + return $class; + } + + $record = $class::query()->find($request->get('id')); + + if ($record === null) { + return Response::error('Datensatz mit ID '.$request->get('id').' in '.class_basename($class).' nicht gefunden.'); + } + + $attributes = (array) ($request->get('attributes') ?? []); + + if ($attributes === []) { + return Response::error('Bitte "attributes" mit den zu ändernden Feldern angeben.'); + } + + if ($blocked = $this->rejectProtectedAttributes($attributes)) { + return $blocked; + } + + try { + $record->forceFill($attributes)->save(); + } catch (Throwable $e) { + return Response::error('Aktualisieren fehlgeschlagen: '.$e->getMessage()); + } + + return Response::json($record->fresh()->toArray()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'model' => $this->modelParameter($schema), + 'id' => $schema->integer()->description('Primärschlüssel des zu ändernden Datensatzes.')->required(), + 'attributes' => $schema->object()->description('Objekt {spalte: wert} mit den zu ändernden Feldern.')->required(), + ]; + } +} diff --git a/tests/Feature/Auth/LnurlAuthTest.php b/tests/Feature/Auth/LnurlAuthTest.php index 06be8f7..ffd86b7 100644 --- a/tests/Feature/Auth/LnurlAuthTest.php +++ b/tests/Feature/Auth/LnurlAuthTest.php @@ -76,6 +76,27 @@ it('completes a Lightning login and redirects to the dashboard when a recent Log $this->assertAuthenticatedAs($user); }); +it('resumes the intended OAuth url after a Lightning login instead of going to the dashboard', function () { + $user = User::factory()->create(); + $k1 = bin2hex(random_bytes(32)); + LoginKey::factory()->create([ + 'user_id' => $user->id, + 'k1' => $k1, + 'created_at' => now(), + ]); + + $intended = url('/oauth/authorize?client_id=1&response_type=code&scope=mcp:use'); + + $response = $this->withSession([ + 'lang_country' => 'de-DE', + 'locale' => 'de', + 'url.intended' => $intended, + ])->get(route('auth.ln.complete', ['k1' => $k1])); + + $response->assertRedirect($intended); + $this->assertAuthenticatedAs($user); +}); + it('redirects to login when the LoginKey is older than 5 minutes', function () { $user = User::factory()->create(); $k1 = bin2hex(random_bytes(32)); diff --git a/tests/Feature/Mcp/EinundzwanzigServerTest.php b/tests/Feature/Mcp/EinundzwanzigServerTest.php index 6ae4e96..1d069ec 100644 --- a/tests/Feature/Mcp/EinundzwanzigServerTest.php +++ b/tests/Feature/Mcp/EinundzwanzigServerTest.php @@ -17,7 +17,7 @@ it('registers every domain tool on the server', function () { $property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools'); $tools = $property->getDefaultValue(); - expect($tools)->toHaveCount(32) + expect($tools)->toHaveCount(38) ->and($tools)->toContain(CreateMeetupTool::class) ->and($tools)->toContain(UpdateCourseEventTool::class) ->and($tools)->toContain(SearchCitiesTool::class); diff --git a/tests/Feature/Mcp/OAuthMcpTest.php b/tests/Feature/Mcp/OAuthMcpTest.php index fbddcd7..c3d452c 100644 --- a/tests/Feature/Mcp/OAuthMcpTest.php +++ b/tests/Feature/Mcp/OAuthMcpTest.php @@ -1,6 +1,7 @@ get('/oauth/authorize?response_type=code&client_id=1&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcb&code_challenge=abc123&code_challenge_method=plain') ->assertStatus(400); }); + +it('redirects a guest from the authorize endpoint to login and stores the intended url', function () { + $clients = app(ClientRepository::class); + $client = $clients->createAuthorizationCodeGrantClient( + name: 'Claude', + redirectUris: ['https://claude.ai/api/mcp/auth_callback'], + confidential: false, + ); + + $response = $this->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => 'https://claude.ai/api/mcp/auth_callback', + 'response_type' => 'code', + 'scope' => 'mcp:use', + 'state' => 'xyz', + 'code_challenge' => str_repeat('a', 43), + 'code_challenge_method' => 'S256', + ])); + + $response->assertRedirect(route('login')); + expect(session('url.intended'))->toContain('/oauth/authorize'); +}); diff --git a/tests/Feature/Mcp/SuperAdminMcpTest.php b/tests/Feature/Mcp/SuperAdminMcpTest.php new file mode 100644 index 0000000..84b7af6 --- /dev/null +++ b/tests/Feature/Mcp/SuperAdminMcpTest.php @@ -0,0 +1,133 @@ +create()->assignRole('super-admin'); +} + +it('lets a super-admin update a meetup created by someone else', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['name' => 'Altname', 'created_by' => $owner->id]); + + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminUpdateRecordTool::class, [ + 'model' => 'meetup', + 'id' => $meetup->id, + 'attributes' => ['name' => 'Vom Admin geändert'], + ]) + ->assertOk(); + + $this->assertDatabaseHas('meetups', [ + 'id' => $meetup->id, + 'name' => 'Vom Admin geändert', + ]); +}); + +it('lets a super-admin create any model', function () { + $country = Country::factory()->create(); + + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminCreateRecordTool::class, [ + 'model' => 'city', + 'attributes' => [ + 'name' => 'Adminstadt', + 'country_id' => $country->id, + 'longitude' => 1.0, + 'latitude' => 2.0, + ], + ]) + ->assertOk(); + + $this->assertDatabaseHas('cities', ['name' => 'Adminstadt']); +}); + +it('lists editable models and describes their columns', function () { + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminListModelsTool::class) + ->assertOk() + ->assertSee('meetup'); + + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminDescribeModelTool::class, ['model' => 'meetup']) + ->assertOk() + ->assertSee('telegram_link'); +}); + +it('lists and shows records of any model', function () { + $meetup = Meetup::factory()->create(['name' => 'Sichtbar']); + + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminListRecordsTool::class, ['model' => 'meetup']) + ->assertOk() + ->assertSee('Sichtbar'); + + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminShowRecordTool::class, ['model' => 'meetup', 'id' => $meetup->id]) + ->assertOk() + ->assertSee('Sichtbar'); +}); + +it('reports an unknown model with the list of available ones', function () { + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminShowRecordTool::class, ['model' => 'gibtsnicht', 'id' => 1]) + ->assertHasErrors(); +}); + +it('refuses to write password or role fields via super-admin tools', function () { + $target = User::factory()->create(['name' => 'Unverändert']); + $originalPassword = $target->password; + + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminUpdateRecordTool::class, [ + 'model' => 'user', + 'id' => $target->id, + 'attributes' => ['name' => 'Neu', 'password' => 'gehackt'], + ]) + ->assertHasErrors(); + + $fresh = $target->fresh(); + expect($fresh->name)->toBe('Unverändert') + ->and($fresh->password)->toBe($originalPassword); +}); + +it('refuses to write further protected fields like nostr or email verification', function () { + $target = User::factory()->create(['nostr' => 'npub-original']); + + EinundzwanzigServer::actingAs(superAdmin()) + ->tool(SuperAdminUpdateRecordTool::class, [ + 'model' => 'user', + 'id' => $target->id, + 'attributes' => ['nostr' => 'npub-gefälscht'], + ]) + ->assertHasErrors(); + + expect($target->fresh()->nostr)->toBe('npub-original'); +}); + +it('denies a non super-admin from using the super-admin tools', function () { + $meetup = Meetup::factory()->create(['name' => 'Geschützt']); + + EinundzwanzigServer::actingAs(User::factory()->create()) + ->tool(SuperAdminUpdateRecordTool::class, [ + 'model' => 'meetup', + 'id' => $meetup->id, + 'attributes' => ['name' => 'Hijack'], + ]) + ->assertHasErrors(); + + $this->assertDatabaseHas('meetups', ['id' => $meetup->id, 'name' => 'Geschützt']); +});