security: enforce access control on CalendarSync and ContactsSync (Closes #170)
This commit was merged in pull request #188.
This commit is contained in:
@@ -8,6 +8,7 @@ use OCA\Mitgliederverwaltung\Db\Member;
|
||||
use OCA\Mitgliederverwaltung\Db\MemberMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
|
||||
@@ -17,6 +18,11 @@ use Sabre\VObject\Component\VCalendar;
|
||||
* Creates/manages the "Pfadfinder Geburtstage" calendar with recurring yearly
|
||||
* all-day events for each active member (excluding Erziehungsberechtigte).
|
||||
*
|
||||
* Note: Uses a custom staging table (mv_calendar_events) because
|
||||
* OCP\Calendar\IManager does not provide write methods. All sync
|
||||
* operations are guarded by PermissionService to enforce Nextcloud
|
||||
* access control. See Issue #170.
|
||||
*
|
||||
* Part of Issue #50.
|
||||
*/
|
||||
class CalendarSyncService {
|
||||
@@ -26,18 +32,56 @@ class CalendarSyncService {
|
||||
|
||||
private MemberMapper $memberMapper;
|
||||
private IDBConnection $db;
|
||||
private PermissionService $permissionService;
|
||||
private IUserSession $userSession;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
MemberMapper $memberMapper,
|
||||
IDBConnection $db,
|
||||
PermissionService $permissionService,
|
||||
IUserSession $userSession,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->memberMapper = $memberMapper;
|
||||
$this->db = $db;
|
||||
$this->permissionService = $permissionService;
|
||||
$this->userSession = $userSession;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current user has write-level access.
|
||||
*
|
||||
* Sync operations modify data (calendar events linked to members),
|
||||
* so at minimum write permission is required.
|
||||
*
|
||||
* In background job context (no user session), this check is skipped
|
||||
* as background jobs are system-level operations.
|
||||
*
|
||||
* @throws \RuntimeException if a user is logged in but lacks permission
|
||||
*/
|
||||
private function assertWritePermission(): void {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
// Background job context — no user session available.
|
||||
// Background jobs are trusted system-level operations.
|
||||
$this->logger->debug('Calendar sync running in background context (no user session)', [
|
||||
'app' => 'mitgliederverwaltung',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
if (!$this->permissionService->canWrite($userId)) {
|
||||
$this->logger->warning('Calendar sync denied: insufficient permissions', [
|
||||
'userId' => $userId,
|
||||
'app' => 'mitgliederverwaltung',
|
||||
]);
|
||||
throw new \RuntimeException('Calendar sync requires write permission');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single member's birthday event.
|
||||
*
|
||||
@@ -48,6 +92,8 @@ class CalendarSyncService {
|
||||
* @param Member $member The member to sync
|
||||
*/
|
||||
public function syncMember(Member $member): void {
|
||||
$this->assertWritePermission();
|
||||
|
||||
$shouldHaveEvent = $this->shouldHaveEvent($member);
|
||||
$hasEvent = $member->getCalendarEventUri() !== null;
|
||||
|
||||
@@ -68,6 +114,8 @@ class CalendarSyncService {
|
||||
* @return array{created: int, updated: int, deleted: int}
|
||||
*/
|
||||
public function fullSync(): array {
|
||||
$this->assertWritePermission();
|
||||
|
||||
$stats = ['created' => 0, 'updated' => 0, 'deleted' => 0];
|
||||
|
||||
try {
|
||||
@@ -214,6 +262,8 @@ class CalendarSyncService {
|
||||
* Delete a birthday calendar event for a member.
|
||||
*/
|
||||
public function deleteEvent(Member $member): void {
|
||||
$this->assertWritePermission();
|
||||
|
||||
$eventUri = $member->getCalendarEventUri();
|
||||
|
||||
if ($eventUri !== null) {
|
||||
|
||||
@@ -9,8 +9,10 @@ use OCA\Mitgliederverwaltung\Db\EmailMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\Member;
|
||||
use OCA\Mitgliederverwaltung\Db\MemberMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\PhoneMapper;
|
||||
use OCP\Contacts\IManager as IContactsManager;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Sabre\VObject\Component\VCard;
|
||||
|
||||
@@ -20,6 +22,11 @@ use Sabre\VObject\Component\VCard;
|
||||
* Creates/manages the "Pfadfinder Mitglieder" address book with VCards
|
||||
* for all active members (including Erziehungsberechtigte).
|
||||
*
|
||||
* Uses OCP\Contacts\IManager for Nextcloud-native contacts integration
|
||||
* with proper access control. Falls back to the staging table
|
||||
* (mv_contact_cards) when the Contacts app is not available.
|
||||
* See Issue #170.
|
||||
*
|
||||
* Part of Issue #51.
|
||||
*/
|
||||
class ContactsSyncService {
|
||||
@@ -31,6 +38,9 @@ class ContactsSyncService {
|
||||
private AddressMapper $addressMapper;
|
||||
private PhoneMapper $phoneMapper;
|
||||
private EmailMapper $emailMapper;
|
||||
private IContactsManager $contactsManager;
|
||||
private PermissionService $permissionService;
|
||||
private IUserSession $userSession;
|
||||
private IDBConnection $db;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
@@ -39,6 +49,9 @@ class ContactsSyncService {
|
||||
AddressMapper $addressMapper,
|
||||
PhoneMapper $phoneMapper,
|
||||
EmailMapper $emailMapper,
|
||||
IContactsManager $contactsManager,
|
||||
PermissionService $permissionService,
|
||||
IUserSession $userSession,
|
||||
IDBConnection $db,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
@@ -46,10 +59,65 @@ class ContactsSyncService {
|
||||
$this->addressMapper = $addressMapper;
|
||||
$this->phoneMapper = $phoneMapper;
|
||||
$this->emailMapper = $emailMapper;
|
||||
$this->contactsManager = $contactsManager;
|
||||
$this->permissionService = $permissionService;
|
||||
$this->userSession = $userSession;
|
||||
$this->db = $db;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current user has write-level access.
|
||||
*
|
||||
* Sync operations modify contact data linked to members,
|
||||
* so at minimum write permission is required.
|
||||
*
|
||||
* In background job context (no user session), this check is skipped
|
||||
* as background jobs are system-level operations.
|
||||
*
|
||||
* @throws \RuntimeException if a user is logged in but lacks permission
|
||||
*/
|
||||
private function assertWritePermission(): void {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
// Background job context — no user session available.
|
||||
// Background jobs are trusted system-level operations.
|
||||
$this->logger->debug('Contacts sync running in background context (no user session)', [
|
||||
'app' => 'mitgliederverwaltung',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
if (!$this->permissionService->canWrite($userId)) {
|
||||
$this->logger->warning('Contacts sync denied: insufficient permissions', [
|
||||
'userId' => $userId,
|
||||
'app' => 'mitgliederverwaltung',
|
||||
]);
|
||||
throw new \RuntimeException('Contacts sync requires write permission');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Nextcloud address book key for our dedicated address book.
|
||||
*
|
||||
* @return string|null The address book key, or null if not found
|
||||
*/
|
||||
private function findAddressBookKey(): ?string {
|
||||
if (!$this->contactsManager->isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$addressBooks = $this->contactsManager->getUserAddressBooks();
|
||||
foreach ($addressBooks as $book) {
|
||||
if ($book->getUri() === self::ADDRESSBOOK_URI) {
|
||||
return $book->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single member's VCard.
|
||||
*
|
||||
@@ -58,6 +126,8 @@ class ContactsSyncService {
|
||||
* - Inactive or deleted => delete VCard
|
||||
*/
|
||||
public function syncMember(Member $member): void {
|
||||
$this->assertWritePermission();
|
||||
|
||||
$shouldHaveContact = $this->shouldHaveContact($member);
|
||||
$hasContact = $member->getContactVcardUri() !== null;
|
||||
|
||||
@@ -76,6 +146,8 @@ class ContactsSyncService {
|
||||
* @return array{created: int, updated: int, deleted: int}
|
||||
*/
|
||||
public function fullSync(): array {
|
||||
$this->assertWritePermission();
|
||||
|
||||
$stats = ['created' => 0, 'updated' => 0, 'deleted' => 0];
|
||||
|
||||
try {
|
||||
@@ -185,12 +257,29 @@ class ContactsSyncService {
|
||||
|
||||
private function createContact(Member $member): void {
|
||||
$vcardUri = $this->generateVcardUri($member);
|
||||
$vcardData = $this->buildVCard($member);
|
||||
|
||||
// Try Nextcloud Contacts API first for proper access control integration
|
||||
$addressBookKey = $this->findAddressBookKey();
|
||||
if ($addressBookKey !== null) {
|
||||
$properties = $this->buildContactProperties($member);
|
||||
$result = $this->contactsManager->createOrUpdate($properties, $addressBookKey);
|
||||
if ($result !== null && isset($result['id'])) {
|
||||
$this->updateMemberContactUri($member->getId(), (string)$result['id']);
|
||||
$this->logger->debug('Created contact via Nextcloud Contacts API', [
|
||||
'memberId' => $member->getId(),
|
||||
'contactId' => $result['id'],
|
||||
'app' => 'mitgliederverwaltung',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to staging table when Contacts app is not available
|
||||
$vcardData = $this->buildVCard($member);
|
||||
$this->updateMemberContactUri($member->getId(), $vcardUri);
|
||||
$this->storeContactData($vcardUri, $vcardData);
|
||||
|
||||
$this->logger->debug('Created contact VCard', [
|
||||
$this->logger->debug('Created contact VCard (staging table fallback)', [
|
||||
'memberId' => $member->getId(),
|
||||
'vcardUri' => $vcardUri,
|
||||
'app' => 'mitgliederverwaltung',
|
||||
@@ -199,11 +288,28 @@ class ContactsSyncService {
|
||||
|
||||
private function updateContact(Member $member): void {
|
||||
$vcardUri = $member->getContactVcardUri();
|
||||
$vcardData = $this->buildVCard($member);
|
||||
|
||||
// Try Nextcloud Contacts API first for proper access control integration
|
||||
$addressBookKey = $this->findAddressBookKey();
|
||||
if ($addressBookKey !== null) {
|
||||
$properties = $this->buildContactProperties($member);
|
||||
// Include the existing contact ID for update
|
||||
$properties['id'] = $vcardUri;
|
||||
$result = $this->contactsManager->createOrUpdate($properties, $addressBookKey);
|
||||
if ($result !== null) {
|
||||
$this->logger->debug('Updated contact via Nextcloud Contacts API', [
|
||||
'memberId' => $member->getId(),
|
||||
'app' => 'mitgliederverwaltung',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to staging table
|
||||
$vcardData = $this->buildVCard($member);
|
||||
$this->storeContactData($vcardUri, $vcardData);
|
||||
|
||||
$this->logger->debug('Updated contact VCard', [
|
||||
$this->logger->debug('Updated contact VCard (staging table fallback)', [
|
||||
'memberId' => $member->getId(),
|
||||
'vcardUri' => $vcardUri,
|
||||
'app' => 'mitgliederverwaltung',
|
||||
@@ -211,6 +317,8 @@ class ContactsSyncService {
|
||||
}
|
||||
|
||||
public function deleteContact(Member $member): void {
|
||||
$this->assertWritePermission();
|
||||
|
||||
$vcardUri = $member->getContactVcardUri();
|
||||
|
||||
if ($vcardUri !== null) {
|
||||
@@ -227,6 +335,88 @@ class ContactsSyncService {
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build contact properties array for use with OCP\Contacts\IManager::createOrUpdate().
|
||||
*
|
||||
* @param Member $member The member to build properties for
|
||||
* @return array Key-value pairs matching RFC 2426 property names
|
||||
*/
|
||||
private function buildContactProperties(Member $member): array {
|
||||
$properties = [
|
||||
'FN' => $member->getVorname() . ' ' . $member->getNachname(),
|
||||
'N' => [
|
||||
$member->getNachname(),
|
||||
$member->getVorname(),
|
||||
'', '', '',
|
||||
],
|
||||
'NOTE' => 'Rolle: ' . $member->getRolle(),
|
||||
];
|
||||
|
||||
if ($member->getGeburtsdatum()) {
|
||||
$properties['BDAY'] = $member->getGeburtsdatum();
|
||||
}
|
||||
|
||||
// Addresses
|
||||
try {
|
||||
$addresses = $this->addressMapper->findByMemberId($member->getId());
|
||||
$adrList = [];
|
||||
foreach ($addresses as $address) {
|
||||
$adrList[] = [
|
||||
'type' => $address->getLabel() ?? 'HOME',
|
||||
'value' => [
|
||||
'', '',
|
||||
$address->getStrasse(),
|
||||
$address->getOrt(),
|
||||
'',
|
||||
$address->getPlz(),
|
||||
$address->getLand() ?? 'Deutschland',
|
||||
],
|
||||
];
|
||||
}
|
||||
if (!empty($adrList)) {
|
||||
$properties['ADR'] = $adrList;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip addresses on error
|
||||
}
|
||||
|
||||
// Phones
|
||||
try {
|
||||
$phones = $this->phoneMapper->findByMemberId($member->getId());
|
||||
$telList = [];
|
||||
foreach ($phones as $phone) {
|
||||
$telList[] = [
|
||||
'type' => $phone->getLabel() ?? 'CELL',
|
||||
'value' => $phone->getNumberE164(),
|
||||
];
|
||||
}
|
||||
if (!empty($telList)) {
|
||||
$properties['TEL'] = $telList;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip phones on error
|
||||
}
|
||||
|
||||
// Emails
|
||||
try {
|
||||
$emails = $this->emailMapper->findByMemberId($member->getId());
|
||||
$emailList = [];
|
||||
foreach ($emails as $email) {
|
||||
$emailList[] = [
|
||||
'type' => $email->getLabel() ?? 'HOME',
|
||||
'value' => $email->getEmail(),
|
||||
];
|
||||
}
|
||||
if (!empty($emailList)) {
|
||||
$properties['EMAIL'] = $emailList;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip emails on error
|
||||
}
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
private function shouldHaveContact(Member $member): bool {
|
||||
return $member->getStatus() === 'aktiv'
|
||||
&& $member->getDeletedAt() === null;
|
||||
|
||||
@@ -7,9 +7,12 @@ namespace OCA\Mitgliederverwaltung\Tests\Unit;
|
||||
use OCA\Mitgliederverwaltung\Db\Member;
|
||||
use OCA\Mitgliederverwaltung\Db\MemberMapper;
|
||||
use OCA\Mitgliederverwaltung\Service\CalendarSyncService;
|
||||
use OCA\Mitgliederverwaltung\Service\PermissionService;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -19,16 +22,47 @@ class CalendarSyncServiceTest extends TestCase {
|
||||
private CalendarSyncService $service;
|
||||
private MemberMapper&MockObject $memberMapper;
|
||||
private IDBConnection&MockObject $db;
|
||||
private PermissionService&MockObject $permissionService;
|
||||
private IUserSession&MockObject $userSession;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->memberMapper = $this->createMock(MemberMapper::class);
|
||||
$this->db = $this->createMock(IDBConnection::class);
|
||||
$this->permissionService = $this->createMock(PermissionService::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
// Default: no user session (background job context — permission check skipped)
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
|
||||
$this->service = new CalendarSyncService(
|
||||
$this->memberMapper,
|
||||
$this->db,
|
||||
$this->permissionService,
|
||||
$this->userSession,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a service instance with a specific user context.
|
||||
*/
|
||||
private function createServiceWithUser(string $userId, bool $canWrite): CalendarSyncService {
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
|
||||
$userSession = $this->createMock(IUserSession::class);
|
||||
$userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$permissionService = $this->createMock(PermissionService::class);
|
||||
$permissionService->method('canWrite')->with($userId)->willReturn($canWrite);
|
||||
|
||||
return new CalendarSyncService(
|
||||
$this->memberMapper,
|
||||
$this->db,
|
||||
$permissionService,
|
||||
$userSession,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
@@ -437,4 +471,65 @@ class CalendarSyncServiceTest extends TestCase {
|
||||
$this->service->enqueueSync(42);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ── Permission checks (Issue #170) ──────────────────────────
|
||||
|
||||
public function testSyncMemberDeniedWithoutWritePermission(): void {
|
||||
$service = $this->createServiceWithUser('nowrite-user', false);
|
||||
$member = $this->createMemberEntity(1, 'aktiv', 'mitglied');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Calendar sync requires write permission');
|
||||
$service->syncMember($member);
|
||||
}
|
||||
|
||||
public function testFullSyncDeniedWithoutWritePermission(): void {
|
||||
$service = $this->createServiceWithUser('nowrite-user', false);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Calendar sync requires write permission');
|
||||
$service->fullSync();
|
||||
}
|
||||
|
||||
public function testDeleteEventDeniedWithoutWritePermission(): void {
|
||||
$service = $this->createServiceWithUser('nowrite-user', false);
|
||||
$member = $this->createMemberEntity(1, 'inaktiv', 'mitglied', 'mv-birthday-1.ics');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Calendar sync requires write permission');
|
||||
$service->deleteEvent($member);
|
||||
}
|
||||
|
||||
public function testSyncMemberAllowedWithWritePermission(): void {
|
||||
if (!class_exists(\Sabre\VObject\Component\VCalendar::class)) {
|
||||
$this->markTestSkipped('Sabre VObject not available');
|
||||
}
|
||||
|
||||
$service = $this->createServiceWithUser('writer-user', true);
|
||||
|
||||
$qb = $this->createQueryBuilderMock();
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$member = $this->createMemberEntity(1, 'aktiv', 'mitglied');
|
||||
|
||||
// Should not throw
|
||||
$service->syncMember($member);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testSyncMemberAllowedInBackgroundContext(): void {
|
||||
if (!class_exists(\Sabre\VObject\Component\VCalendar::class)) {
|
||||
$this->markTestSkipped('Sabre VObject not available');
|
||||
}
|
||||
|
||||
// Default setUp has no user session (background context)
|
||||
$qb = $this->createQueryBuilderMock();
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$member = $this->createMemberEntity(1, 'aktiv', 'mitglied');
|
||||
|
||||
// Should not throw - background context skips permission check
|
||||
$this->service->syncMember($member);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,13 @@ use OCA\Mitgliederverwaltung\Db\MemberMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\Phone;
|
||||
use OCA\Mitgliederverwaltung\Db\PhoneMapper;
|
||||
use OCA\Mitgliederverwaltung\Service\ContactsSyncService;
|
||||
use OCA\Mitgliederverwaltung\Service\PermissionService;
|
||||
use OCP\Contacts\IManager as IContactsManager;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -27,6 +31,9 @@ class ContactsSyncServiceTest extends TestCase {
|
||||
private AddressMapper&MockObject $addressMapper;
|
||||
private PhoneMapper&MockObject $phoneMapper;
|
||||
private EmailMapper&MockObject $emailMapper;
|
||||
private IContactsManager&MockObject $contactsManager;
|
||||
private PermissionService&MockObject $permissionService;
|
||||
private IUserSession&MockObject $userSession;
|
||||
private IDBConnection&MockObject $db;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
|
||||
@@ -35,14 +42,55 @@ class ContactsSyncServiceTest extends TestCase {
|
||||
$this->addressMapper = $this->createMock(AddressMapper::class);
|
||||
$this->phoneMapper = $this->createMock(PhoneMapper::class);
|
||||
$this->emailMapper = $this->createMock(EmailMapper::class);
|
||||
$this->contactsManager = $this->createMock(IContactsManager::class);
|
||||
$this->permissionService = $this->createMock(PermissionService::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->db = $this->createMock(IDBConnection::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
// Default: no user session (background job context — permission check skipped)
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
|
||||
// Default: contacts manager not enabled (falls back to staging table)
|
||||
$this->contactsManager->method('isEnabled')->willReturn(false);
|
||||
|
||||
$this->service = new ContactsSyncService(
|
||||
$this->memberMapper,
|
||||
$this->addressMapper,
|
||||
$this->phoneMapper,
|
||||
$this->emailMapper,
|
||||
$this->contactsManager,
|
||||
$this->permissionService,
|
||||
$this->userSession,
|
||||
$this->db,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a service instance with a specific user context.
|
||||
*/
|
||||
private function createServiceWithUser(string $userId, bool $canWrite): ContactsSyncService {
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
|
||||
$userSession = $this->createMock(IUserSession::class);
|
||||
$userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$permissionService = $this->createMock(PermissionService::class);
|
||||
$permissionService->method('canWrite')->with($userId)->willReturn($canWrite);
|
||||
|
||||
$contactsManager = $this->createMock(IContactsManager::class);
|
||||
$contactsManager->method('isEnabled')->willReturn(false);
|
||||
|
||||
return new ContactsSyncService(
|
||||
$this->memberMapper,
|
||||
$this->addressMapper,
|
||||
$this->phoneMapper,
|
||||
$this->emailMapper,
|
||||
$contactsManager,
|
||||
$permissionService,
|
||||
$userSession,
|
||||
$this->db,
|
||||
$this->logger
|
||||
);
|
||||
@@ -542,4 +590,71 @@ class ContactsSyncServiceTest extends TestCase {
|
||||
$this->service->enqueueSync(42);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ── Permission checks (Issue #170) ──────────────────────────
|
||||
|
||||
public function testSyncMemberDeniedWithoutWritePermission(): void {
|
||||
$service = $this->createServiceWithUser('nowrite-user', false);
|
||||
$member = $this->createMemberEntity(1, 'aktiv');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Contacts sync requires write permission');
|
||||
$service->syncMember($member);
|
||||
}
|
||||
|
||||
public function testFullSyncDeniedWithoutWritePermission(): void {
|
||||
$service = $this->createServiceWithUser('nowrite-user', false);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Contacts sync requires write permission');
|
||||
$service->fullSync();
|
||||
}
|
||||
|
||||
public function testDeleteContactDeniedWithoutWritePermission(): void {
|
||||
$service = $this->createServiceWithUser('nowrite-user', false);
|
||||
$member = $this->createMemberEntity(1, 'inaktiv', 'mv-contact-1.vcf');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Contacts sync requires write permission');
|
||||
$service->deleteContact($member);
|
||||
}
|
||||
|
||||
public function testSyncMemberAllowedWithWritePermission(): void {
|
||||
if (!class_exists(\Sabre\VObject\Component\VCard::class)) {
|
||||
$this->markTestSkipped('Sabre VObject not available');
|
||||
}
|
||||
|
||||
$service = $this->createServiceWithUser('writer-user', true);
|
||||
|
||||
$qb = $this->createQueryBuilderMock();
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$member = $this->createMemberEntity(1, 'aktiv');
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
|
||||
// Should not throw
|
||||
$service->syncMember($member);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testSyncMemberAllowedInBackgroundContext(): void {
|
||||
if (!class_exists(\Sabre\VObject\Component\VCard::class)) {
|
||||
$this->markTestSkipped('Sabre VObject not available');
|
||||
}
|
||||
|
||||
// Default setUp has no user session (background context)
|
||||
$qb = $this->createQueryBuilderMock();
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$member = $this->createMemberEntity(1, 'aktiv');
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
|
||||
// Should not throw - background context skips permission check
|
||||
$this->service->syncMember($member);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user