diff --git a/.agents/skills/work-in-nextcloud-app/SKILL.md b/.agents/skills/work-in-nextcloud-app/SKILL.md index 35e0ff6..4650c39 100644 --- a/.agents/skills/work-in-nextcloud-app/SKILL.md +++ b/.agents/skills/work-in-nextcloud-app/SKILL.md @@ -57,7 +57,9 @@ Stop immediately if production systems, Git history rewriting, new production de - Develop executable UI logic test-first; additionally cover layout, accessibility, and Nextcloud integration with suitable smoke or browser checks. - Treat a time-boxed exploratory spike or hard-to-isolate Nextcloud integration as an explicitly justified deviation. Discard spike code or characterize it before adoption; choose the truthful broader integration level when isolation would hide the real contract. - Use the local fast entries named by `AGENTS.md`, normally `php tests/run.php` and `node tests/run-js.mjs`; dependency-light PHP smokes run in isolated processes. Run LocalBase and every affected consumer contract/smoke suite after a LocalBase contract change. -- In local pre-production, app tests may use shared LocalBase test helpers through relative repository paths. Add heavier packaging/autoload structure or a larger test framework only when path handling, runners, assertions, mocks, or fixtures are materially duplicated or impair readability. +- PHP classes must not remain coupled through distributed relative `require` or `require_once` chains. Production classes under `lib/` use Nextcloud's PSR-4 app autoloader; a bundled category-A dependency uses exactly one reproducibly generated, app-local, namespace-isolated Composer autoloader. Never use a shared workspace autoloader, shared cross-app `vendor/`, or neighboring production repository path as a delivery mechanism. Category-B services remain separate runtime apps and are consumed only through their public activation- and version-aware contracts. +- Every app uses one central app-local test bootstrap/autoloader for dependency-light PHP tests. It may resolve a test-only LocalBase helper through exactly one central transition point until a versioned development dependency exists, but individual tests must not retain direct relative LocalBase class paths after migration. Nextcloud integration tests may load the documented Nextcloud test bootstrap; template partials, explicit process/test entrypoints, and the one-time bootstrap of a bundled Composer autoloader remain justified includes rather than class-load chains. +- When a writing task first touches executable PHP or PHP tests in an app that has not yet migrated, include that app's complete autoload migration as a separate preparatory step in the same app scope. An app is complete only when distributed manual class requires and production fallback requires are gone, allowed includes are limited and reviewable, relevant app and provider/consumer PHP tests are green, and release checks contain neither development dependencies nor foreign repository paths. Documentation-only, formatting-only, and JavaScript-only work does not trigger an artificial PHP migration. - Known overall and app coverage must not decline unnoticed. Aim for at least 85 percent line coverage for new or materially changed executable code, report PHP and JavaScript separately, and fully cover security invariants regardless of percentages. Coverage is a warning and delivery indicator, not a substitute for meaningful assertions. - Do not prepare a commit or release with red relevant fast tests, contract tests, security checks, coverage gates, or delivery gates. @@ -82,6 +84,7 @@ Stop immediately if production systems, Git history rewriting, new production de ## Git and completion report +- When Simon asks for the next open steps, priorities, remaining work, or a similar outlook, include every applicable pending migration and unresolved decision from accepted ADRs and documented rollout plans. Report its current status, trigger, and required approval gate, and distinguish work executable now from work triggered by a later app change and work that is currently undecidable. Mentioning an item does not expand the current write scope or authorize a gated change. - Do not commit, push, release, deploy, or use `git add .` without Simon's explicit authorization. Stage individual files only when staging was requested. - Before a commit, show `git status --short`, `git diff --stat`, and `git diff --name-only`. Never use `git reset --hard`, `git clean`, force-push, history rewrite, or versioned backup copies. - Run relevant local tests, `git diff --check`, and the repository's own structure/fast check. For an explicitly authorized cross-app contract change, validate every provider and consumer repository from its own root and use the Parent workspace check only as an additional coordinator. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfa537e..effad22 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -106,3 +106,12 @@ jobs: --check-coverage \ --lines=95.71 \ node tests/run-js.mjs + + deploy-staging: + name: Staging-Deployment + if: github.event_name == 'push' + needs: [php, javascript] + uses: Filzmann/br-nextcloud-apps/.github/workflows/deploy-staging.yml@main + with: + app-id: orgsuite + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index bd48c76..7758054 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ Die priorisierte Produktplanung und offene Entscheidungen stehen in `ROADMAP.md` OrgSuite stellt genau zwei Haupteinstiege im Nextcloud-Appmenue bereit: -- `AD` fuer AD Kalender, Assistenzplanung, AD Urlaub und AD Raumplaner. +- `AD` fuer AD Kalender, Assistenzplanung, AD Urlaub, AD Raumplaner und AD + Recruitment. - `BR` fuer BRTop, BR-Stunden und Berechtigungsmatrix. Die Fachapps bleiben eigenständige Repositories, Datenmodelle und Berechtigungsräume. OrgSuite besitzt keine Fachdaten und erweitert keine fachlichen Rechte. Zielapps erzwingen ihre Berechtigungen weiterhin serverseitig. OrgSuite stellt ab zwei AD-Fachprodukten ausschließlich Navigation, gemeinsame Assets und den Nextcloud-Adminadapter für in LocalBase persistierte Organisations- und Freigabeverträge bereit. Einstellungen, die nur eine Fachapp betreffen, erhalten einen eigenen Adminabschnitt in dieser Fachapp. @@ -27,7 +28,7 @@ Die Fachapps bleiben eigenständige Repositories, Datenmodelle und Berechtigungs ## Navigationsvertrag - Die Haupteinstiege werden dynamisch registriert und nur angezeigt, wenn mindestens eine Zielapp fuer die angemeldete Person aktiviert ist. -- `AD` leitet bevorzugt zum AD Kalender weiter, `BR` bevorzugt zu BRTop. Ist das bevorzugte Ziel nicht aktiviert, wird die erste aktivierte Fachapp der Suite verwendet. +- `AD` leitet bevorzugt zum AD Kalender weiter, `BR` bevorzugt zu BRTop. Ist das bevorzugte Ziel nicht aktiviert, wird die erste aktivierte Fachapp der Suite verwendet. AD-Ziele und ihre Reihenfolge stammen aus dem versionierten LocalBase-Produktkatalog. - OrgSuite lädt `js/suite-navigation.js` und `css/suite-navigation.css` zentral über `BeforeTemplateRenderedEvent`. Fachapps stellen nur einen wirkungslosen Host mit `data-orgsuite`, `data-suite` und `data-current-app` bereit und besitzen dadurch keine harte Asset-Abhängigkeit. - Die Menuestruktur wird ausschliesslich hier gepflegt. Fachapps duplizieren keine Linklisten oder Menuelogik. - Ein sichtbarer Link ist keine Berechtigung. Jeder Zielcontroller und jede API prueft Zugriffe selbst. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5a14e..22e044d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0-rc.1 + +- AD-Navigation, Weiterleitungsreihenfolge und Menüdaten aus dem LocalBase-Produktkatalog abgeleitet. +- AD Recruitment als kontobezogen aktiviertes Suite-Ziel ergänzt. +- Sichtbare Produktlabels weiterhin im Übersetzungsbereich der jeweiligen App aufgelöst. + ## 0.3.0-rc.1 - OrgSuite als ab zwei AD-Produkten aktivierte Infrastruktur entkoppelt. diff --git a/README.md b/README.md index 90c1877..6d62d64 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,9 @@ Nach der Aktivierung werden Organisationsdefinition und Freigaben im Nextcloud-A Geplante Erweiterungen und offene Produktentscheidungen stehen in der [Roadmap](ROADMAP.md). +Für die manuelle Staging-Prüfung von Haupteinstiegen, Quermenüs und +Adminadapter steht ein ausfüllbares +[Abnahmeformular](docs/manual-acceptance.md) bereit. Es prüft ausdrücklich, +dass OrgSuite keine Fachrechte erteilt und keine Fachdaten hält. + Installations-, Betriebs- und Abnahmeunterlagen stehen im öffentlichen [AD-Suite-Projekt](https://github.com/Filzmann/ad-suite). diff --git a/ROADMAP.md b/ROADMAP.md index 79859bd..efd542d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,26 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Verbindliche Fach-, Sicherheits- und Architekturregeln stehen in `AGENTS.md`. +## Zukunftsplanung – nicht freigegeben + +### ORGS-L10N – OrgSuite vollständig lokalisieren + +Status: später, nicht freigegeben; Pilot-App, Reihenfolge und Rohtext-Gate +werden vor jeder Umsetzung appübergreifend separat freigegeben + +- Navigation, Adminadapter, Status- und Fehlermeldungen vertikal auf + Nextcloud-l10n umstellen. +- Produkt-IDs, Routen, Menü-Suite-Schlüssel und Capability-Verträge + sprachneutral lassen. +- Deutsche Ausgabe, eine weitere Locale, Fallback, Platzhalter, + Pluralformen, Escaping und JavaScript/PHP-Übergabe testen. +- Erst nach vollständiger Migration einen Rohtext-Check für OrgSuite + verbindlich schalten. + ## Aktueller Fokus +- Die manuellen Prüfungen werden im ausfüllbaren + [`docs/manual-acceptance.md`](docs/manual-acceptance.md) dokumentiert. - Gemeinsame AD-/BR-Navigation und den administrativen Einstieg für Organisations- und Freigabeverträge auf einem realitätsnahen Staging abnehmen. - Dabei auch die globale, rein visuelle Links-rechts-Anordnung der LocalBase-Organigrammkarten prüfen; die fachliche Gruppenreihenfolge bleibt davon getrennt. - Standalone- und Mehrproduktzustände einschließlich deaktivierter Zielapps zuverlässig prüfen. diff --git a/appinfo/info.xml b/appinfo/info.xml index 8fc1c5f..40c5c4f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,14 +5,14 @@ AD- und BR-Suite Gemeinsame Navigation und Administration für die AD- und BR-Fachapps. Bündelt die Einstiege der eigenständigen AD- und BR-Apps und stellt den administrativen Einstieg für organisationsweite Suite-Einstellungen bereit. - 0.3.0-rc.1 + 0.4.0-rc.1 agpl Simon + OrgSuite + organization https://github.com/Filzmann/ad-suite https://github.com/Filzmann/nextcloud-orgsuite/issues https://github.com/Filzmann/nextcloud-orgsuite - OrgSuite - organization diff --git a/docs/manual-acceptance.md b/docs/manual-acceptance.md new file mode 100644 index 0000000..2066c45 --- /dev/null +++ b/docs/manual-acceptance.md @@ -0,0 +1,86 @@ +# Manuelles Abnahmeformular – OrgSuite + +Dieses Formular dokumentiert die fachliche, visuelle und sicherheitsbezogene +Abnahme der gemeinsamen AD-/BR-Navigation und des administrativen +OrgSuite-Einstiegs auf einem realitätsnahen Staging-System. OrgSuite enthält +keine Fachdaten und erteilt keine Rechte in Zielapps. + +Pro Prüffall wird genau ein Ergebnis markiert. Keine Passwörter, Tokens, +personenbezogenen Echtdaten, vollständigen Mitgliederlisten oder internen +Kennungen eintragen. Ausschließlich neutrale Testkonten und synthetische +Organisationsdaten verwenden. + +## Kopfdaten + +| Feld | Eintrag | +|---|---| +| Datum und Uhrzeit | | +| Prüfer*in | | +| Umgebung und URL | | +| OrgSuite-Version | | +| LocalBase-Version | | +| Nextcloud-Version | | +| Browser und Version | | +| Fenstergröße / Zoom | | +| Neutrale Testkonten und Zielapp-Rechte | | +| Aktivierte AD- und BR-Apps | | + +Ergebniskennzeichnung: `[ ] erfolgreich` / `[ ] nicht erfolgreich` / +`[ ] nicht geprüft`. Bei „nicht erfolgreich“ oder „nicht geprüft“ ist eine +Begründung verpflichtend. + +## A. Haupteinstiege und Weiterleitung + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| A1 | AD-Einstieg | Mindestens eine aktuelle AD-Zielapp aktivieren und den Nextcloud-Appbereich mit einem berechtigten Testkonto öffnen. | Genau ein Haupteinstieg `AD` erscheint und führt zu einer aktivierten, für das Konto nutzbaren Zielapp. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A2 | BR-Einstieg | Mindestens eine BR-Zielapp aktivieren und denselben Weg prüfen. | Genau ein Haupteinstieg `BR` erscheint und führt zu einer aktivierten, nutzbaren BR-App. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A3 | Bevorzugtes AD-Ziel | AD Kalender zusammen mit einer weiteren AD-App aktivieren und `AD` öffnen. | AD Kalender wird als bevorzugtes Ziel verwendet. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A4 | AD-Fallback | Das bevorzugte AD-Ziel deaktivieren und den Einstieg erneut öffnen. | Die erste noch aktivierte Zielapp wird verwendet; es entsteht keine Schleife oder Fehlerseite. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A5 | Bevorzugtes BR-Ziel und Fallback | BRTop mit weiterer BR-App prüfen, danach BRTop deaktivieren und wiederholen. | Zuerst wird BRTop verwendet, danach eine aktive BR-Fallback-App. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A6 | Keine aktive Zielapp | Für eine Suite alle Zielapps deaktivieren und Navigation sowie direkte Suite-Route prüfen. | Der betreffende Haupteinstieg wird nicht angeboten; die Route leitet nicht auf eine deaktivierte App. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| A7 | Einzelproduktzustand | Eine AD-Installation mit genau einem Fachprodukt gemäß Installervertrag prüfen. | OrgSuite bleibt deaktiviert; das Fachprodukt besitzt seinen eigenen Standalone-Einstieg. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## B. Quermenü in Fachapps + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| B1 | Zentrale Menüliste | In mehreren AD- und BR-Fachapps die angebotenen Quermenüs vergleichen. | Links und Reihenfolge stammen erkennbar aus OrgSuite; Fachapps zeigen keine abweichenden duplizierten Linklisten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B2 | Aktuelle App | Jede aktivierte Zielapp nacheinander öffnen. | Genau der aktuelle Link trägt `aria-current="page"` und eine verständliche sichtbare Markierung. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B3 | Deaktivierte Zielapp | Eine Zielapp deaktivieren und die verbleibenden Fachapps neu laden. | Der nicht nutzbare Link verschwindet beziehungsweise wird nicht als aktives Ziel angeboten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B4 | Sticky und Scrollvertrag | Eine lange Fachansicht vertikal scrollen und zusätzlich eine breite Tabelle horizontal bewegen. | Das Quermenü bleibt innerhalb des App-Scrollcontainers oben sichtbar und erzeugt keinen globalen zweiten Scrollbereich. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B5 | Schmales Fenster | Menü bei kleinem Viewport und vergrößertem Browserzoom prüfen. | Das Menü darf kompakt umbrechen; Links bleiben lesbar und bedienbar, ohne Fachinhalte zu überdecken. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| B6 | Tastatur und Fokus | Alle Menüpunkte nur mit Tastatur durchlaufen und aktivieren. | Fokus ist sichtbar, Reihenfolge ist nachvollziehbar und jedes Ziel lässt sich ohne Zeiger bedienen. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## C. Fachrechte bleiben in den Zielapps + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| C1 | Sichtbarer Link ohne Fachrecht | Mit einem Testkonto einen sichtbaren Zielapp-Link aktivieren, für dessen Fachfunktion das Konto keine Berechtigung besitzt. | Die Zielapp verweigert den Zugriff serverseitig; OrgSuite erweitert das Fachrecht nicht. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C2 | Direkter Zielaufruf | Dieselbe nicht erlaubte Zielroute und einen Ziel-API-Endpunkt direkt aufrufen. | Beide Aufrufe werden von der Zielapp abgewiesen; Navigation ist keine Zugriffskontrolle. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C3 | OrgSuite ohne Fachdaten | OrgSuite-Routen, Oberfläche und administrativen Bereich auf Sitzungs-, Urlaubs-, Bewerbungs- oder andere Fachdaten prüfen. | OrgSuite hält und zeigt keine Fachdaten der Zielapps. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| C4 | Fehler einer Zielapp | Eine Zielapp in einer isolierten Testumgebung gezielt nicht erreichbar machen und eine andere Zielapp öffnen. | Die unabhängige Zielapp bleibt erreichbar; der Fehler erteilt keine Rechte und verändert keine Fachdaten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## D. Administrativer Einstieg + +| ID | Was wird geprüft? | Auszuführende Schritte | Erwartetes Ergebnis | Ergebnis | Warum/Beleg/Abweichung | +|---|---|---|---|---|---| +| D1 | Adminadapter | Als Nextcloud-Admin mit mehreren AD-Produkten den OrgSuite-Adminabschnitt öffnen. | Die von LocalBase bereitgestellte Organisations- und Freigabeoberfläche erscheint einmal im OrgSuite-Kontext. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D2 | Nichtadmin-Deny | Als Nichtadmin Adminabschnitt und direkte administrative Lese- sowie Schreibaufrufe versuchen. | Der Zugriff wird serverseitig verweigert; keine LocalBase-Konfiguration ändert sich. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D3 | CSRF-Schutz | Einen schreibenden Adminaufruf mit Sitzung, aber ohne gültiges Requesttoken wiederholen. | Der Request wird abgewiesen und die bestehende Konfiguration bleibt unverändert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D4 | App-spezifische Einstellungen | Adminbereich auf Kalenderprovider-, Raum- oder andere nur eine Fachapp betreffende Einstellungen prüfen. | App-spezifische Administration bleibt im eigenen Fachapp-Abschnitt und wird nicht in OrgSuite dupliziert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D5 | Visuelle Diagrammordnung | Organigrammkarten in LocalBase über den OrgSuite-Adminadapter horizontal umordnen und danach fachliche Rollenreihenfolge sowie Rechte prüfen. | Nur die Darstellung ändert sich; fachliche Reihenfolge, Kalender und Berechtigungen bleiben unverändert. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | +| D6 | Datensparsame Abnahme | Formular und Screenshots prüfen. | Es wurden ausschließlich synthetische Organisationsdaten dokumentiert; keine Secrets oder realen Mitgliederlisten sind enthalten. | [ ] erfolgreich [ ] nicht erfolgreich [ ] nicht geprüft | | + +## Abschlussentscheidung + +| Feld | Eintrag | +|---|---| +| Anzahl erfolgreich | | +| Anzahl nicht erfolgreich | | +| Anzahl nicht geprüft | | +| Kritische Abweichungen / Ticketreferenzen | | +| Erneute Prüfung erforderlich bis | | +| Gesamtentscheidung | [ ] abgenommen [ ] mit Auflagen abgenommen [ ] nicht abgenommen | +| Begründung der Gesamtentscheidung | | +| Name / Datum | | diff --git a/js/suite-navigation.js b/js/suite-navigation.js index 12c779e..a2b1086 100644 --- a/js/suite-navigation.js +++ b/js/suite-navigation.js @@ -1,25 +1,20 @@ (function () { 'use strict'; - const suites = Object.freeze({ - ad: Object.freeze({ - label: 'AD-Anwendungen', - items: Object.freeze([ - Object.freeze({app: 'adcalendar', label: 'Kalender'}), - Object.freeze({app: 'adplaner', label: 'Assistenzplanung'}), - Object.freeze({app: 'adurlaub', label: 'Urlaub'}), - Object.freeze({app: 'adroom', label: 'Räume'}), - ]), - }), - br: Object.freeze({ - label: 'BR-Anwendungen', - items: Object.freeze([ - Object.freeze({app: 'brtop', label: 'Sitzungen'}), - Object.freeze({app: 'brstunden', label: 'Stunden'}), - Object.freeze({app: 'br_permission_matrix', label: 'Berechtigungsmatrix'}), - ]), - }), - }); + function loadInitialState(app, key, fallback) { + const element = document.querySelector(`#initial-state-${app}-${key}`); + if (!element) { + return fallback; + } + try { + return JSON.parse(window.atob(element.value)); + } catch (_error) { + return fallback; + } + } + + const suites = Object.freeze(loadInitialState('orgsuite', 'suite-navigation', {})); + const translate = typeof window.t === 'function' ? window.t : (_app, text) => text; function appUrl(appId) { return window.OC.generateUrl('/apps/{appId}/', {appId}); @@ -33,7 +28,7 @@ const nav = document.createElement('nav'); nav.className = 'orgsuite-nav'; - nav.setAttribute('aria-label', definition.label); + nav.setAttribute('aria-label', translate('orgsuite', definition.label)); const list = document.createElement('ul'); list.className = 'orgsuite-nav__list'; @@ -42,8 +37,8 @@ const listItem = document.createElement('li'); const link = document.createElement('a'); link.className = 'orgsuite-nav__link'; - link.href = appUrl(item.app); - link.textContent = item.label; + link.href = item.href || appUrl(item.app); + link.textContent = translate(item.app, item.label); if (host.dataset.currentApp === item.app) { link.classList.add('is-current'); link.setAttribute('aria-current', 'page'); diff --git a/lib/Controller/EntryController.php b/lib/Controller/EntryController.php index d915fed..f2b84f6 100644 --- a/lib/Controller/EntryController.php +++ b/lib/Controller/EntryController.php @@ -4,6 +4,7 @@ namespace OCA\OrgSuite\Controller; +use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\OrgSuite\AppInfo\Application; use OCP\App\IAppManager; use OCP\AppFramework\Controller; @@ -14,6 +15,7 @@ use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; +use RuntimeException; /** * Zweck: Leitet einen Suite-Einstieg auf die erste fuer die aktuelle Person aktivierte Fachapp weiter. @@ -21,25 +23,18 @@ */ final class EntryController extends Controller { /** @var array> */ - private const TARGETS = [ - 'ad' => [ - ['app' => 'adcalendar', 'route' => 'adcalendar.page.index'], - ['app' => 'adplaner', 'route' => 'adplaner.page.index'], - ['app' => 'adurlaub', 'route' => 'adurlaub.page.index'], - ['app' => 'adroom', 'route' => 'adroom.page.index'], - ], - 'br' => [ - ['app' => 'brtop', 'route' => 'brtop.page.index'], - ['app' => 'brstunden', 'route' => 'brstunden.page.index'], - ['app' => 'br_permission_matrix', 'route' => 'br_permission_matrix.page.index'], - ], + private const BR_TARGETS = [ + ['app' => 'brtop', 'route' => 'brtop.page.index'], + ['app' => 'brstunden', 'route' => 'brstunden.page.index'], + ['app' => 'br_permission_matrix', 'route' => 'br_permission_matrix.page.index'], ]; public function __construct( IRequest $request, private IAppManager $appManager, private IUserSession $userSession, - private IURLGenerator $url + private IURLGenerator $url, + private AdProductCatalog $catalog, ) { parent::__construct(Application::APP_ID, $request); } @@ -62,7 +57,7 @@ private function redirectToSuite(string $suite): RedirectResponse|NotFoundRespon return new NotFoundResponse(); } - foreach (self::TARGETS[$suite] ?? [] as $target) { + foreach ($this->targets($suite) as $target) { if ($this->appManager->isEnabledForUser($target['app'], $user)) { return new RedirectResponse($this->url->linkToRoute($target['route'])); } @@ -70,4 +65,23 @@ private function redirectToSuite(string $suite): RedirectResponse|NotFoundRespon return new NotFoundResponse(); } + + /** @return list */ + private function targets(string $suite): array { + if ($suite === 'br') { + return self::BR_TARGETS; + } + if ($suite !== 'ad') { + return []; + } + + try { + return array_map( + static fn(array $product): array => ['app' => $product['id'], 'route' => $product['route']], + $this->catalog->menuProducts('ad'), + ); + } catch (RuntimeException) { + return []; + } + } } diff --git a/lib/Listener/NavigationListener.php b/lib/Listener/NavigationListener.php index 91e22f7..6f9a532 100644 --- a/lib/Listener/NavigationListener.php +++ b/lib/Listener/NavigationListener.php @@ -4,6 +4,7 @@ namespace OCA\OrgSuite\Listener; +use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\OrgSuite\AppInfo\Application; use OCP\App\IAppManager; use OCP\EventDispatcher\Event; @@ -13,6 +14,7 @@ use OCP\IUser; use OCP\IUserSession; use OCP\Navigation\Events\LoadAdditionalEntriesEvent; +use RuntimeException; /** * Zweck: Zeigt pro Fachbereich genau einen App-Einstieg und vermeidet Links auf vollstaendig deaktivierte Suiten. @@ -21,16 +23,14 @@ */ final class NavigationListener implements IEventListener { /** @var array> */ - private const TARGETS = [ - 'ad' => ['adcalendar', 'adplaner', 'adurlaub', 'adroom'], - 'br' => ['brtop', 'brstunden', 'br_permission_matrix'], - ]; + private const BR_TARGETS = ['brtop', 'brstunden', 'br_permission_matrix']; public function __construct( private IUserSession $userSession, private IAppManager $appManager, private INavigationManager $navigation, - private IURLGenerator $url + private IURLGenerator $url, + private AdProductCatalog $catalog, ) { } @@ -65,7 +65,7 @@ private function addEntryWhenAvailable(string $suite, string $name, int $order, } private function hasEnabledTarget(string $suite, IUser $user): bool { - foreach (self::TARGETS[$suite] ?? [] as $appId) { + foreach ($this->targetIds($suite) as $appId) { if ($this->appManager->isEnabledForUser($appId, $user)) { return true; } @@ -73,4 +73,20 @@ private function hasEnabledTarget(string $suite, IUser $user): bool { return false; } + + /** @return list */ + private function targetIds(string $suite): array { + if ($suite === 'br') { + return self::BR_TARGETS; + } + if ($suite !== 'ad') { + return []; + } + + try { + return array_column($this->catalog->menuProducts('ad'), 'id'); + } catch (RuntimeException) { + return []; + } + } } diff --git a/lib/Listener/SuiteAssetsListener.php b/lib/Listener/SuiteAssetsListener.php index 931acec..033e3fa 100644 --- a/lib/Listener/SuiteAssetsListener.php +++ b/lib/Listener/SuiteAssetsListener.php @@ -4,20 +4,90 @@ namespace OCA\OrgSuite\Listener; +use OCA\LocalBase\Catalog\AdProductCatalog; +use OCP\App\IAppManager; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; use OCP\Util; +use RuntimeException; /** - * Zweck: Lädt das gemeinsame Quermenü zentral, sodass Fachapps keine harte Asset-Abhängigkeit zur OrgSuite besitzen. - * Vertrag: Ohne aktivierte OrgSuite wird der Listener nicht geladen; Fachapp-Templates bleiben eigenständig auslieferbar. + * Lädt das gemeinsame Quermenü und liefert ausschließlich aktivierte Ziele aus. * @template-implements IEventListener */ final class SuiteAssetsListener implements IEventListener { + private const BR_TARGETS = [ + ['app' => 'brtop', 'route' => 'brtop.page.index', 'label' => 'Sitzungen'], + ['app' => 'brstunden', 'route' => 'brstunden.page.index', 'label' => 'Stunden'], + ['app' => 'br_permission_matrix', 'route' => 'br_permission_matrix.page.index', 'label' => 'Berechtigungsmatrix'], + ]; + + public function __construct( + private AdProductCatalog $catalog, + private IAppManager $appManager, + private IUserSession $userSession, + private IURLGenerator $url, + private IInitialState $initialState, + ) { + } + public function handle(Event $event): void { - if (!$event instanceof BeforeTemplateRenderedEvent) return; + if (!$event instanceof BeforeTemplateRenderedEvent) { + return; + } + + $this->initialState->provideInitialState('suite-navigation', $this->navigation()); Util::addScript('orgsuite', 'suite-navigation'); Util::addStyle('orgsuite', 'suite-navigation'); } + + /** @return array}> */ + private function navigation(): array { + $user = $this->userSession->getUser(); + if ($user === null) { + return []; + } + + $adItems = []; + try { + foreach ($this->catalog->menuProducts('ad') as $product) { + if (!$this->appManager->isEnabledForUser($product['id'], $user)) { + continue; + } + $adItems[] = [ + 'app' => $product['id'], + 'label' => $product['navigationLabel'], + 'href' => $this->url->linkToRoute($product['route']), + ]; + } + } catch (RuntimeException) { + $adItems = []; + } + + return [ + 'ad' => ['label' => 'AD-Anwendungen', 'items' => $adItems], + 'br' => ['label' => 'BR-Anwendungen', 'items' => $this->enabledBrItems($user)], + ]; + } + + /** @return list */ + private function enabledBrItems(IUser $user): array { + $items = []; + foreach (self::BR_TARGETS as $target) { + if (!$this->appManager->isEnabledForUser($target['app'], $user)) { + continue; + } + $items[] = [ + 'app' => $target['app'], + 'label' => $target['label'], + 'href' => $this->url->linkToRoute($target['route']), + ]; + } + return $items; + } } diff --git a/tests/EntryControllerTest.php b/tests/EntryControllerTest.php index b5e4463..9dacb80 100644 --- a/tests/EntryControllerTest.php +++ b/tests/EntryControllerTest.php @@ -35,8 +35,10 @@ final class Application { public const APP_ID = 'orgsuite'; } } namespace { + require_once __DIR__ . '/../../localbase/lib/Catalog/AdProductCatalog.php'; require __DIR__ . '/../lib/Controller/EntryController.php'; + use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\OrgSuite\Controller\EntryController; use OCP\App\IAppManager; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -63,7 +65,8 @@ public function __construct(private array $enabledApps) {} public function isEnabledForUser($appId, $user = null): bool { return in_array($appId, $this->enabledApps, true); } }; - $controller = new EntryController($request, $apps, $session, $url); + $catalog = new AdProductCatalog(); + $controller = new EntryController($request, $apps, $session, $url, $catalog); $ad = $controller->ad(); if (!$ad instanceof RedirectResponse || $ad->redirectURL !== '/route/adplaner.page.index') { throw new RuntimeException('AD muss auf die erste aktivierte Fachapp weiterleiten.'); @@ -76,14 +79,32 @@ public function isEnabledForUser($appId, $user = null): bool { return in_array($ $noApps = new class implements IAppManager { public function isEnabledForUser($appId, $user = null): bool { return false; } }; - if (!(new EntryController($request, $noApps, $session, $url))->ad() instanceof NotFoundResponse) { + if (!(new EntryController($request, $noApps, $session, $url, $catalog))->ad() instanceof NotFoundResponse) { throw new RuntimeException('Eine vollstaendig deaktivierte Suite muss abgewiesen werden.'); } $loggedOut = new class implements IUserSession { public function getUser(): ?IUser { return null; } }; - if (!(new EntryController($request, $apps, $loggedOut, $url))->br() instanceof NotFoundResponse) { + if (!(new EntryController($request, $apps, $loggedOut, $url, $catalog))->br() instanceof NotFoundResponse) { throw new RuntimeException('Ein anonymer Suite-Einstieg muss abgewiesen werden.'); } + $recruitmentApps = new class implements IAppManager { + public function isEnabledForUser($appId, $user = null): bool { return $appId === 'adrecruitment'; } + }; + $recruitment = (new EntryController($request, $recruitmentApps, $session, $url, $catalog))->ad(); + if (!$recruitment instanceof RedirectResponse || $recruitment->redirectURL !== '/route/adrecruitment.page.index') { + throw new RuntimeException('AD Recruitment ist kein katalogisiertes Suite-Ziel.'); + } + + $missingCatalog = new AdProductCatalog(__DIR__ . '/missing-catalog.json'); + if (!(new EntryController($request, $recruitmentApps, $session, $url, $missingCatalog))->ad() instanceof NotFoundResponse) { + throw new RuntimeException('Fehlender Katalogprovider darf kein unsicheres Fallbackziel erzeugen.'); + } + + $targets = new ReflectionMethod(EntryController::class, 'targets'); + if ($targets->invoke($controller, 'unknown') !== []) { + throw new RuntimeException('Unbekannte Suite erhält unerwartete Weiterleitungsziele.'); + } + foreach (['ad', 'br'] as $methodName) { $method = new ReflectionMethod(EntryController::class, $methodName); if ($method->getAttributes(NoAdminRequired::class) === [] || $method->getAttributes(NoCSRFRequired::class) === []) { @@ -93,4 +114,3 @@ public function isEnabledForUser($appId, $user = null): bool { return false; } echo "OrgSuite entry controller tests passed\n"; } - diff --git a/tests/NavigationContractTest.php b/tests/NavigationContractTest.php index d482de6..bc0a917 100644 --- a/tests/NavigationContractTest.php +++ b/tests/NavigationContractTest.php @@ -31,8 +31,15 @@ throw new RuntimeException("Weiterleitungsvertrag fehlt: {$contract}"); } } -foreach (['adcalendar', 'adplaner', 'adurlaub', 'adroom'] as $appId) { - if (!str_contains($listener, "'{$appId}'") || !str_contains($controller, "'app' => '{$appId}'")) throw new RuntimeException("AD-Suite-Ziel fehlt: {$appId}"); +foreach (['AdProductCatalog', "menuProducts('ad')"] as $catalogContract) { + if (!str_contains($listener, $catalogContract) || !str_contains($controller, $catalogContract)) { + throw new RuntimeException("AD-Produktkatalog wird nicht durchgängig konsumiert: {$catalogContract}"); + } +} +foreach (['adcalendar', 'adplaner', 'adurlaub', 'adroom', 'adrecruitment'] as $appId) { + if (str_contains($listener, "'{$appId}'") || str_contains($controller, "'app' => '{$appId}'")) { + throw new RuntimeException("AD-Produkt bleibt außerhalb des Katalogs festgeschrieben: {$appId}"); + } } echo "OrgSuite navigation contract passed\n"; diff --git a/tests/NavigationListenerExecutionTest.php b/tests/NavigationListenerExecutionTest.php index f5db936..2569e42 100644 --- a/tests/NavigationListenerExecutionTest.php +++ b/tests/NavigationListenerExecutionTest.php @@ -14,8 +14,10 @@ interface INavigationManager { public const TYPE_APPS = 'link'; public function namespace OCA\OrgSuite\AppInfo { final class Application { public const APP_ID = 'orgsuite'; } } namespace { + require_once __DIR__ . '/../../localbase/lib/Catalog/AdProductCatalog.php'; require_once __DIR__ . '/../lib/Listener/NavigationListener.php'; + use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\OrgSuite\Listener\NavigationListener; use OCP\App\IAppManager; use OCP\EventDispatcher\Event; @@ -39,7 +41,8 @@ public function add(callable $entry): void { $this->entries[] = $entry; } public function linkToRoute(string $routeName, array $arguments = []): string { return '/route/' . $routeName; } public function imagePath(string $appName, string $file): string { return '/image/' . $appName . '/' . $file; } }; - $listener = new NavigationListener($session, $apps, $navigation, $url); + $catalog = new AdProductCatalog(); + $listener = new NavigationListener($session, $apps, $navigation, $url, $catalog); $listener->handle(new Event()); if ($navigation->entries !== []) throw new RuntimeException('Fremdes Event erzeugt Navigation.'); $listener->handle(new LoadAdditionalEntriesEvent()); @@ -49,11 +52,18 @@ public function imagePath(string $appName, string $file): string { return '/imag } $apps->enabled = []; $emptyNavigation = new class implements INavigationManager { public array $entries = []; public function add(callable $entry): void { $this->entries[] = $entry; } }; - (new NavigationListener($session, $apps, $emptyNavigation, $url))->handle(new LoadAdditionalEntriesEvent()); + (new NavigationListener($session, $apps, $emptyNavigation, $url, $catalog))->handle(new LoadAdditionalEntriesEvent()); if ($emptyNavigation->entries !== []) throw new RuntimeException('Leere Suite wird angezeigt.'); $loggedOut = new class implements IUserSession { public function getUser(): ?IUser { return null; } }; - (new NavigationListener($loggedOut, $apps, $emptyNavigation, $url))->handle(new LoadAdditionalEntriesEvent()); + (new NavigationListener($loggedOut, $apps, $emptyNavigation, $url, $catalog))->handle(new LoadAdditionalEntriesEvent()); if ($emptyNavigation->entries !== []) throw new RuntimeException('Anonyme Navigation wurde registriert.'); + $targetIds = new ReflectionMethod(NavigationListener::class, 'targetIds'); + if ($targetIds->invoke($listener, 'unknown') !== []) throw new RuntimeException('Unbekannte Suite besitzt Navigationsziele.'); + $missingCatalog = new AdProductCatalog(__DIR__ . '/missing-catalog.json'); + $apps->enabled = ['adplaner']; + (new NavigationListener($session, $apps, $emptyNavigation, $url, $missingCatalog))->handle(new LoadAdditionalEntriesEvent()); + if ($emptyNavigation->entries !== []) throw new RuntimeException('Fehlender Katalog erzeugt einen unsicheren AD-Einstieg.'); + echo "OrgSuite navigation listener execution tests passed\n"; } diff --git a/tests/SuiteAssetsListenerExecutionTest.php b/tests/SuiteAssetsListenerExecutionTest.php index 8a35657..041bf50 100644 --- a/tests/SuiteAssetsListenerExecutionTest.php +++ b/tests/SuiteAssetsListenerExecutionTest.php @@ -4,7 +4,12 @@ namespace OCP\EventDispatcher { class Event {} interface IEventListener { public function handle(Event $event): void; } } namespace OCP\AppFramework\Http\Events { class BeforeTemplateRenderedEvent extends \OCP\EventDispatcher\Event {} } +namespace OCP\AppFramework\Services { interface IInitialState { public function provideInitialState(string $key, $data): void; } } +namespace OCP\App { interface IAppManager { public function isEnabledForUser($appId, $user = null); } } namespace OCP { + interface IUser {} + interface IUserSession { public function getUser(): ?IUser; } + interface IURLGenerator { public function linkToRoute(string $routeName, array $arguments = []): string; } final class Util { public static array $scripts = []; public static array $styles = []; @@ -14,20 +19,65 @@ public static function addStyle(string $appId, string $style): void { self::$sty } namespace { + require_once __DIR__ . '/../../localbase/lib/Catalog/AdProductCatalog.php'; require_once __DIR__ . '/../lib/Listener/SuiteAssetsListener.php'; + use OCA\LocalBase\Catalog\AdProductCatalog; use OCA\OrgSuite\Listener\SuiteAssetsListener; + use OCP\App\IAppManager; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\EventDispatcher\Event; + use OCP\AppFramework\Services\IInitialState; + use OCP\IURLGenerator; + use OCP\IUser; + use OCP\IUserSession; use OCP\Util; - $listener = new SuiteAssetsListener(); + $user = new class implements IUser {}; + $session = new class($user) implements IUserSession { public function __construct(private ?IUser $user) {} public function getUser(): ?IUser { return $this->user; } }; + $apps = new class implements IAppManager { + public function isEnabledForUser($appId, $user = null): bool { return in_array($appId, ['adplaner', 'adrecruitment', 'brtop'], true); } + }; + $url = new class implements IURLGenerator { public function linkToRoute(string $routeName, array $arguments = []): string { return '/route/' . $routeName; } }; + $initialState = new class implements IInitialState { + public array $states = []; + public function provideInitialState(string $key, $data): void { $this->states[$key] = $data; } + }; + + $listener = new SuiteAssetsListener(new AdProductCatalog(), $apps, $session, $url, $initialState); $listener->handle(new Event()); - if (Util::$scripts !== [] || Util::$styles !== []) throw new RuntimeException('Fremdes Event lädt Suite-Assets.'); + if (Util::$scripts !== [] || Util::$styles !== [] || $initialState->states !== []) throw new RuntimeException('Fremdes Event lädt Suite-Assets.'); $listener->handle(new BeforeTemplateRenderedEvent()); if (Util::$scripts !== [['orgsuite', 'suite-navigation']] || Util::$styles !== [['orgsuite', 'suite-navigation']]) { throw new RuntimeException('Suite-Assets werden nicht zentral registriert.'); } + $navigation = $initialState->states['suite-navigation'] ?? []; + if (array_column($navigation['ad']['items'] ?? [], 'app') !== ['adplaner', 'adrecruitment']) { + throw new RuntimeException('AD-Menüdaten sind nicht katalogisiert oder nicht auf aktivierte Apps begrenzt.'); + } + if (($navigation['ad']['items'][1]['href'] ?? null) !== '/route/adrecruitment.page.index') { + throw new RuntimeException('Recruitment-Menüroute stammt nicht aus dem Katalog.'); + } + + $loggedOut = new class implements IUserSession { public function getUser(): ?IUser { return null; } }; + $anonymousState = new class implements IInitialState { + public array $states = []; + public function provideInitialState(string $key, $data): void { $this->states[$key] = $data; } + }; + (new SuiteAssetsListener(new AdProductCatalog(), $apps, $loggedOut, $url, $anonymousState))->handle(new BeforeTemplateRenderedEvent()); + if (($anonymousState->states['suite-navigation'] ?? null) !== []) { + throw new RuntimeException('Anonyme Sitzung erhält unerwartete Suite-Menüdaten.'); + } + + $missingState = new class implements IInitialState { + public array $states = []; + public function provideInitialState(string $key, $data): void { $this->states[$key] = $data; } + }; + $missingCatalog = new AdProductCatalog(__DIR__ . '/missing-catalog.json'); + (new SuiteAssetsListener($missingCatalog, $apps, $session, $url, $missingState))->handle(new BeforeTemplateRenderedEvent()); + if (($missingState->states['suite-navigation']['ad']['items'] ?? null) !== []) { + throw new RuntimeException('Fehlender Katalog erzeugt unsichere AD-Menüdaten.'); + } echo "OrgSuite assets listener execution tests passed\n"; } diff --git a/tests/js/suite-navigation-smoke.mjs b/tests/js/suite-navigation-smoke.mjs index f21c2cc..f0aa7c4 100644 --- a/tests/js/suite-navigation-smoke.mjs +++ b/tests/js/suite-navigation-smoke.mjs @@ -4,10 +4,10 @@ import vm from 'node:vm'; const source = readFileSync(new URL('../../js/suite-navigation.js', import.meta.url), 'utf8'); const css = readFileSync(new URL('../../css/suite-navigation.css', import.meta.url), 'utf8'); +const sourceFilename = fileURLToPath(new URL('../../js/suite-navigation.js', import.meta.url)); for (const contract of [ - "adcalendar", "adplaner", "adurlaub", "adroom", - "brtop", "brstunden", "br_permission_matrix", + "loadInitialState", "window.t", "aria-current", "document.createElement('nav')", "window.OC.generateUrl", "document.querySelectorAll('[data-orgsuite]')", ]) { @@ -42,21 +42,62 @@ const document = { readyState: 'complete', createElement: (tagName) => new FakeElement(tagName), querySelectorAll: (selector) => selector === '[data-orgsuite]' ? [host] : [], + querySelector: (selector) => selector === '#initial-state-orgsuite-suite-navigation' + ? {value: Buffer.from(JSON.stringify(navigation)).toString('base64')} + : null, addEventListener: () => {}, }; -const window = {OC: {generateUrl: (path, params) => path.replace('{appId}', params.appId)}}; -vm.runInNewContext(source, {document, window, Object}, {filename: fileURLToPath(new URL('../../js/suite-navigation.js', import.meta.url))}); +const navigation = { + ad: { + label: 'AD-Anwendungen', + items: [ + {app: 'adplaner', label: 'Assistenzplanung', href: '/route/adplaner.page.index'}, + {app: 'adrecruitment', label: 'Recruitment'}, + ], + }, +}; +const window = { + OC: {generateUrl: (path, params) => path.replace('{appId}', params.appId)}, + atob: (value) => Buffer.from(value, 'base64').toString('binary'), + t: (app, label) => `${app}:${label}`, +}; +vm.runInNewContext(source, {document, window, Object}, {filename: sourceFilename}); const nav = host.children[0]; const links = nav.children[0].children.map((item) => item.children[0]); -if (nav.tagName !== 'NAV' || nav.attributes['aria-label'] !== 'AD-Anwendungen') { +if (nav.tagName !== 'NAV' || nav.attributes['aria-label'] !== 'orgsuite:AD-Anwendungen') { throw new Error('Das Suite-Menue muss als beschriftete Navigation gerendert werden.'); } -if (links.length !== 4 || links[1].attributes['aria-current'] !== 'page' || links[1].href !== '/apps/adplaner/') { +if (links.length !== 2 || links[0].attributes['aria-current'] !== 'page' + || links[0].href !== '/route/adplaner.page.index' || links[1].href !== '/apps/adrecruitment/' + || links[1].textContent !== 'adrecruitment:Recruitment') { throw new Error('Aktiver AD-Menuepunkt wurde nicht korrekt gerendert.'); } if (host.dataset.orgsuiteMounted !== 'true') { throw new Error('Mehrfaches Mounten des Suite-Menues wird nicht verhindert.'); } +window.OrgSuiteNavigation.mount(host); + +const fallbackHost = new FakeElement('div'); +fallbackHost.dataset.suite = 'ad'; +const fallbackDocument = { + readyState: 'complete', + createElement: (tagName) => new FakeElement(tagName), + querySelectorAll: () => [fallbackHost], + querySelector: () => null, + addEventListener: () => {}, +}; +const fallbackWindow = {OC: window.OC}; +vm.runInNewContext(source, {document: fallbackDocument, window: fallbackWindow, Object}, {filename: sourceFilename}); +if (Object.keys(fallbackWindow.OrgSuiteNavigation.suites).length !== 0 || fallbackHost.children.length !== 0) { + throw new Error('Fehlender Initialzustand erweitert das Suite-Menü unerwartet.'); +} + +const malformedWindow = {OC: window.OC, atob: window.atob}; +const malformedDocument = {...fallbackDocument, querySelector: () => ({value: 'kein-json'}), querySelectorAll: () => []}; +vm.runInNewContext(source, {document: malformedDocument, window: malformedWindow, Object}, {filename: sourceFilename}); +if (Object.keys(malformedWindow.OrgSuiteNavigation.suites).length !== 0) { + throw new Error('Ungültiger Initialzustand wird nicht sicher verworfen.'); +} console.log('OrgSuite JavaScript smoke test passed');