diff --git a/lib/Mcp/Handler/AbstractToolHandler.php b/lib/Mcp/Handler/AbstractToolHandler.php index 028b6103..5612d34f 100644 --- a/lib/Mcp/Handler/AbstractToolHandler.php +++ b/lib/Mcp/Handler/AbstractToolHandler.php @@ -4,8 +4,8 @@ * Abstract base class for OpenBuilt MCP tool handlers. * * Provides the shared utilities (slug validation, auth check, error envelope, - * toArray/extractUuid coercion, deep-link builder) that every concrete handler - * needs, eliminating duplication across the handler family. + * toArray/extractUuid coercion, deep-link builder, per-Application RBAC) that + * every concrete handler needs, eliminating duplication across the handler family. * * @category Service * @package OCA\OpenBuilt\Mcp\Handler @@ -26,6 +26,8 @@ namespace OCA\OpenBuilt\Mcp\Handler; +use OCP\IGroupManager; +use OCP\IUser; use OCP\IUserSession; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -40,17 +42,26 @@ abstract class AbstractToolHandler protected const REGISTER_SLUG = 'openbuilt'; + /** + * Roles that grant write access to an Application. + * + * @var array + */ + private const WRITE_ROLES = ['owners', 'editors']; + /** * Constructor. * - * @param IUserSession $userSession User session used to resolve the current authenticated user. - * @param ContainerInterface $container DI container used to resolve OpenRegister services lazily. - * @param LoggerInterface $logger PSR logger used for non-fatal warnings and error logging. + * @param IUserSession $userSession User session used to resolve the current authenticated user. + * @param ContainerInterface $container DI container used to resolve OpenRegister services lazily. + * @param LoggerInterface $logger PSR logger used for non-fatal warnings and error logging. + * @param IGroupManager $groupManager Group manager used for admin and group membership checks. */ public function __construct( protected readonly IUserSession $userSession, protected readonly ContainerInterface $container, protected readonly LoggerInterface $logger, + protected readonly IGroupManager $groupManager, ) { }//end __construct() @@ -98,6 +109,149 @@ protected function requireAuthenticatedUser(): ?string }//end requireAuthenticatedUser() + /** + * Check whether the current user is an NC admin. + * + * Returns a forbidden error envelope when the user is not signed in or is not + * an admin. Returns null on success. + * + * @return array{isError: true, error: string, message: string}|null Null on allow. + */ + protected function requireAdminUser(): ?array + { + $uid = $this->requireAuthenticatedUser(); + if ($uid === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in.'); + } + + if ($this->groupManager->isAdmin($uid) === false) { + return $this->errorResult( + error: 'forbidden', + message: 'This operation requires Nextcloud admin privileges.' + ); + } + + return null; + + }//end requireAdminUser() + + /** + * Verify the current user holds an owners or editors role on the Application + * identified by $appSlug. + * + * When $allowAdminBypass is true (the default) NC admins pass without needing + * an explicit role entry. Set it to false for promotion (spec REQ-OBVP-007). + * + * Returns a forbidden/not_found error envelope on denial, null on allow. + * + * @param string $appSlug Slug of the target Application. + * @param bool $allowAdminBypass Whether NC admin group membership grants access. + * + * @return array{isError: true, error: string, message: string}|null Null on allow. + */ + protected function requireWriteRole(string $appSlug, bool $allowAdminBypass=true): ?array + { + $uid = $this->requireAuthenticatedUser(); + if ($uid === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in.'); + } + + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]); + if (is_array($apps) === false || $apps === []) { + return $this->errorResult(error: 'not_found', message: "No virtual app found for slug '{$appSlug}'."); + } + + $app = $this->toArray(item: $apps[0]); + + if ($allowAdminBypass === true && $this->groupManager->isAdmin($uid) === true) { + $this->logger->info( + 'OpenBuilt MCP: rbac.admin_bypass', + ['actor' => $uid, 'appSlug' => $appSlug] + ); + return null; + } + + if ($this->callerHasWriteRole(app: $app, uid: $uid) === true) { + return null; + } + + return $this->errorResult( + error: 'forbidden', + message: "You do not have owner or editor access to application '{$appSlug}'." + ); + + }//end requireWriteRole() + + /** + * Check whether $uid holds any WRITE_ROLES entry on the Application. + * + * Also checks group membership via IGroupManager. + * + * @param array $app Application data. + * @param string $uid Caller's user ID. + * + * @return bool + */ + private function callerHasWriteRole(array $app, string $uid): bool + { + $permissions = ($app['permissions'] ?? []); + if (is_array($permissions) === false) { + return false; + } + + $userSet = []; + $groupSet = []; + + foreach (self::WRITE_ROLES as $role) { + $bucket = ($permissions[$role] ?? []); + if (is_array($bucket) === false) { + continue; + } + + foreach ($bucket as $principal) { + if (is_string($principal) === false || $principal === '') { + continue; + } + + if (str_starts_with($principal, 'user:') === true) { + $pUid = substr($principal, 5); + if ($pUid !== '') { + $userSet[$pUid] = true; + } + + continue; + } + + $gid = $principal; + if (str_starts_with($principal, 'group:') === true) { + $gid = substr($principal, 6); + } + + if ($gid !== '') { + $groupSet[$gid] = true; + } + }//end foreach + }//end foreach + + if (isset($userSet[$uid]) === true) { + return true; + } + + $user = $this->userSession->getUser(); + if ($user instanceof IUser) { + $userGroups = $this->groupManager->getUserGroups($user); + foreach ($userGroups as $group) { + if (isset($groupSet[$group->getGID()]) === true) { + return true; + } + } + } + + return false; + + }//end callerHasWriteRole() + /** * Validate that a candidate string matches the OpenBuilt slug shape. * diff --git a/lib/Mcp/Handler/AddWidgetHandler.php b/lib/Mcp/Handler/AddWidgetHandler.php index b67da040..94dc372f 100644 --- a/lib/Mcp/Handler/AddWidgetHandler.php +++ b/lib/Mcp/Handler/AddWidgetHandler.php @@ -45,16 +45,17 @@ public function handle(array $args): array return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); } - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to add widgets.'); - } - $appSlug = $validation['appSlug']; $versionSlug = $validation['versionSlug']; $pageId = $validation['pageId']; $widgetType = $validation['widgetType']; $widgetConfig = $validation['widgetConfig']; + $rbacError = $this->requireWriteRole(appSlug: $appSlug); + if ($rbacError !== null) { + return $rbacError; + } + try { $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); diff --git a/lib/Mcp/Handler/CreateAppHandler.php b/lib/Mcp/Handler/CreateAppHandler.php index 89140287..8348eecf 100644 --- a/lib/Mcp/Handler/CreateAppHandler.php +++ b/lib/Mcp/Handler/CreateAppHandler.php @@ -54,8 +54,11 @@ public function handle(array $args): array return $this->errorResult(error: 'invalid_arguments', message: $argError); } - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to create a virtual app.'); + // Creating an app requires NC admin privileges (same policy as schema + // creation — the app author controls a new register + schemas). + $adminError = $this->requireAdminUser(); + if ($adminError !== null) { + return $adminError; } try { diff --git a/lib/Mcp/Handler/PromoteVersionHandler.php b/lib/Mcp/Handler/PromoteVersionHandler.php index 507041ac..54d14c19 100644 --- a/lib/Mcp/Handler/PromoteVersionHandler.php +++ b/lib/Mcp/Handler/PromoteVersionHandler.php @@ -49,14 +49,17 @@ public function handle(array $args): array return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); } - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to promote a virtual app version.'); - } - $appSlug = $validation['appSlug']; $sourceVersionSlug = $validation['sourceVersionSlug']; $strategy = $validation['strategy']; + // Spec REQ-OBVP-007 — NC admins are NOT auto-granted; caller must hold + // an explicit owners or editors entry on the Application. + $rbacError = $this->requireWriteRole(appSlug: $appSlug, allowAdminBypass: false); + if ($rbacError !== null) { + return $rbacError; + } + try { $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); diff --git a/lib/Mcp/Handler/UpsertMenuItemHandler.php b/lib/Mcp/Handler/UpsertMenuItemHandler.php index 0bd930e9..6adbba7e 100644 --- a/lib/Mcp/Handler/UpsertMenuItemHandler.php +++ b/lib/Mcp/Handler/UpsertMenuItemHandler.php @@ -47,14 +47,15 @@ public function handle(array $args): array return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); } - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author menu items.'); - } - $appSlug = $validation['appSlug']; $versionSlug = $validation['versionSlug']; $id = $validation['id']; + $rbacError = $this->requireWriteRole(appSlug: $appSlug); + if ($rbacError !== null) { + return $rbacError; + } + try { $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); diff --git a/lib/Mcp/Handler/UpsertPageHandler.php b/lib/Mcp/Handler/UpsertPageHandler.php index 4f254336..8ce0a11e 100644 --- a/lib/Mcp/Handler/UpsertPageHandler.php +++ b/lib/Mcp/Handler/UpsertPageHandler.php @@ -48,14 +48,15 @@ public function handle(array $args): array return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); } - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author pages.'); - } - $appSlug = $validation['appSlug']; $versionSlug = $validation['versionSlug']; $pageId = $validation['pageId']; + $rbacError = $this->requireWriteRole(appSlug: $appSlug); + if ($rbacError !== null) { + return $rbacError; + } + try { $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); diff --git a/lib/Mcp/Handler/UpsertSchemaHandler.php b/lib/Mcp/Handler/UpsertSchemaHandler.php index b95afe26..5c71c07d 100644 --- a/lib/Mcp/Handler/UpsertSchemaHandler.php +++ b/lib/Mcp/Handler/UpsertSchemaHandler.php @@ -46,8 +46,11 @@ public function handle(array $args): array return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); } - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author schemas.'); + // Schema creation/update mirrors OR's admin-only SchemasController gate + // (OR #1949/#1957/#1959 — default-secure checkSchemaManagePermission). + $adminError = $this->requireAdminUser(); + if ($adminError !== null) { + return $adminError; } $appSlug = $validation['appSlug']; diff --git a/lib/Mcp/OpenBuiltToolProvider.php b/lib/Mcp/OpenBuiltToolProvider.php index ab434ece..f6c95bea 100644 --- a/lib/Mcp/OpenBuiltToolProvider.php +++ b/lib/Mcp/OpenBuiltToolProvider.php @@ -313,7 +313,7 @@ public function isAdmin(string $userId): bool */ private function makeHandler(string $class): \OCA\OpenBuilt\Mcp\Handler\AbstractToolHandler { - return new $class($this->userSession, $this->container, $this->logger); + return new $class($this->userSession, $this->container, $this->logger, $this->groupManager); }//end makeHandler() }//end class diff --git a/tests/Unit/Mcp/OpenBuiltToolProviderTest.php b/tests/Unit/Mcp/OpenBuiltToolProviderTest.php index 5d6fe8c2..14515aa8 100644 --- a/tests/Unit/Mcp/OpenBuiltToolProviderTest.php +++ b/tests/Unit/Mcp/OpenBuiltToolProviderTest.php @@ -4,8 +4,8 @@ * Unit tests for OpenBuiltToolProvider. * * Covers: getAppId, getTools catalogue shape, invokeTool dispatch of an - * unknown tool id (no throw), argument validation, and the unauthenticated - * forbidden path. + * unknown tool id (no throw), argument validation, the unauthenticated + * forbidden path, and the per-Application RBAC gate on write tools. * * @category Test * @package OCA\OpenBuilt\Tests\Unit\Mcp @@ -24,6 +24,7 @@ namespace OCA\OpenBuilt\Tests\Unit\Mcp; use OCA\OpenBuilt\Mcp\OpenBuiltToolProvider; +use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; use OCP\IUserSession; @@ -275,4 +276,373 @@ public function testAuthenticatedUserIsResolved(): void }//end testAuthenticatedUserIsResolved() + // ------------------------------------------------------------------------- + // C1 + C2: RBAC gate tests for write tools + // ------------------------------------------------------------------------- + + /** + * createApp returns forbidden when caller is not an NC admin (C2/C1 policy). + * + * @return void + */ + public function testCreateAppForbiddenForNonAdmin(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('bob'); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->with('bob')->willReturn(false); + + $this->container->expects($this->never())->method('get'); + + $result = $this->provider->invokeTool('openbuilt.createApp', [ + 'slug' => 'my-app', + 'name' => 'My App', + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testCreateAppForbiddenForNonAdmin() + + /** + * createApp returns forbidden when unauthenticated. + * + * @return void + */ + public function testCreateAppForbiddenWhenUnauthenticated(): void + { + $this->userSession->method('getUser')->willReturn(null); + $this->container->expects($this->never())->method('get'); + + $result = $this->provider->invokeTool('openbuilt.createApp', [ + 'slug' => 'my-app', + 'name' => 'My App', + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testCreateAppForbiddenWhenUnauthenticated() + + /** + * upsertSchema returns forbidden for non-admin (C2 gate). + * + * @return void + */ + public function testUpsertSchemaForbiddenForNonAdmin(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('bob'); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->with('bob')->willReturn(false); + + $this->container->expects($this->never())->method('get'); + + $result = $this->provider->invokeTool('openbuilt.upsertSchema', [ + 'appSlug' => 'my-app', + 'slug' => 'my-schema', + 'title' => 'My Schema', + 'properties' => ['name' => ['type' => 'string']], + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testUpsertSchemaForbiddenForNonAdmin() + + /** + * upsertSchema returns forbidden when unauthenticated (C2 gate). + * + * @return void + */ + public function testUpsertSchemaForbiddenWhenUnauthenticated(): void + { + $this->userSession->method('getUser')->willReturn(null); + $this->container->expects($this->never())->method('get'); + + $result = $this->provider->invokeTool('openbuilt.upsertSchema', [ + 'appSlug' => 'my-app', + 'slug' => 'my-schema', + 'title' => 'My Schema', + 'properties' => ['name' => ['type' => 'string']], + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testUpsertSchemaForbiddenWhenUnauthenticated() + + /** + * upsertPage returns forbidden when caller has no owners/editors role (C1 gate). + * + * The ObjectService is wired to return the app without the caller in any role bucket. + * + * @return void + */ + public function testUpsertPageForbiddenForNonOwner(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('bob'); + $this->userSession->method('getUser')->willReturn($user); + + // Not an admin. + $this->groupManager->method('isAdmin')->with('bob')->willReturn(false); + // No group memberships. + $this->groupManager->method('getUserGroups')->willReturn([]); + + // App exists but bob is not an owner/editor. + $objectService = $this->createMock(\OCA\OpenRegister\Service\ObjectService::class); + $objectService->method('searchObjectsBySlug')->willReturn([ + ['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'permissions' => ['owners' => ['user:alice'], 'editors' => []]], + ]); + + $this->container->method('get') + ->with('OCA\OpenRegister\Service\ObjectService') + ->willReturn($objectService); + + $result = $this->provider->invokeTool('openbuilt.upsertPage', [ + 'appSlug' => 'my-app', + 'pageId' => 'home', + 'title' => 'Home', + 'type' => 'dashboard', + 'route' => '/home', + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testUpsertPageForbiddenForNonOwner() + + /** + * upsertPage proceeds past the RBAC gate when caller is in owners (C1 fix). + * + * The ObjectService returns the app with alice in owners; a second call + * (loadVersion) returns not_found, proving the gate was passed and business + * logic was reached. + * + * @return void + */ + public function testUpsertPageAllowedForOwner(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + $this->groupManager->method('isAdmin')->with('alice')->willReturn(false); + $this->groupManager->method('getUserGroups')->willReturn([]); + + $callCount = 0; + $objectService = $this->createMock(\OCA\OpenRegister\Service\ObjectService::class); + $objectService->method('searchObjectsBySlug') + ->willReturnCallback(function () use (&$callCount) { + $callCount++; + if ($callCount === 1) { + // RBAC lookup — app found, alice is an owner. + return [['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'permissions' => ['owners' => ['user:alice'], 'editors' => []]]]; + } + + // loadVersion lookup — app found again, version not found. + if ($callCount === 2) { + return [['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'name' => 'My App', 'permissions' => ['owners' => ['user:alice']]]]; + } + + return []; + }); + + $this->container->method('get') + ->with('OCA\OpenRegister\Service\ObjectService') + ->willReturn($objectService); + + $result = $this->provider->invokeTool('openbuilt.upsertPage', [ + 'appSlug' => 'my-app', + 'pageId' => 'home', + 'title' => 'Home', + 'type' => 'dashboard', + 'route' => '/home', + ]); + + // Gate passed; business logic returned not_found (no version), not forbidden. + $this->assertTrue($result['isError']); + $this->assertNotSame('forbidden', $result['error'], 'Owner should pass RBAC gate'); + $this->assertSame('not_found', $result['error']); + + }//end testUpsertPageAllowedForOwner() + + /** + * addWidget returns forbidden for a non-owner (C1 gate). + * + * @return void + */ + public function testAddWidgetForbiddenForNonOwner(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('bob'); + $this->userSession->method('getUser')->willReturn($user); + + $this->groupManager->method('isAdmin')->with('bob')->willReturn(false); + $this->groupManager->method('getUserGroups')->willReturn([]); + + $objectService = $this->createMock(\OCA\OpenRegister\Service\ObjectService::class); + $objectService->method('searchObjectsBySlug')->willReturn([ + ['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'permissions' => ['owners' => ['user:alice'], 'editors' => []]], + ]); + + $this->container->method('get') + ->with('OCA\OpenRegister\Service\ObjectService') + ->willReturn($objectService); + + $result = $this->provider->invokeTool('openbuilt.addWidget', [ + 'appSlug' => 'my-app', + 'pageId' => 'home', + 'widgetType' => 'stat-counter', + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testAddWidgetForbiddenForNonOwner() + + /** + * upsertMenuItem returns forbidden for a non-owner (C1 gate). + * + * @return void + */ + public function testUpsertMenuItemForbiddenForNonOwner(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('bob'); + $this->userSession->method('getUser')->willReturn($user); + + $this->groupManager->method('isAdmin')->with('bob')->willReturn(false); + $this->groupManager->method('getUserGroups')->willReturn([]); + + $objectService = $this->createMock(\OCA\OpenRegister\Service\ObjectService::class); + $objectService->method('searchObjectsBySlug')->willReturn([ + ['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'permissions' => ['owners' => ['user:alice'], 'editors' => []]], + ]); + + $this->container->method('get') + ->with('OCA\OpenRegister\Service\ObjectService') + ->willReturn($objectService); + + $result = $this->provider->invokeTool('openbuilt.upsertMenuItem', [ + 'appSlug' => 'my-app', + 'id' => 'nav-home', + 'label' => 'Home', + 'route' => '/home', + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testUpsertMenuItemForbiddenForNonOwner() + + /** + * promoteVersion returns forbidden for a non-owner even when caller is NC admin + * (spec REQ-OBVP-007 — no admin bypass for promotion). + * + * @return void + */ + public function testPromoteVersionForbiddenForAdminWithoutExplicitRole(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin-user'); + $this->userSession->method('getUser')->willReturn($user); + + // Caller IS an NC admin but has no explicit role entry. + $this->groupManager->method('isAdmin')->with('admin-user')->willReturn(true); + $this->groupManager->method('getUserGroups')->willReturn([]); + + $objectService = $this->createMock(\OCA\OpenRegister\Service\ObjectService::class); + $objectService->method('searchObjectsBySlug')->willReturn([ + ['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'permissions' => ['owners' => ['user:alice'], 'editors' => []]], + ]); + + $this->container->method('get') + ->with('OCA\OpenRegister\Service\ObjectService') + ->willReturn($objectService); + + $result = $this->provider->invokeTool('openbuilt.promoteVersion', [ + 'appSlug' => 'my-app', + 'sourceVersionSlug' => 'development', + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testPromoteVersionForbiddenForAdminWithoutExplicitRole() + + /** + * promoteVersion allows a caller with an explicit owners entry (no admin bypass + * needed; the gate is per-Application RBAC — spec REQ-OBVP-007). + * + * After the RBAC gate passes, loadVersion returns not_found (no version data + * wired), confirming the gate was cleared. + * + * @return void + */ + public function testPromoteVersionAllowedForExplicitOwner(): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + + // isAdmin is NOT called for promoteVersion (allowAdminBypass=false). + $this->groupManager->method('isAdmin')->willReturn(false); + $this->groupManager->method('getUserGroups')->willReturn([]); + + $callCount = 0; + $objectService = $this->createMock(\OCA\OpenRegister\Service\ObjectService::class); + $objectService->method('searchObjectsBySlug') + ->willReturnCallback(function () use (&$callCount) { + $callCount++; + if ($callCount === 1) { + // RBAC lookup — alice is an owner. + return [['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'permissions' => ['owners' => ['user:alice']]]]; + } + + // loadVersion: app found, version not found. + if ($callCount === 2) { + return [['uuid' => 'app-uuid-1', 'slug' => 'my-app', 'name' => 'My App']]; + } + + return []; + }); + + $this->container->method('get') + ->with('OCA\OpenRegister\Service\ObjectService') + ->willReturn($objectService); + + $result = $this->provider->invokeTool('openbuilt.promoteVersion', [ + 'appSlug' => 'my-app', + 'sourceVersionSlug' => 'development', + ]); + + // Gate passed; no version found → not_found, NOT forbidden. + $this->assertTrue($result['isError']); + $this->assertNotSame('forbidden', $result['error'], 'Explicit owner must pass RBAC gate'); + $this->assertSame('not_found', $result['error']); + + }//end testPromoteVersionAllowedForExplicitOwner() + + /** + * promoteVersion returns forbidden when unauthenticated. + * + * @return void + */ + public function testPromoteVersionForbiddenWhenUnauthenticated(): void + { + $this->userSession->method('getUser')->willReturn(null); + $this->container->expects($this->never())->method('get'); + + $result = $this->provider->invokeTool('openbuilt.promoteVersion', [ + 'appSlug' => 'my-app', + 'sourceVersionSlug' => 'development', + ]); + + $this->assertTrue($result['isError']); + $this->assertSame('forbidden', $result['error']); + + }//end testPromoteVersionForbiddenWhenUnauthenticated() + }//end class