feat: bulk-reveal encrypted Allergien on member list (admin-only) (#198)
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 35s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 4s
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 35s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 4s
This commit was merged in pull request #198.
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<name>Mitgliederverwaltung</name>
|
<name>Mitgliederverwaltung</name>
|
||||||
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
|
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
|
||||||
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
|
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
|
||||||
<version>0.2.10</version>
|
<version>0.2.11</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>shahondin1624</author>
|
<author>shahondin1624</author>
|
||||||
<namespace>Mitgliederverwaltung</namespace>
|
<namespace>Mitgliederverwaltung</namespace>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ return [
|
|||||||
// ── Member archive (soft-deleted, admin-only) ───────────────
|
// ── Member archive (soft-deleted, admin-only) ───────────────
|
||||||
['name' => 'member#archive', 'url' => '/api/v1/members/archive', 'verb' => 'GET'],
|
['name' => 'member#archive', 'url' => '/api/v1/members/archive', 'verb' => 'GET'],
|
||||||
|
|
||||||
|
// ── Admin: reveal encrypted Allergien (audited) ──────────────
|
||||||
|
['name' => 'member#revealAllergies', 'url' => '/api/v1/members/allergien/reveal', 'verb' => 'POST'],
|
||||||
|
|
||||||
// ── Member CRUD ──────────────────────────────────────────────
|
// ── Member CRUD ──────────────────────────────────────────────
|
||||||
['name' => 'member#index', 'url' => '/api/v1/members', 'verb' => 'GET'],
|
['name' => 'member#index', 'url' => '/api/v1/members', 'verb' => 'GET'],
|
||||||
['name' => 'member#show', 'url' => '/api/v1/members/{id}', 'verb' => 'GET'],
|
['name' => 'member#show', 'url' => '/api/v1/members/{id}', 'verb' => 'GET'],
|
||||||
|
|||||||
@@ -282,6 +282,41 @@ class MemberController extends ApiController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Admin-only: bulk-reveal encrypted Allergien ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal decrypted Allergien for every non-deleted member.
|
||||||
|
*
|
||||||
|
* POST /api/v1/members/allergien/reveal
|
||||||
|
*
|
||||||
|
* Admin-only (enforced by AuthorizationMiddleware).
|
||||||
|
* Each reveal is audited. Response is not cached; clients are expected
|
||||||
|
* to drop the plaintext from memory after use.
|
||||||
|
*
|
||||||
|
* @return JSONResponse { data: { [memberId]: plaintext|null } }
|
||||||
|
*/
|
||||||
|
public function revealAllergies(): JSONResponse {
|
||||||
|
try {
|
||||||
|
$user = $this->userSession->getUser();
|
||||||
|
$userId = $user !== null ? $user->getUID() : 'unknown';
|
||||||
|
|
||||||
|
$map = $this->memberService->revealAllAllergies($userId);
|
||||||
|
|
||||||
|
$response = new JSONResponse(['data' => $map]);
|
||||||
|
$response->addHeader('Cache-Control', 'no-store');
|
||||||
|
return $response;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Allergien reveal fehlgeschlagen', [
|
||||||
|
'exception' => $e,
|
||||||
|
'app' => 'mitgliederverwaltung',
|
||||||
|
]);
|
||||||
|
return new JSONResponse(
|
||||||
|
['error' => 'Allergien konnten nicht entschluesselt werden.'],
|
||||||
|
Http::STATUS_INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Address sub-entity endpoints ─────────────────────────────────
|
// ── Address sub-entity endpoints ─────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use OCA\Mitgliederverwaltung\Controller\ExportController;
|
|||||||
use OCA\Mitgliederverwaltung\Controller\FeeController;
|
use OCA\Mitgliederverwaltung\Controller\FeeController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\FileController;
|
use OCA\Mitgliederverwaltung\Controller\FileController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\ImportController;
|
use OCA\Mitgliederverwaltung\Controller\ImportController;
|
||||||
|
use OCA\Mitgliederverwaltung\Controller\MemberController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\MilestoneController;
|
use OCA\Mitgliederverwaltung\Controller\MilestoneController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\PageController;
|
use OCA\Mitgliederverwaltung\Controller\PageController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\PermissionController;
|
use OCA\Mitgliederverwaltung\Controller\PermissionController;
|
||||||
@@ -43,6 +44,7 @@ class AuthorizationMiddleware extends Middleware {
|
|||||||
private const ADMIN_METHODS_EXPORT = ['bundleSensitive'];
|
private const ADMIN_METHODS_EXPORT = ['bundleSensitive'];
|
||||||
private const ADMIN_METHODS_MILESTONE = ['updateSettings'];
|
private const ADMIN_METHODS_MILESTONE = ['updateSettings'];
|
||||||
private const ADMIN_METHODS_FILE = ['updateSettings'];
|
private const ADMIN_METHODS_FILE = ['updateSettings'];
|
||||||
|
private const ADMIN_METHODS_MEMBER = ['revealAllergies', 'archive'];
|
||||||
|
|
||||||
/** Methods that are read-only regardless of HTTP verb */
|
/** Methods that are read-only regardless of HTTP verb */
|
||||||
private const READ_METHODS = [
|
private const READ_METHODS = [
|
||||||
@@ -145,6 +147,9 @@ class AuthorizationMiddleware extends Middleware {
|
|||||||
if ($controller instanceof FileController) {
|
if ($controller instanceof FileController) {
|
||||||
return self::ADMIN_METHODS_FILE;
|
return self::ADMIN_METHODS_FILE;
|
||||||
}
|
}
|
||||||
|
if ($controller instanceof MemberController) {
|
||||||
|
return self::ADMIN_METHODS_MEMBER;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class MemberService {
|
|||||||
private StufeHistoryMapper $stufeHistoryMapper;
|
private StufeHistoryMapper $stufeHistoryMapper;
|
||||||
private AuditService $auditService;
|
private AuditService $auditService;
|
||||||
private LoggerInterface $logger;
|
private LoggerInterface $logger;
|
||||||
|
private ?EncryptionService $encryptionService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
MemberMapper $memberMapper,
|
MemberMapper $memberMapper,
|
||||||
@@ -45,7 +46,8 @@ class MemberService {
|
|||||||
FamilyMapper $familyMapper,
|
FamilyMapper $familyMapper,
|
||||||
StufeHistoryMapper $stufeHistoryMapper,
|
StufeHistoryMapper $stufeHistoryMapper,
|
||||||
AuditService $auditService,
|
AuditService $auditService,
|
||||||
LoggerInterface $logger
|
LoggerInterface $logger,
|
||||||
|
?EncryptionService $encryptionService = null
|
||||||
) {
|
) {
|
||||||
$this->memberMapper = $memberMapper;
|
$this->memberMapper = $memberMapper;
|
||||||
$this->addressMapper = $addressMapper;
|
$this->addressMapper = $addressMapper;
|
||||||
@@ -55,6 +57,50 @@ class MemberService {
|
|||||||
$this->stufeHistoryMapper = $stufeHistoryMapper;
|
$this->stufeHistoryMapper = $stufeHistoryMapper;
|
||||||
$this->auditService = $auditService;
|
$this->auditService = $auditService;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
$this->encryptionService = $encryptionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal decrypted allergien for every non-deleted member.
|
||||||
|
*
|
||||||
|
* Caller must be authorized (admin-only enforced by the middleware).
|
||||||
|
* The caller is audited — field + count, never the content.
|
||||||
|
*
|
||||||
|
* @param string $userId ID of the user triggering the reveal (for audit)
|
||||||
|
* @return array<int,string|null> Map of memberId → plaintext or null
|
||||||
|
* @throws Exception
|
||||||
|
* @throws \RuntimeException if EncryptionService is unavailable
|
||||||
|
*/
|
||||||
|
public function revealAllAllergies(string $userId): array {
|
||||||
|
if ($this->encryptionService === null) {
|
||||||
|
throw new \RuntimeException('EncryptionService ist nicht verfuegbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = $this->memberMapper->findAll();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
$counted = 0;
|
||||||
|
foreach ($members as $m) {
|
||||||
|
$ct = $m->getAllergienEncrypted();
|
||||||
|
if ($ct === null || $ct === '') {
|
||||||
|
$map[$m->getId()] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$plain = $this->encryptionService->decrypt($ct);
|
||||||
|
$map[$m->getId()] = $plain;
|
||||||
|
if ($plain !== null && $plain !== '') {
|
||||||
|
$counted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Allergien-Bulk-Reveal durch Admin', [
|
||||||
|
'app' => 'mitgliederverwaltung',
|
||||||
|
'user' => $userId,
|
||||||
|
'members_total' => count($members),
|
||||||
|
'members_with_allergien' => $counted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create ───────────────────────────────────────────────────────
|
// ── Create ───────────────────────────────────────────────────────
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ app.use(router)
|
|||||||
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
|
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
|
||||||
// not via webpack DefinePlugin globals.
|
// not via webpack DefinePlugin globals.
|
||||||
app.provide('appName', 'mitgliederverwaltung')
|
app.provide('appName', 'mitgliederverwaltung')
|
||||||
app.provide('appVersion', '0.2.10')
|
app.provide('appVersion', '0.2.11')
|
||||||
|
|
||||||
app.mount('#mitgliederverwaltung')
|
app.mount('#mitgliederverwaltung')
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,19 @@
|
|||||||
<ColumnPicker :columns="allColumns"
|
<ColumnPicker :columns="allColumns"
|
||||||
:model-value="visibleKeys"
|
:model-value="visibleKeys"
|
||||||
@update:model-value="setVisibleKeys" />
|
@update:model-value="setVisibleKeys" />
|
||||||
|
<NcButton :type="allergienVisible ? 'warning' : 'secondary'"
|
||||||
|
:disabled="allergienLoading"
|
||||||
|
:title="allergienVisible
|
||||||
|
? 'Allergien-Spalte ausblenden und entschlüsselte Daten verwerfen'
|
||||||
|
: 'Allergien-Spalte anzeigen (serverseitig entschlüsselt, auditiert)'"
|
||||||
|
@click="toggleAllergien">
|
||||||
|
<template #icon>
|
||||||
|
<NcLoadingIcon v-if="allergienLoading" :size="18" />
|
||||||
|
<EyeOff v-else-if="allergienVisible" :size="18" />
|
||||||
|
<Eye v-else :size="18" />
|
||||||
|
</template>
|
||||||
|
{{ allergienVisible ? 'Allergien ausblenden' : 'Allergien anzeigen' }}
|
||||||
|
</NcButton>
|
||||||
<NcButton type="primary"
|
<NcButton type="primary"
|
||||||
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
|
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -145,7 +158,7 @@
|
|||||||
<table v-else class="member-list__table">
|
<table v-else class="member-list__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th v-for="col in visibleColumns" :key="col.key"
|
<th v-for="col in displayColumns" :key="col.key"
|
||||||
class="member-list__th"
|
class="member-list__th"
|
||||||
:class="{ 'member-list__th--sortable': col.sortable }"
|
:class="{ 'member-list__th--sortable': col.sortable }"
|
||||||
@click="col.sortable && toggleSort(col.sortField || col.key)">
|
@click="col.sortable && toggleSort(col.sortField || col.key)">
|
||||||
@@ -162,8 +175,9 @@
|
|||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="member-list__row"
|
class="member-list__row"
|
||||||
@click="$router.push({ name: 'member-detail', params: { id: member.id } })">
|
@click="$router.push({ name: 'member-detail', params: { id: member.id } })">
|
||||||
<td v-for="col in visibleColumns" :key="col.key"
|
<td v-for="col in displayColumns" :key="col.key"
|
||||||
class="member-list__td">
|
class="member-list__td"
|
||||||
|
:class="col.key === 'allergien' ? 'member-list__td--allergien' : ''">
|
||||||
<span v-if="col.key === 'status'"
|
<span v-if="col.key === 'status'"
|
||||||
:class="'member-list__status member-list__status--' + member.status">
|
:class="'member-list__status member-list__status--' + member.status">
|
||||||
{{ col.render(member) }}
|
{{ col.render(member) }}
|
||||||
@@ -186,8 +200,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||||
|
import Eye from 'vue-material-design-icons/Eye.vue'
|
||||||
|
import EyeOff from 'vue-material-design-icons/EyeOff.vue'
|
||||||
import { useMembersStore } from '../stores/members.js'
|
import { useMembersStore } from '../stores/members.js'
|
||||||
import { useStufenStore } from '../stores/stufen.js'
|
import { useStufenStore } from '../stores/stufen.js'
|
||||||
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
@@ -288,6 +306,72 @@ const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
|
|||||||
['name', 'stufe', 'status', 'geburtsdatum', 'alter', 'rolle'],
|
['name', 'stufe', 'status', 'geburtsdatum', 'alter', 'rolle'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Allergien reveal (admin-only, audited) ──
|
||||||
|
// State is kept local and NEVER persisted to localStorage or the Pinia
|
||||||
|
// store. Toggling off wipes the plaintext map so it cannot be recovered
|
||||||
|
// from the page.
|
||||||
|
const allergienVisible = ref(false)
|
||||||
|
const allergienLoading = ref(false)
|
||||||
|
const allergienMap = ref(null) // { [memberId]: plaintext | null } | null
|
||||||
|
|
||||||
|
const displayColumns = computed(() => {
|
||||||
|
if (!allergienVisible.value) return visibleColumns.value
|
||||||
|
return [
|
||||||
|
...visibleColumns.value,
|
||||||
|
{
|
||||||
|
key: 'allergien',
|
||||||
|
label: 'Allergien',
|
||||||
|
sortable: false,
|
||||||
|
render: (m) => {
|
||||||
|
if (allergienMap.value === null) return '\u2026'
|
||||||
|
const val = allergienMap.value[m.id]
|
||||||
|
if (val === null || val === undefined || val === '') return '\u2014'
|
||||||
|
return val
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function toggleAllergien() {
|
||||||
|
if (allergienVisible.value) {
|
||||||
|
discardAllergien()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!confirm('Allergien aller Mitglieder entschlüsseln und anzeigen?\n\nSensible medizinische Daten werden serverseitig entschlüsselt und einmalig angezeigt. Der Zugriff wird im Audit-Log protokolliert.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allergienLoading.value = true
|
||||||
|
try {
|
||||||
|
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/members/allergien/reveal')
|
||||||
|
const response = await axios.post(url, {}, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
})
|
||||||
|
allergienMap.value = response.data?.data || {}
|
||||||
|
allergienVisible.value = true
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.error || err.message || 'Entschlüsselung fehlgeschlagen'
|
||||||
|
alert('Allergien konnten nicht geladen werden: ' + msg)
|
||||||
|
} finally {
|
||||||
|
allergienLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardAllergien() {
|
||||||
|
// Overwrite every stored value before dropping the ref so the
|
||||||
|
// underlying strings are no longer reachable via Vue's reactive proxy.
|
||||||
|
if (allergienMap.value) {
|
||||||
|
for (const k of Object.keys(allergienMap.value)) {
|
||||||
|
allergienMap.value[k] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allergienMap.value = null
|
||||||
|
allergienVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
discardAllergien()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Filters ──
|
// ── Filters ──
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
@@ -419,6 +503,7 @@ function reload() {
|
|||||||
.member-list__row { cursor: pointer; transition: background-color 0.15s ease; }
|
.member-list__row { cursor: pointer; transition: background-color 0.15s ease; }
|
||||||
.member-list__row:hover { background-color: var(--color-background-hover); }
|
.member-list__row:hover { background-color: var(--color-background-hover); }
|
||||||
.member-list__td { padding: 10px 16px; border-bottom: 1px solid var(--color-border); }
|
.member-list__td { padding: 10px 16px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.member-list__td--allergien { color: var(--color-warning-text, #b86e00); font-weight: 500; max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.member-list__status { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
|
.member-list__status { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
|
||||||
.member-list__status--aktiv { background-color: var(--color-success); color: white; }
|
.member-list__status--aktiv { background-color: var(--color-success); color: white; }
|
||||||
.member-list__status--inaktiv { background-color: var(--color-warning); color: white; }
|
.member-list__status--inaktiv { background-color: var(--color-warning); color: white; }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use OCA\Mitgliederverwaltung\Db\PhoneMapper;
|
|||||||
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
|
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
|
||||||
use OCA\Mitgliederverwaltung\Service\AuditService;
|
use OCA\Mitgliederverwaltung\Service\AuditService;
|
||||||
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
|
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
|
||||||
|
use OCA\Mitgliederverwaltung\Service\EncryptionService;
|
||||||
use OCA\Mitgliederverwaltung\Service\MemberService;
|
use OCA\Mitgliederverwaltung\Service\MemberService;
|
||||||
use OCA\Mitgliederverwaltung\Service\ValidationException;
|
use OCA\Mitgliederverwaltung\Service\ValidationException;
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
@@ -681,6 +682,82 @@ class MemberServiceTest extends TestCase {
|
|||||||
$this->service->update(1, ['vorname' => 'Moritz']);
|
$this->service->update(1, ['vorname' => 'Moritz']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── revealAllAllergies ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testRevealAllAllergiesDecryptsEveryMember(): void {
|
||||||
|
$m1 = $this->createMember(1, 'A', 'Aa');
|
||||||
|
$m1->setAllergienEncrypted('CT1');
|
||||||
|
$m2 = $this->createMember(2, 'B', 'Bb');
|
||||||
|
$m2->setAllergienEncrypted(null);
|
||||||
|
$m3 = $this->createMember(3, 'C', 'Cc');
|
||||||
|
$m3->setAllergienEncrypted('CT3');
|
||||||
|
|
||||||
|
$this->memberMapper->method('findAll')->willReturn([$m1, $m2, $m3]);
|
||||||
|
|
||||||
|
$encryption = $this->createMock(EncryptionService::class);
|
||||||
|
$encryption->method('decrypt')->willReturnMap([
|
||||||
|
['CT1', 'Nussallergie'],
|
||||||
|
['CT3', 'Laktose'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new MemberService(
|
||||||
|
$this->memberMapper,
|
||||||
|
$this->addressMapper,
|
||||||
|
$this->phoneMapper,
|
||||||
|
$this->emailMapper,
|
||||||
|
$this->familyMapper,
|
||||||
|
$this->stufeHistoryMapper,
|
||||||
|
$this->auditService,
|
||||||
|
$this->logger,
|
||||||
|
$encryption
|
||||||
|
);
|
||||||
|
|
||||||
|
$map = $service->revealAllAllergies('alice');
|
||||||
|
|
||||||
|
$this->assertSame('Nussallergie', $map[1]);
|
||||||
|
$this->assertNull($map[2]);
|
||||||
|
$this->assertSame('Laktose', $map[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevealAllAllergiesLogsCountsNotValues(): void {
|
||||||
|
$m1 = $this->createMember(1);
|
||||||
|
$m1->setAllergienEncrypted('CT');
|
||||||
|
|
||||||
|
$this->memberMapper->method('findAll')->willReturn([$m1]);
|
||||||
|
|
||||||
|
$encryption = $this->createMock(EncryptionService::class);
|
||||||
|
$encryption->method('decrypt')->willReturn('Nuss');
|
||||||
|
|
||||||
|
$this->logger->expects($this->once())
|
||||||
|
->method('info')
|
||||||
|
->with(
|
||||||
|
$this->stringContains('Bulk-Reveal'),
|
||||||
|
$this->callback(function ($ctx) {
|
||||||
|
$flat = json_encode($ctx);
|
||||||
|
return !str_contains($flat, 'Nuss');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$service = new MemberService(
|
||||||
|
$this->memberMapper,
|
||||||
|
$this->addressMapper,
|
||||||
|
$this->phoneMapper,
|
||||||
|
$this->emailMapper,
|
||||||
|
$this->familyMapper,
|
||||||
|
$this->stufeHistoryMapper,
|
||||||
|
$this->auditService,
|
||||||
|
$this->logger,
|
||||||
|
$encryption
|
||||||
|
);
|
||||||
|
|
||||||
|
$service->revealAllAllergies('alice');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevealAllAllergiesThrowsWithoutEncryptionService(): void {
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service->revealAllAllergies('alice');
|
||||||
|
}
|
||||||
|
|
||||||
public function testUpdateImportedMemberSucceeds(): void {
|
public function testUpdateImportedMemberSucceeds(): void {
|
||||||
// Simulate a member created by import (has all fields set via import path)
|
// Simulate a member created by import (has all fields set via import path)
|
||||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01');
|
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01');
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
|||||||
new VueLoaderPlugin(),
|
new VueLoaderPlugin(),
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
appName: JSON.stringify('mitgliederverwaltung'),
|
appName: JSON.stringify('mitgliederverwaltung'),
|
||||||
appVersion: JSON.stringify('0.2.10'),
|
appVersion: JSON.stringify('0.2.11'),
|
||||||
}),
|
}),
|
||||||
new webpack.optimize.LimitChunkCountPlugin({
|
new webpack.optimize.LimitChunkCountPlugin({
|
||||||
maxChunks: 1,
|
maxChunks: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user