From 47d4283765676f3ec1b662677b129249a594846b Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 6 Nov 2026 19:50:28 +0100 Subject: [PATCH] fix(contexts): include circle (team) shares when resolving application access ContextMapper::applyOwnedOrSharedQuery resolved only owner, direct-user and group shares, so an application shared with a circle (team) was invisible to every member of that circle even though the share row and its cleanup wiring already exist. The write path accepts circle receivers on context shares without restriction, so such shares can be created via the API but are never surfaced to their recipients. Resolve the requesting user's circle ids and add a circle clause that mirrors the existing group clause, so circle members see circle-shared applications the same way group members already do. findForNavBar needs no change: its non-owner display clause is receiver-type agnostic and surfaces the context once this access query includes it. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Andy Scherzinger --- lib/Db/ContextMapper.php | 12 +++ tests/unit/Db/ContextMapperTest.php | 110 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/unit/Db/ContextMapperTest.php diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 0da199335e..127cb4d9c8 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -10,6 +10,7 @@ use OCA\Tables\AppInfo\Application; use OCA\Tables\Errors\NotFoundError; +use OCA\Tables\Helper\CircleHelper; use OCA\Tables\Helper\GroupHelper; use OCA\Tables\Helper\UserHelper; use OCP\AppFramework\Db\QBMapper; @@ -27,6 +28,7 @@ public function __construct( IDBConnection $db, protected UserHelper $userHelper, protected GroupHelper $groupHelper, + protected CircleHelper $circleHelper, ) { parent::__construct($db, $this->table, Context::class); } @@ -328,6 +330,16 @@ protected function applyOwnedOrSharedQuery(IQueryBuilder $qb, string $userId): v $sharedToConditions->add($groupShares); } + // shared to circle (team) clause + $circleIDs = $this->circleHelper->getCircleIdsForUser($userId); + if (!empty($circleIDs)) { + $circleShares = $qb->expr()->andX( + $qb->expr()->eq('s.receiver_type', $qb->createNamedParameter('circle')), + $qb->expr()->in('s.receiver', $qb->createNamedParameter($circleIDs, IQueryBuilder::PARAM_STR_ARRAY)), + ); + $sharedToConditions->add($circleShares); + } + // owned contexts + apply share conditions $qb->leftJoin('c', 'tables_shares', 's', $qb->expr()->andX( $qb->expr()->eq('c.id', 's.node_id'), diff --git a/tests/unit/Db/ContextMapperTest.php b/tests/unit/Db/ContextMapperTest.php new file mode 100644 index 0000000000..f6a5ca20b8 --- /dev/null +++ b/tests/unit/Db/ContextMapperTest.php @@ -0,0 +1,110 @@ +userHelper = $this->createMock(UserHelper::class); + $this->groupHelper = $this->createMock(GroupHelper::class); + $this->circleHelper = $this->createMock(CircleHelper::class); + // no group membership in these scenarios, so only the circle clause can match + $this->userHelper->method('getGroupIdsForUser')->willReturn([]); + $this->mapper = new ContextMapper( + $this->connectionAdapter, + $this->userHelper, + $this->groupHelper, + $this->circleHelper, + ); + $this->cleanup(); + } + + protected function tearDown(): void { + $this->cleanup(); + parent::tearDown(); + } + + private function cleanup(): void { + $qb = $this->connection->getQueryBuilder(); + $qb->delete('tables_contexts_context')->executeStatement(); + $qb = $this->connection->getQueryBuilder(); + $qb->delete('tables_shares')->executeStatement(); + } + + private function insertContext(string $ownerId): int { + $qb = $this->connection->getQueryBuilder(); + $qb->insert('tables_contexts_context') + ->setValue('name', $qb->createNamedParameter('Circle application')) + ->setValue('icon', $qb->createNamedParameter('star')) + ->setValue('description', $qb->createNamedParameter('')) + ->setValue('owner_id', $qb->createNamedParameter($ownerId)) + ->setValue('owner_type', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->executeStatement(); + return (int)$this->connection->lastInsertId(); + } + + private function insertCircleShare(int $contextId, string $circleId, string $sender): void { + $qb = $this->connection->getQueryBuilder(); + $qb->insert('tables_shares') + ->setValue('sender', $qb->createNamedParameter($sender)) + ->setValue('receiver', $qb->createNamedParameter($circleId)) + ->setValue('receiver_type', $qb->createNamedParameter('circle')) + ->setValue('node_id', $qb->createNamedParameter($contextId, IQueryBuilder::PARAM_INT)) + ->setValue('node_type', $qb->createNamedParameter('context')) + ->setValue('permission_read', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->setValue('permission_create', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->setValue('permission_update', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->setValue('permission_delete', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->setValue('permission_manage', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->setValue('created_at', $qb->createNamedParameter('2026-01-01 00:00:00')) + ->setValue('last_edit_at', $qb->createNamedParameter('2026-01-01 00:00:00')) + ->executeStatement(); + } + + /** + * A context shared to a circle must be resolved for a member of that circle, + * and must stay invisible to a user who is not a member. + */ + public function testFindAllResolvesCircleSharedContextForMembersOnly(): void { + $contextId = $this->insertContext('alice'); + $this->insertCircleShare($contextId, 'circle-single-id-1', 'alice'); + + $this->circleHelper->method('getCircleIdsForUser') + ->willReturnMap([ + ['bob', ['circle-single-id-1']], + ['carol', []], + ]); + + $memberIds = array_map( + static fn ($context) => $context->getId(), + $this->mapper->findAll('bob'), + ); + self::assertContains($contextId, $memberIds, 'a circle member should see the circle-shared application'); + + $nonMemberIds = array_map( + static fn ($context) => $context->getId(), + $this->mapper->findAll('carol'), + ); + self::assertNotContains($contextId, $nonMemberIds, 'a non-member should not see the circle-shared application'); + } +}