From 477e355905cac3be6e83590cf5760ca0ef5f0d53 Mon Sep 17 00:00:00 2001 From: filzmann Date: Wed, 22 Jul 2026 19:12:44 +0200 Subject: [PATCH 1/4] feat: finalize calendar views and Kopano diagnostics --- AGENTS.md | 3 +- CHANGELOG.md | 7 ++ README.md | 4 +- ROADMAP.md | 20 ++--- appinfo/info.xml | 2 +- appinfo/routes.php | 1 + css/admin.css | 35 ++++++++- css/style.css | 6 ++ js/admin.js | 35 +++++++++ js/components/external-calendars.js | 2 +- js/components/week-navigation.js | 2 +- js/components/week-table.js | 47 ++++++++++-- js/modules/berlin-public-holidays.js | 72 ++++++++++++++++++ lib/CalendarSync/CalDavClient.php | 28 ++++++- .../ExternalCalendarConnectionException.php | 14 ++++ .../ExternalCalendarAdminController.php | 54 ++++++++++++++ lib/Controller/ExternalCalendarController.php | 4 + .../Version000007Date202607220002.php | 22 ++++++ lib/Service/ExternalCalendarService.php | 29 +++++--- templates/admin.php | 17 +++++ templates/index.php | 1 + templates/partials/settings.php | 1 + tests/Controller/DemoAdminContractTest.php | 10 ++- ...alCalendarAdminControllerExecutionTest.php | 73 +++++++++++++++++++ ...xternalCalendarControllerExecutionTest.php | 27 ++++++- .../BackgroundJobMigrationSmokeTest.php | 20 +++++ tests/Service/CalDavClientTest.php | 43 +++++++++++ tests/Service/ExternalCalendarServiceTest.php | 7 +- tests/Ui/LayoutSmokeTest.php | 5 +- tests/js/admin-settings-smoke.mjs | 24 ++++++ tests/js/calendar-workflow-smoke.mjs | 36 ++++++++- 31 files changed, 604 insertions(+), 47 deletions(-) create mode 100644 js/modules/berlin-public-holidays.js create mode 100644 lib/CalendarSync/ExternalCalendarConnectionException.php create mode 100644 lib/Controller/ExternalCalendarAdminController.php create mode 100644 lib/Migration/Version000007Date202607220002.php create mode 100644 tests/Controller/ExternalCalendarAdminControllerExecutionTest.php create mode 100644 tests/Migration/BackgroundJobMigrationSmokeTest.php diff --git a/AGENTS.md b/AGENTS.md index 4ab1a0b..8cd1302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ AD Kalender uebertraegt den fachlichen Kern des bisherigen WordPress-Plugins `ad Kernprozess: - Eine Wochenansicht zeigt Mitarbeiter*innen als Zeilen und Kalendertage als Spalten. -- Eine umschaltbare Monatsansicht zeigt die betroffenen Wochenblöcke untereinander, behält Mitarbeiter*innen als Zeilen bei, dimmt Randtage und fixiert die Personenspalte beim horizontalen Scrollen. +- Eine umschaltbare Monatsansicht zeigt die betroffenen Wochenblöcke untereinander und unterstützt wie die Wochenansicht „Tage als Zeilen“ sowie „Personen als Zeilen“. Die zu den Personen gehörende erste Spalte beziehungsweise Kopfzeile bleibt beim Scrollen sichtbar. Randtage werden gedimmt; Samstag und Sonntag werden flächig und zusätzlich mit dem Text „Wochenende“ gekennzeichnet. Gesetzliche Berliner Feiertage erscheinen mit ihrem Namen als reine Anzeigeebene und verändern weder Einträge noch Verfügbarkeit oder Rechte. - Dienste besitzen Mitarbeiter*in, Beginn und Ende. Der Titel bleibt bei Diensten optional. - Termine besitzen zusaetzlich einen sprechenden Titel. - Termine innerhalb eines Dienstes werden diesem Dienst in der Darstellung zugeordnet. @@ -45,6 +45,7 @@ Kernprozess: - Jede angemeldete Person kann im eigenen Einstellungs-Tab Kopano, Google, Apple und einen manuellen CalDAV-Anbieter verbinden. Kopano ist mit `https://mail.adberlin.org` vorbelegt, bleibt aber änderbar. Externe Anbieter erhalten einen sichtbaren, app-eigenen Kalender „AD Dienste“; ihre Kalenderinhalte werden nicht in AD Calendar eingeblendet. - Persönliche CalDAV-Zugangsdaten und Google-Tokens werden mit Nextclouds Kryptodienst verschlüsselt und als sensible Benutzerkonfiguration gespeichert. Antworten und Logs enthalten weder Passwörter, Tokens, Konto- noch Kalenderkennungen. Google benötigt einen systemweit administrierten OAuth-Webclient; ohne ihn bleibt der persönliche Verbindungsweg deaktiviert. - Der Google-OAuth-Webclient wird im app-eigenen Nextcloud-Adminabschnitt konfiguriert. Das Client-Secret ist nur schreibbar, wird als sensible lazy AppConfig gespeichert und niemals an Templates oder Statusantworten zurückgegeben; die installationsspezifische Redirect-URI wird dort kopierbar angezeigt. +- Nextcloud-Admins können im app-eigenen Adminabschnitt eine Kopano-CalDAV-Adresse mit temporär eingegebenen Zugangsdaten rein lesend prüfen. Der Test verwendet denselben Kopano-Pfadvertrag wie die persönliche Verbindung, speichert keine Zugangsdaten und legt keinen Kalender an. - Persönliche Providerverbindungen können parallel bestehen und arbeiten unabhängig vom Opt-out für den internen Nextcloud-Kalender. Ein Providerfehler blockiert weder andere Provider noch die führende AD-Mutation. Der Abgleich bleibt einseitig und überträgt auch extern ausschließlich Dienste. - Das Kalender-Demo-Pack wird ausschließlich nach ausdrücklicher Bestätigung im app-eigenen Nextcloud-Adminabschnitt installiert; `adcalendar:demo:seed` delegiert auf denselben Service. Es synchronisiert neutrale, benannte Demokonten für jede Kalenderrolle und jeden Bürobereich. Namen tragen die fachliche Demo-Zuordnung in Klammern; Mehrfachrollen werden auch in Gruppentiteln als Hauptrolle mit weiteren Rollen in Klammern dargestellt. - Demo-Provisioning übernimmt niemals ein vorhandenes fremdes oder LDAP-verwaltetes Konto. Read-only LDAP-Gruppen brechen das Pack im Preflight vor der ersten Mutation ab; eigene lokale Demokonten werden explizit in LocalBase registriert. diff --git a/CHANGELOG.md b/CHANGELOG.md index 665b1ab..abff9e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.12.0-rc.11 + +- Kopano-/CalDAV-Fehlerdiagnose mit verständlicher HTTP-405-Meldung im persönlichen Connector und einem rein lesenden administrativen Verbindungstest ergänzt. +- Umschaltbare Zeilen-/Spaltenausrichtung auch in der Monatsansicht sowie fixierte Personenachse beim Scrollen abgesichert. +- Samstage, dunklere Sonntage, gesetzliche Berliner Feiertage und getrennte Markierungen für Heiligabend und Silvester ergänzt, ohne Tagesspalten zu verbreitern. +- Den DAV-Konsistenzjob bei Updates bestehender Installationen idempotent registriert. + ## 0.12.0-rc.9 - Google-OAuth-Konfiguration im Nextcloud-Adminabschnitt von AD Kalender ergänzt. diff --git a/README.md b/README.md index 3f0025f..c03adf7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Wochen- und monatsbasierte Dienst- und Terminplanung mit wiederkehrenden Terminen, Personensuche, Gruppenfiltern, Meetinglückensuche, Standarddienstzeiten, read-only Urlaubsmarkierungen und persönlichem Dienstexport in Nextcloud sowie externe Kalender. -Die Monatsansicht stellt alle betroffenen Kalenderwochen untereinander dar. Tage außerhalb des gewählten Monats sind abgedunkelt; die Personenspalte bleibt beim horizontalen Scrollen sichtbar. Die Auswahl `Woche` oder `Monat` kann zusammen mit den Filtern als persönlicher Standard gespeichert werden. +Die Monatsansicht stellt alle betroffenen Kalenderwochen untereinander dar und lässt sich wie die Wochenansicht zwischen „Tage als Zeilen“ und „Personen als Zeilen“ umschalten. Die zu den Personen gehörende erste Spalte beziehungsweise Kopfzeile bleibt beim Scrollen sichtbar. Tage außerhalb des gewählten Monats sind abgedunkelt; Samstag und Sonntag werden zusätzlich als „Wochenende“ beschriftet. Gesetzliche Berliner Feiertage werden nach dem [Berliner Feiertagsgesetz](https://gesetze.berlin.de/perma?j=FeiertG_BE) berechnet, namentlich gekennzeichnet und bleiben ohne Auswirkung auf Dienste, Termine oder Rechte. Zeitraum und Ausrichtung können zusammen mit den Filtern als persönlicher Standard gespeichert werden. ## Staging-Kompatibilität @@ -24,6 +24,8 @@ Der Befehl `adcalendar:demo:seed` ist ausschließlich für synthetische Testdate Jede angemeldete Person verwaltet Kopano-, Google-, Apple- und manuelle CalDAV-Verbindungen im eigenen Tab `Einstellungen`. AD Calendar erzeugt beim Anbieter einen sichtbaren Kalender `AD Dienste` und exportiert ausschließlich Dienste. Anbieterinhalte werden nicht in AD Calendar eingeblendet oder zurückimportiert. - Kopano ist mit `https://mail.adberlin.org` vorbelegt; die Adresse bleibt im Verbindungsdialog änderbar. +- Der Kopano-Betreiber muss einen HTTPS-CalDAV-Endpunkt bereitstellen. HTTP 405 wird im Connector ausdrücklich als nicht freigegebener CalDAV-Zugriff erklärt; die notwendige Serverfreigabe kann nicht durch AD Kalender erfolgen. +- Nextcloud-Admins können Adresse und Zugang im AD-Kalender-Adminabschnitt mit einer ausschließlich lesenden CalDAV-Anfrage prüfen. Das Passwort wird weder gespeichert noch zurückgegeben; der Test legt keinen Kalender an. - Apple und manuelles CalDAV verwenden ein Anbieter- beziehungsweise app-spezifisches Passwort. - CalDAV-Ziele müssen HTTPS verwenden. Nextclouds HTTP-Client erzwingt zusätzlich seine serverseitige SSRF-Sperre. - Persönliche Passwörter und Google-Tokens liegen verschlüsselt und als sensible Nextcloud-Benutzerkonfiguration vor. diff --git a/ROADMAP.md b/ROADMAP.md index 9a2b519..483097a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,8 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Ve - Rollen-, Bereichs- und Personenfilter einschließlich bereichsübergreifender Leitungen in der sichtbaren Oberfläche prüfen. - Die ergänzten globalen Gruppen Stv. PDL, Büroorganisation Pflege, Fahrzeugverwaltung und Empfang mit ihrer Backend-Reihenfolge und Hierarchie im Kalender abnehmen. - Den einseitigen Abgleich persönlicher Dienste in den privaten Nextcloud-Kalender „AD Dienste“ fachlich abnehmen. -- Persönliche Kopano-, Google-, Apple- und manuelle CalDAV-Verbindungen mit realen Testkonten auf Staging fachlich abnehmen. +- Persönliche Kopano- und manuelle CalDAV-Verbindungen mit realen Testkonten auf Staging fachlich abnehmen. +- Die fachliche Abnahme der Google- und Apple-Verbindungen ist auf Mitte bis Ende August 2026 verschoben. ## Umgesetzte Synchronisationsstufe @@ -25,6 +26,7 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Ve - CalDAV-Zugangsdaten und Google-Refresh-Tokens werden mit Nextclouds `ICrypto` verschlüsselt und als sensible persönliche Konfiguration gespeichert. Statusantworten und Logs enthalten keine Geheimnisse oder Kontokennungen. - Google verwendet Webserver-OAuth mit einmaligem, nutzergebundenem Statuswert, Offline-Zugriff und dem auf app-erzeugte Kalender begrenzten Scope. Ohne systemweit hinterlegten OAuth-Client bleibt die persönliche Schaltfläche sichtbar, aber deaktiviert. - Providerfehler bleiben voneinander und von der führenden AD-Mutation isoliert. Der Hintergrundlauf bezieht verbundene externe Konten auch dann ein, wenn ihr interner Nextcloud-Kalender deaktiviert wurde. +- Der Adminbereich enthält einen rein lesenden Kopano-CalDAV-Verbindungstest. Er verwendet denselben URL-Vertrag wie der persönliche Connector, speichert keine Testzugangsdaten und erklärt insbesondere einen vom Betreiber abgewiesenen HTTP-405-Zugriff. ## Umgesetzte Terminserien @@ -36,24 +38,18 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Ve ## Umgesetzte Ansichtszeiträume - Zwischen Wochen- und Monatsansicht kann direkt in der Kalendernavigation umgeschaltet werden; der Zeitraum ist Teil des persönlichen Standards. -- Die Monatsansicht zeigt die betroffenen Wochenblöcke untereinander, hält Personen als Zeilen und dimmt Tage außerhalb des gewählten Monats. -- Die Personenspalte bleibt beim horizontalen Scrollen sichtbar. Anlegen, Bearbeiten und Löschen verwenden unverändert den bestehenden serverseitigen Rechtevertrag. +- Der Wechsel „Tage als Zeilen / Personen als Zeilen“ steht in Wochen- und Monatsansicht zur Verfügung und ist bereits Teil des persönlichen Standards. +- Die Monatsansicht zeigt die betroffenen Wochenblöcke untereinander und dimmt Tage außerhalb des gewählten Monats. +- Die zu den Personen gehörende Kopfzeile beziehungsweise erste Spalte bleibt in beiden Ausrichtungen beim Scrollen sichtbar. +- Samstag und Sonntag sind in beiden Ansichten und Ausrichtungen als Wochenendflächen und zusätzlich mit dem Text „Wochenende“ gekennzeichnet. Anlegen, Bearbeiten und Löschen verwenden unverändert den bestehenden serverseitigen Rechtevertrag. +- Gesetzliche Berliner Feiertage erscheinen in beiden Ansichten und Ausrichtungen mit Namen und eigener Markierung. Die Regeln werden lokal aus § 1 Feiertagsgesetz Berlin einschließlich beweglicher Feiertage berechnet; der einmalige Feiertag am 17. Juni 2028 ist berücksichtigt. Feiertage verändern weder Dienste noch Termine, Verfügbarkeit oder Berechtigungen. - Die Meeting-Lückensuche bleibt bewusst auf die ausgewählte Wochenansicht begrenzt. ## Geplante Erweiterungen -- Der vorhandene Wechsel „Tage als Zeilen / Personen als Zeilen“ soll auch in der Monatsansicht verfügbar sein. In beiden Ausrichtungen bleibt die zu den Personen gehörende Kopfzeile beziehungsweise erste Spalte beim Scrollen sichtbar; die Monatsansicht darf Personen weder horizontal noch vertikal aus dem sichtbaren Zuordnungskontext verlieren. -- Samstag und Sonntag sollen in Wochen- und Monatsansicht als eigene Tagesarten erkennbar sein. Die Kennzeichnung darf nicht ausschließlich über Farbe erfolgen und muss auch bei fixierten Kopfzeilen sowie in beiden Zeilen-/Spaltenausrichtungen eindeutig bleiben. -- Gesetzliche Feiertage sollen zusätzlich mit Namen und einer barrierefrei verständlichen Kennzeichnung erscheinen. Sie verändern zunächst weder Dienste noch Termine, Verfügbarkeit oder Berechtigungen, sondern bilden eine reine Anzeigeebene. - Für den Produktivbetrieb ist noch festzulegen, wie fehlgeschlagene Hintergrundläufe überwacht und administrativ sichtbar gemacht werden. - Weitere Auswertungszeiträume über Woche und Monat hinaus werden nach einem konkreten Fachbedarf festgelegt. -## Vor den neuen Kalenderkennzeichnungen zu klären - -- Welches Bundesland beziehungsweise welcher konfigurierbare Standort für gesetzliche Feiertage maßgeblich ist; für die Berliner Organisation liegt Berlin als initialer Standard nahe, ist aber vor der Umsetzung fachlich zu bestätigen. -- Aus welcher nachvollziehbaren Quelle Feiertage bezogen oder nach welchen geprüften Regeln sie berechnet werden, wie bewegliche Feiertage getestet werden und wie die Anzeige ohne erreichbaren externen Dienst funktioniert. -- Ob die gewählte Zeilen-/Spaltenausrichtung nur für die aktuelle Sitzung oder als persönliche Nextcloud-Einstellung gespeichert wird; sie erweitert keine Lese- oder Bearbeitungsrechte. - ## Festgelegte Synchronisationsleitplanken - AD Kalender bleibt zunächst die alleinige Quelle der Wahrheit; Dienste werden nur aus AD Kalender in angebundene Kalender übertragen. diff --git a/appinfo/info.xml b/appinfo/info.xml index 4cbfe3e..ed0d9f8 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ AD Kalender Dienst- und Terminplanung für Mitarbeiter*innen im Büro. Zeigt Dienste, darin liegende Termine und externe Sperrtermine in einer gemeinsamen Wochen- oder Monatsübersicht. - 0.12.0-rc.9 + 0.12.0-rc.11 agpl Simon https://github.com/Filzmann/ad-suite diff --git a/appinfo/routes.php b/appinfo/routes.php index 6d09e1c..d7a43a8 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -24,5 +24,6 @@ ['name' => 'demo_admin#install', 'url' => '/api/admin/demo-pack/install', 'verb' => 'POST'], ['name' => 'google_oauth_admin#save', 'url' => '/api/admin/google-oauth', 'verb' => 'PUT'], ['name' => 'google_oauth_admin#remove', 'url' => '/api/admin/google-oauth', 'verb' => 'DELETE'], + ['name' => 'external_calendar_admin#testCalDav', 'url' => '/api/admin/external-calendars/caldav/test', 'verb' => 'POST'], ], ]; diff --git a/css/admin.css b/css/admin.css index cca22e0..404fee9 100644 --- a/css/admin.css +++ b/css/admin.css @@ -56,6 +56,38 @@ font-size: var(--font-size-small); } +.adc-kopano-test-form { + display: grid; + grid-template-columns: minmax(12rem, 15rem) minmax(18rem, 1fr); + gap: 8px 12px; + align-items: center; + max-width: 760px; + margin-top: 14px; +} + +.adc-kopano-test-form > label { + font-weight: 600; +} + +.adc-kopano-test-form > input { + min-width: 0; + width: 100%; +} + +.adc-kopano-test-form > small, +.adc-kopano-test-form > div { + grid-column: 2; +} + +.adc-kopano-test-status { + margin: 12px 0 0; + padding-inline-start: 10px; + border-inline-start: 4px solid var(--color-text-maxcontrast); +} + +.adc-kopano-test-status.is-success { border-inline-start-color: var(--color-success); } +.adc-kopano-test-status.is-error { border-inline-start-color: var(--color-error); } + .adc-google-oauth-status { margin: 12px 0; padding-inline-start: 10px; @@ -156,7 +188,8 @@ } @media (max-width: 700px) { - .adc-google-oauth-form { grid-template-columns: 1fr; } + .adc-google-oauth-form, .adc-kopano-test-form { grid-template-columns: 1fr; } + .adc-kopano-test-form > small, .adc-kopano-test-form > div, .adc-google-oauth-form > small, .adc-google-oauth-actions { grid-column: 1; } .adc-google-redirect-row { align-items: stretch; flex-direction: column; } diff --git a/css/style.css b/css/style.css index 03be46e..85c6cbf 100644 --- a/css/style.css +++ b/css/style.css @@ -44,6 +44,7 @@ .adc-provider-card { min-width: 0; padding: 10px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-background-hover); } .adc-provider-card h3 { margin: 0 0 6px; } .adc-provider-card p { min-height: 2.5em; margin: 0 0 8px; color: var(--color-text-maxcontrast); } +.adc-provider-card .adc-provider-requirement { min-height: 0; padding-inline-start: 8px; border-inline-start: 3px solid var(--color-warning); font-size: var(--font-size-small); } .adc-provider-card > div { display: flex; flex-wrap: wrap; gap: 5px; } .adc-provider-card button { margin: 0; } .adc-external-dialog > form { display: grid; gap: 8px; } @@ -62,9 +63,14 @@ .adc-calendar thead th { position: sticky; top: 0; z-index: 4; background: var(--color-main-background); white-space: nowrap; } .adc-calendar th:first-child { width: auto; min-width: 11.5rem; } .adc-calendar thead th:first-child, .adc-calendar tbody th[scope="row"] { position: sticky; inset-inline-start: 0; background: var(--color-main-background); box-shadow: 2px 0 0 var(--color-border); } +.adc-calendar thead .adc-person-heading { position: sticky; top: 0; z-index: 5; } +.adc-calendar tbody .adc-person-heading { position: sticky; inset-inline-start: 0; z-index: 3; } .adc-calendar thead th:first-child { z-index: 6; } .adc-calendar tbody th[scope="row"] { z-index: 3; } .adc-calendar td { min-width: 8.5rem; } +.adc-calendar .adc-weekend { background: var(--color-background-dark); } +.adc-calendar th.adc-weekend { font-weight: 700; } +.adc-calendar .adc-holiday { box-shadow: inset 0 3px 0 var(--color-warning); } .adc-outside-month { opacity: .52; } .adc-cell-actions { display: flex; gap: 2px; min-height: 24px; margin: 0 0 3px; } .adc-quick-add { position: relative; min-height: 24px !important; height: 24px; margin: 0 !important; padding: 0 !important; border-radius: 50%; font-size: var(--font-size-small); overflow: visible !important; } diff --git a/js/admin.js b/js/admin.js index 8c144e2..bcd9e31 100644 --- a/js/admin.js +++ b/js/admin.js @@ -68,6 +68,40 @@ }); } + function initCalDavTest() { + const form = document.getElementById('adc-kopano-test-form'); + if (!form) return; + const serverUrl = document.getElementById('adc-kopano-test-url'); + const username = document.getElementById('adc-kopano-test-username'); + const password = document.getElementById('adc-kopano-test-password'); + const status = document.getElementById('adc-kopano-test-status'); + const submit = document.getElementById('adc-kopano-test-submit'); + + const showStatus = (message, error = false) => { + status.textContent = message; + status.classList.remove('is-success', 'is-error'); + status.classList.add(error ? 'is-error' : 'is-success'); + }; + + form.addEventListener('submit', async event => { + event.preventDefault(); + submit.disabled = true; + showStatus('Kopano-CalDAV-Verbindung wird geprüft.'); + try { + const response = await client.request('/api/admin/external-calendars/caldav/test', { + method: 'POST', + body: JSON.stringify({ serverUrl: serverUrl.value, username: username.value, password: password.value }), + }); + showStatus(response.message || 'Kopano-CalDAV-Verbindung erfolgreich geprüft.'); + } catch (error) { + showStatus(error.message || 'Die Kopano-CalDAV-Verbindung konnte nicht geprüft werden.', true); + } finally { + password.value = ''; + submit.disabled = false; + } + }); + } + function initDemoPack() { const confirmation = document.getElementById('adc-demo-confirm'); const button = document.getElementById('adc-demo-install'); @@ -96,5 +130,6 @@ } initGoogleOAuth(); + initCalDavTest(); initDemoPack(); }()); diff --git a/js/components/external-calendars.js b/js/components/external-calendars.js index 28418de..1295f80 100644 --- a/js/components/external-calendars.js +++ b/js/components/external-calendars.js @@ -5,7 +5,7 @@ kopano: { title: 'Kopano verbinden', serverUrl: 'https://mail.adberlin.org', - instruction: 'Melde dich mit deinem Kopano-Benutzernamen und Passwort an. Die vorbelegte Serveradresse kann geändert werden.', + instruction: 'Melde dich mit deinem Kopano-Benutzernamen und Passwort an. Die vorbelegte Serveradresse kann geändert werden. Voraussetzung: Der Kopano-Betreiber muss CalDAV per HTTPS erlauben; die App kann diesen Serverzugang nicht selbst freischalten.', usernameLabel: 'Kopano-Benutzername', passwordLabel: 'Kopano-Passwort', }, diff --git a/js/components/week-navigation.js b/js/components/week-navigation.js index fb7fb5c..1f76a3e 100644 --- a/js/components/week-navigation.js +++ b/js/components/week-navigation.js @@ -44,7 +44,7 @@ this.monthNumber.value = CalendarDate.monthValue(this.state.month); this.weekPicker.hidden = isMonth; this.monthPicker.hidden = !isMonth; - this.toggleView.hidden = isMonth; + this.toggleView.hidden = false; this.weekButton.setAttribute('aria-pressed', String(!isMonth)); this.monthButton.setAttribute('aria-pressed', String(isMonth)); this.previous.textContent = isMonth ? 'Vorheriger Monat' : 'Vorherige Woche'; diff --git a/js/components/week-table.js b/js/components/week-table.js index 3b63ce5..2ef4b2a 100644 --- a/js/components/week-table.js +++ b/js/components/week-table.js @@ -12,6 +12,7 @@ this.calendarCell = options.calendarCell; this.organization = options.organization; this.timeline = new window.AdCalendar.modules.CalendarTimeline(); + this.holidays = options.holidays || new window.AdCalendar.modules.BerlinPublicHolidays(); } render(employees, state) { @@ -42,14 +43,17 @@ wrap.append(table); block.append(wrap); const activeMonth = state.period === 'month' ? state.month : null; - if (state.period === 'month' || state.vertical) this.vertical(employees, state, days, head, body, activeMonth); - else this.horizontal(employees, state, days, head, body); + if (state.vertical) this.vertical(employees, state, days, head, body, activeMonth); + else this.horizontal(employees, state, days, head, body, activeMonth); return block; } vertical(employees, state, days, head, body, activeMonth = null) { const header = document.createElement('tr'); header.append(this.node('th', 'Mitarbeiter*in')); - for (const day of days) header.append(this.node('th', day.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' }), this.outsideMonth(day, activeMonth) ? 'adc-outside-month' : '')); + for (const day of days) { + const dayHeading = this.node('th', this.dayLabel(day, { weekday: 'short', day: '2-digit', month: '2-digit' }), this.dayClasses(day, activeMonth)); + dayHeading.scope = 'col'; header.append(dayHeading); + } head.replaceChildren(header); const rows = []; @@ -62,7 +66,7 @@ groupCell.colSpan = 8; groupRow.append(groupCell); rows.push(groupRow); previousCluster = cluster; } const row = document.createElement('tr'); - const name = this.node('th', employee.displayName, state.selected.has(employee.uid) ? 'adc-selected' : ''); + const name = this.node('th', employee.displayName, this.classes('adc-person-heading', state.selected.has(employee.uid) ? 'adc-selected' : '')); name.scope = 'row'; row.append(name); const employeeEntries = state.data.entries.filter(entry => entry.employeeUid === employee.uid); const layout = this.timeline.layout(employeeEntries, days); @@ -72,19 +76,22 @@ body.replaceChildren(...rows); } - horizontal(employees, state, days, head, body) { + horizontal(employees, state, days, head, body, activeMonth = null) { const header = document.createElement('tr'); header.append(this.node('th', 'Tag')); - for (const employee of employees) header.append(this.node('th', employee.displayName, state.selected.has(employee.uid) ? 'adc-selected' : '')); + for (const employee of employees) { + const name = this.node('th', employee.displayName, this.classes('adc-person-heading', state.selected.has(employee.uid) ? 'adc-selected' : '')); + name.scope = 'col'; header.append(name); + } head.replaceChildren(header); const rows = days.map(day => { const row = document.createElement('tr'); - const label = this.node('th', day.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit' })); + const label = this.node('th', this.dayLabel(day, { weekday: 'long', day: '2-digit', month: '2-digit' }), this.dayClasses(day, activeMonth)); label.scope = 'row'; row.append(label); const dayEnd = new Date(day); dayEnd.setDate(dayEnd.getDate() + 1); const visibleUids = new Set(employees.map(employee => employee.uid)); const dayEntries = state.data.entries.filter(entry => visibleUids.has(entry.employeeUid) && new Date(entry.start) < dayEnd && new Date(entry.end) > day); const layout = this.timeline.layout(dayEntries, [day]); - for (const employee of employees) row.append(this.cellFor(employee, day, state.data.entries, state.data.absences || [], layout)); + for (const employee of employees) row.append(this.cellFor(employee, day, state.data.entries, state.data.absences || [], layout, activeMonth)); return row; }); body.replaceChildren(...rows); @@ -98,10 +105,33 @@ cell.dataset.employeeUid = employee.uid; cell.dataset.day = CalendarDate.isoDay(day); if (this.outsideMonth(day, activeMonth)) cell.classList.add('adc-outside-month'); + if (this.isWeekend(day)) cell.classList.add('adc-weekend'); + const holiday = this.holidays.name(day); + if (holiday) { + cell.classList.add('adc-holiday'); + cell.dataset.holiday = holiday; + } cell.innerHTML = this.calendarCell.render(entries, employee, absences, layout, day, this.timeline); return cell; } + dayLabel(day, options) { + const label = day.toLocaleDateString('de-DE', options); + return [label, this.isWeekend(day) ? 'Wochenende' : '', this.holidays.name(day)].filter(Boolean).join(' · '); + } + + dayClasses(day, activeMonth) { + return this.classes( + this.outsideMonth(day, activeMonth) ? 'adc-outside-month' : '', + this.isWeekend(day) ? 'adc-weekend' : '', + this.holidays.name(day) ? 'adc-holiday' : '', + ); + } + + isWeekend(day) { + return day.getDay() === 0 || day.getDay() === 6; + } + outsideMonth(day, activeMonth) { return activeMonth !== null && (day.getFullYear() !== activeMonth.getFullYear() || day.getMonth() !== activeMonth.getMonth()); } @@ -159,6 +189,7 @@ } days(monday) { return Array.from({ length: 7 }, (_, offset) => { const day = new Date(monday); day.setDate(day.getDate() + offset); return day; }); } + classes(...values) { return values.filter(Boolean).join(' '); } node(tag, value, className) { const result = document.createElement(tag); result.textContent = value; if (className) result.className = className; return result; } } diff --git a/js/modules/berlin-public-holidays.js b/js/modules/berlin-public-holidays.js new file mode 100644 index 0000000..e2467ad --- /dev/null +++ b/js/modules/berlin-public-holidays.js @@ -0,0 +1,72 @@ +(function () { + 'use strict'; + + /** + * Zweck: Bestimmt die gesetzlichen Berliner Feiertage ohne Laufzeitabhängigkeit zu einem Fremddienst. + * Quelle: § 1 Feiertagsgesetz Berlin; bewegliche Feiertage werden vom Ostersonntag abgeleitet. + */ + class BerlinPublicHolidays { + constructor() { + this.cache = new Map(); + } + + name(date) { + return this.forYear(date.getFullYear()).get(this.isoDay(date)) || ''; + } + + forYear(year) { + if (this.cache.has(year)) return this.cache.get(year); + const holidays = new Map([ + [`${year}-01-01`, 'Neujahr'], + [`${year}-03-08`, 'Internationaler Frauentag'], + [`${year}-05-01`, 'Tag der Arbeit'], + [`${year}-10-03`, 'Tag der Deutschen Einheit'], + [`${year}-12-25`, '1. Weihnachtsfeiertag'], + [`${year}-12-26`, '2. Weihnachtsfeiertag'], + ]); + const easter = this.easterSunday(year); + for (const [offset, name] of [ + [-2, 'Karfreitag'], + [1, 'Ostermontag'], + [39, 'Christi Himmelfahrt'], + [50, 'Pfingstmontag'], + ]) holidays.set(this.isoDay(this.addDays(easter, offset)), name); + if (year === 2028) holidays.set('2028-06-17', '75. Jahrestag des Aufstandes vom 17. Juni 1953'); + this.cache.set(year, holidays); + return holidays; + } + + easterSunday(year) { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return new Date(year, month - 1, day, 12); + } + + addDays(date, offset) { + const result = new Date(date); + result.setDate(result.getDate() + offset); + return result; + } + + isoDay(date) { + const pad = value => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } + } + + window.AdCalendar = window.AdCalendar || {}; + window.AdCalendar.modules = window.AdCalendar.modules || {}; + window.AdCalendar.modules.BerlinPublicHolidays = BerlinPublicHolidays; +}()); diff --git a/lib/CalendarSync/CalDavClient.php b/lib/CalendarSync/CalDavClient.php index 9dd6b6a..6be7736 100644 --- a/lib/CalendarSync/CalDavClient.php +++ b/lib/CalendarSync/CalDavClient.php @@ -35,6 +35,19 @@ public function connect(array $connection): string { return $this->discoverOrCreateCalendar($connection); } + /** Prüft Adresse und Anmeldung ausschließlich lesend und gibt den erfolgreichen HTTP-Status zurück. */ + public function probe(array $connection): int { + $this->assertCredentials($connection); + $baseUrl = $this->urls->normalize((string)$connection['serverUrl']); + $parts = parse_url($baseUrl); + $probeUrl = (($parts['path'] ?? '/') === '/') + ? 'https://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '') . '/.well-known/caldav' + : rtrim($baseUrl, '/'); + $response = $this->request('PROPFIND', $probeUrl, $connection, $this->properties(['current-user-principal', 'calendar-home-set']), ['Depth' => '0']); + $this->expect($response, [200, 207]); + return $response['status']; + } + /** @param list $shifts */ public function replaceAll(array $connection, array $shifts): void { $calendarUrl = $this->connect($connection); @@ -174,7 +187,7 @@ private function request(string $method, string $url, array $connection, ?string } catch (\Throwable) { throw new RuntimeException('Der Kalenderanbieter ist nicht erreichbar.', 0, $error); } - if (!in_array($response->getStatusCode(), $acceptedErrors, true)) throw new RuntimeException('Der Kalenderanbieter hat die Anfrage abgewiesen.', 0, $error); + if (!in_array($response->getStatusCode(), $acceptedErrors, true)) throw $this->rejectedResponse($response->getStatusCode(), $error); } if (!in_array($response->getStatusCode(), [301, 302, 307, 308], true)) break; if ($redirects >= 3) throw new RuntimeException('Der Kalenderanbieter leitet zu häufig weiter.'); @@ -186,7 +199,18 @@ private function request(string $method, string $url, array $connection, ?string } private function expect(array $response, array $statuses): void { - if (!in_array($response['status'], $statuses, true)) throw new RuntimeException('Der Kalenderanbieter hat unerwartet geantwortet.'); + if (!in_array($response['status'], $statuses, true)) throw $this->rejectedResponse($response['status']); + } + + private function rejectedResponse(int $status, ?\Throwable $previous = null): RuntimeException { + if ($status === 405) { + return new ExternalCalendarConnectionException( + 'Der Kalenderanbieter erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an die Administration des Anbieters.', + $status, + $previous, + ); + } + return new RuntimeException('Der Kalenderanbieter hat die Anfrage abgewiesen.', $status, $previous); } private function body(mixed $body): string { diff --git a/lib/CalendarSync/ExternalCalendarConnectionException.php b/lib/CalendarSync/ExternalCalendarConnectionException.php new file mode 100644 index 0000000..a904265 --- /dev/null +++ b/lib/CalendarSync/ExternalCalendarConnectionException.php @@ -0,0 +1,14 @@ +getCode() === 405 + ? 'Der Kopano-Betreiber erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an dessen Administration.' + : $this->getMessage(); + } +} diff --git a/lib/Controller/ExternalCalendarAdminController.php b/lib/Controller/ExternalCalendarAdminController.php new file mode 100644 index 0000000..0323947 --- /dev/null +++ b/lib/Controller/ExternalCalendarAdminController.php @@ -0,0 +1,54 @@ +isAdmin()) return $this->denied(); + try { + $status = $this->calendars->testCalDavConnection('kopano', $serverUrl, $username, $password); + return new JSONResponse(['message' => "Kopano-CalDAV-Verbindung erfolgreich geprüft (HTTP {$status})."]); + } catch (\InvalidArgumentException $error) { + return new JSONResponse(['error' => $error->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (ExternalCalendarConnectionException $error) { + $this->logger->error('Administrativer Kopano-CalDAV-Test wurde vom Anbieter abgewiesen.', ['provider' => 'kopano', 'status' => $error->getCode()]); + return new JSONResponse(['error' => $error->userMessage('kopano')], Http::STATUS_BAD_REQUEST); + } catch (\Throwable $error) { + $this->logger->error('Administrativer Kopano-CalDAV-Test ist fehlgeschlagen.', ['provider' => 'kopano', 'exceptionClass' => $error::class]); + return new JSONResponse(['error' => 'Die Kopano-CalDAV-Verbindung konnte nicht geprüft werden. Bitte Adresse und Serverkonfiguration prüfen.'], Http::STATUS_BAD_REQUEST); + } + } + + private function isAdmin(): bool { + $user = $this->session->getUser(); + return $user !== null && $this->groups->isAdmin($user->getUID()); + } + + private function denied(): JSONResponse { + return new JSONResponse(['error' => 'Keine Berechtigung.'], Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/Controller/ExternalCalendarController.php b/lib/Controller/ExternalCalendarController.php index f510ecc..02d685e 100644 --- a/lib/Controller/ExternalCalendarController.php +++ b/lib/Controller/ExternalCalendarController.php @@ -5,6 +5,7 @@ namespace OCA\AdCalendar\Controller; use OCA\AdCalendar\AppInfo\Application; +use OCA\AdCalendar\CalendarSync\ExternalCalendarConnectionException; use OCA\AdCalendar\CalendarSync\GoogleOAuthService; use OCA\AdCalendar\Service\CalendarAccessService; use OCA\AdCalendar\Service\ExternalCalendarService; @@ -45,6 +46,9 @@ public function connectCalDav(string $provider, string $serverUrl, string $usern return new JSONResponse(['externalCalendars' => $this->calendars->connectCalDav($uid, $provider, $serverUrl, $username, $password)]); } catch (\InvalidArgumentException $error) { return new JSONResponse(['error' => $error->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (ExternalCalendarConnectionException $error) { + $this->logger->error('Der externe Kalenderanbieter erlaubt keine CalDAV-Verbindung.', ['provider' => $provider, 'status' => $error->getCode()]); + return new JSONResponse(['error' => $error->userMessage($provider)], Http::STATUS_BAD_REQUEST); } catch (\Throwable $error) { $this->logger->error('Externe CalDAV-Verbindung konnte nicht hergestellt werden.', ['provider' => $provider, 'exception' => $error]); return new JSONResponse(['error' => 'Die Kalenderverbindung konnte nicht hergestellt werden. Bitte Adresse und Zugangsdaten prüfen.'], Http::STATUS_BAD_REQUEST); diff --git a/lib/Migration/Version000007Date202607220002.php b/lib/Migration/Version000007Date202607220002.php new file mode 100644 index 0000000..3c1166d --- /dev/null +++ b/lib/Migration/Version000007Date202607220002.php @@ -0,0 +1,22 @@ +jobs->has(ReconcileShiftCalendarsJob::class, null)) { + $this->jobs->add(ReconcileShiftCalendarsJob::class); + } + } +} diff --git a/lib/Service/ExternalCalendarService.php b/lib/Service/ExternalCalendarService.php index 110318f..012d601 100644 --- a/lib/Service/ExternalCalendarService.php +++ b/lib/Service/ExternalCalendarService.php @@ -35,18 +35,9 @@ public function status(string $uid): array { } public function connectCalDav(string $uid, string $provider, string $serverUrl, string $username, string $password): array { - if (!in_array($provider, ExternalCalendarConnectionStore::CALDAV_PROVIDERS, true)) throw new InvalidArgumentException('Unbekannter CalDAV-Anbieter.'); if (trim($uid) === '') throw new InvalidArgumentException('Die angemeldete Person fehlt.'); - $serverUrl = trim($serverUrl) === '' ? self::DEFAULTS[$provider] : $serverUrl; - $serverUrl = $this->urls->normalize($serverUrl); - $username = trim($username); - if ($username === '' || $password === '') throw new InvalidArgumentException('Benutzername und Passwort sind erforderlich.'); - if (strlen($username) > 320 || strlen($password) > 4096) throw new InvalidArgumentException('Die Zugangsdaten sind zu lang.'); - if ($provider === 'kopano' && (parse_url($serverUrl, PHP_URL_PATH) ?: '/') === '/') { - $serverUrl .= 'caldav/' . rawurlencode($username) . '/'; - } + $connection = $this->calDavConnection($provider, $serverUrl, $username, $password); $previous = $this->connections->connection($uid, $provider); - $connection = ['serverUrl' => $serverUrl, 'username' => $username, 'password' => $password]; $connection['calendarUrl'] = $this->calDav->connect($connection); if ($previous !== null && ($previous['calendarUrl'] ?? '') !== $connection['calendarUrl']) { $this->publisher->removeProviderCalendar($uid, $provider, $previous); @@ -65,6 +56,24 @@ public function connectCalDav(string $uid, string $provider, string $serverUrl, return $this->status($uid); } + /** Prüft dieselbe Provideradresse wie die persönliche Verbindung, ohne Daten zu speichern oder Kalender anzulegen. */ + public function testCalDavConnection(string $provider, string $serverUrl, string $username, string $password): int { + return $this->calDav->probe($this->calDavConnection($provider, $serverUrl, $username, $password)); + } + + private function calDavConnection(string $provider, string $serverUrl, string $username, string $password): array { + if (!in_array($provider, ExternalCalendarConnectionStore::CALDAV_PROVIDERS, true)) throw new InvalidArgumentException('Unbekannter CalDAV-Anbieter.'); + $serverUrl = trim($serverUrl) === '' ? self::DEFAULTS[$provider] : $serverUrl; + $serverUrl = $this->urls->normalize($serverUrl); + $username = trim($username); + if ($username === '' || $password === '') throw new InvalidArgumentException('Benutzername und Passwort sind erforderlich.'); + if (strlen($username) > 320 || strlen($password) > 4096) throw new InvalidArgumentException('Die Zugangsdaten sind zu lang.'); + if ($provider === 'kopano' && (parse_url($serverUrl, PHP_URL_PATH) ?: '/') === '/') { + $serverUrl .= 'caldav/' . rawurlencode($username) . '/'; + } + return ['serverUrl' => $serverUrl, 'username' => $username, 'password' => $password]; + } + public function connectGoogle(string $uid, array $tokens): array { if (trim($uid) === '') throw new InvalidArgumentException('Die angemeldete Person fehlt.'); $previous = $this->connections->connection($uid, 'google'); diff --git a/templates/admin.php b/templates/admin.php index 5a5bc35..45b7fa0 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -22,6 +22,23 @@

Keine Konten- oder Kalenderkennungen werden in diesem Status gespeichert oder angezeigt.

+
+

Kopano und CalDAV

+

Voraussetzung: Der Kopano-Betreiber muss CalDAV per HTTPS bereitstellen und Methoden wie PROPFIND an den Kopano-CalDAV-Dienst weiterleiten. AD Kalender kann diesen Zugang nicht selbst auf dem fremden Server freischalten.

+

HTTP 405 bedeutet: Der Betreiber erlaubt an dieser Adresse keine CalDAV-Verbindung. Ohne Zugangsdaten ist HTTP 401 eine erwartbare Anmeldeaufforderung; ein erfolgreicher authentifizierter PROPFIND antwortet üblicherweise mit HTTP 207.

+

Die Vorgabe https://mail.adberlin.org bleibt im persönlichen Verbindungsdialog änderbar. Bei einer abweichenden veröffentlichten CalDAV-Adresse muss dort die vollständige HTTPS-Adresse eingetragen werden.

+
+ + + + + + + Der Test führt ausschließlich eine lesende CalDAV-Anfrage aus. Zugangsdaten werden nicht gespeichert und kein Kalender wird angelegt. +
+
+

Noch nicht getestet.

+

Google Calendar OAuth

Diese systemweite Webclient-Konfiguration ermöglicht allen Nutzer*innen, ihr eigenes Google-Konto im persönlichen Einstellungs-Tab zu verbinden.

diff --git a/templates/index.php b/templates/index.php index e66addb..9721083 100644 --- a/templates/index.php +++ b/templates/index.php @@ -7,6 +7,7 @@ \OCP\Util::addScript('adcalendar', 'models/organization'); \OCP\Util::addScript('adcalendar', 'repositories/calendar-repository'); \OCP\Util::addScript('adcalendar', 'modules/calendar-date'); +\OCP\Util::addScript('adcalendar', 'modules/berlin-public-holidays'); \OCP\Util::addScript('adcalendar', 'modules/calendar-state'); \OCP\Util::addScript('adcalendar', 'modules/calendar-timeline'); \OCP\Util::addScript('adcalendar', 'modules/entry-workflow'); diff --git a/templates/partials/settings.php b/templates/partials/settings.php index 2a430de..8439585 100644 --- a/templates/partials/settings.php +++ b/templates/partials/settings.php @@ -15,6 +15,7 @@

Kopano

Vorgabe: https://mail.adberlin.org (änderbar) +

Voraussetzung: Der Kopano-Betreiber muss CalDAV per HTTPS erlauben. HTTP 405 bedeutet, dass der Betreiber den CalDAV-Zugriff an dieser Adresse nicht freigegeben hat.

Status wird geladen.

diff --git a/tests/Controller/DemoAdminContractTest.php b/tests/Controller/DemoAdminContractTest.php index 9925a59..2ce38b9 100644 --- a/tests/Controller/DemoAdminContractTest.php +++ b/tests/Controller/DemoAdminContractTest.php @@ -7,26 +7,34 @@ $info = file_get_contents($root . '/appinfo/info.xml'); $controller = file_get_contents($root . '/lib/Controller/DemoAdminController.php'); $googleController = file_get_contents($root . '/lib/Controller/GoogleOAuthAdminController.php'); +$externalCalendarController = file_get_contents($root . '/lib/Controller/ExternalCalendarAdminController.php'); $template = file_get_contents($root . '/templates/admin.php'); $script = file_get_contents($root . '/js/admin.js'); -if ($routes === false || $info === false || $controller === false || $googleController === false || $template === false || $script === false) { +if ($routes === false || $info === false || $controller === false || $googleController === false || $externalCalendarController === false || $template === false || $script === false) { throw new RuntimeException('Kalender-Demo-Adminbestandteile fehlen.'); } foreach (['/api/admin/demo-pack/install', "'verb' => 'POST'"] as $contract) if (!str_contains($routes, $contract)) throw new RuntimeException("Demo-Route fehlt: {$contract}"); foreach (["'google_oauth_admin#save'", "'google_oauth_admin#remove'", '/api/admin/google-oauth', "'verb' => 'PUT'", "'verb' => 'DELETE'"] as $contract) if (!str_contains($routes, $contract)) throw new RuntimeException("Google-Adminroute fehlt: {$contract}"); +foreach (["'external_calendar_admin#testCalDav'", '/api/admin/external-calendars/caldav/test', "'verb' => 'POST'"] as $contract) if (!str_contains($routes, $contract)) throw new RuntimeException("CalDAV-Adminroute fehlt: {$contract}"); foreach (['OCA\\AdCalendar\\Settings\\Admin', 'OCA\\AdCalendar\\Settings\\AdminSection'] as $contract) if (!str_contains($info, $contract)) throw new RuntimeException("Adminregistrierung fehlt: {$contract}"); foreach (['CalendarDemoPackService', 'private function isAdmin()', '$this->groups->isAdmin(', 'Http::STATUS_FORBIDDEN'] as $contract) if (!str_contains($controller, $contract)) throw new RuntimeException("Serverseitiger Demo-Adminschutz fehlt: {$contract}"); if (str_contains($controller, 'NoCSRFRequired')) throw new RuntimeException('Demo-Installation darf den CSRF-Schutz nicht umgehen.'); foreach (['private function isAdmin()', '$this->groups->isAdmin(', 'Http::STATUS_FORBIDDEN', 'saveConfiguration', 'removeConfiguration'] as $contract) if (!str_contains($googleController, $contract)) throw new RuntimeException("Serverseitiger Google-Adminschutz fehlt: {$contract}"); if (str_contains($googleController, 'NoCSRFRequired')) throw new RuntimeException('Google-Administration darf den CSRF-Schutz nicht umgehen.'); +foreach (['private function isAdmin()', '$this->groups->isAdmin(', 'Http::STATUS_FORBIDDEN', 'testCalDavConnection'] as $contract) if (!str_contains($externalCalendarController, $contract)) throw new RuntimeException("Serverseitiger CalDAV-Testschutz fehlt: {$contract}"); +if (str_contains($externalCalendarController, 'NoCSRFRequired')) throw new RuntimeException('CalDAV-Verbindungstest darf den CSRF-Schutz nicht umgehen.'); foreach (['id="adc-demo-confirm"', 'id="adc-demo-install"', 'nicht automatisch', 'Dienstkalender-Abgleich', 'calendarSyncStatus', 'Keine Konten- oder Kalenderkennungen'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Demo- oder DAV-Adminoberfläche fehlt: {$contract}"); +foreach (['id="adc-kopano-caldav-heading"', 'Kopano und CalDAV', 'Der Kopano-Betreiber muss CalDAV', 'HTTP 405', 'HTTP 207'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Kopano-CalDAV-Adminhinweis fehlt: {$contract}"); +foreach (['id="adc-kopano-test-form"', 'id="adc-kopano-test-url"', 'id="adc-kopano-test-username"', 'id="adc-kopano-test-password"', 'id="adc-kopano-test-status"', 'Verbindung testen', 'nicht gespeichert'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Administratives Kopano-Testformular fehlt: {$contract}"); foreach (['adc-demo-confirm', 'adc-demo-install', "client.request('/api/admin/demo-pack/install'"] as $contract) if (!str_contains($script, $contract)) throw new RuntimeException("Demo-Admininteraktion fehlt: {$contract}"); foreach (['id="adc-google-oauth-form"', 'id="adc-google-client-id"', 'id="adc-google-client-secret"', 'id="adc-google-redirect-uri"', 'id="adc-google-oauth-remove"', 'autocomplete="new-password"', 'googleOAuth'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Google-Adminoberfläche fehlt: {$contract}"); foreach (['
', 'Google-App registrieren – Schritt für Schritt', 'Google Calendar API', 'Google Auth Platform', 'Intern', 'Extern', 'https://www.googleapis.com/auth/calendar.app.created', 'Webanwendung', 'Autorisierte Weiterleitungs-URI', 'Keine autorisierten JavaScript-Quellen', 'Testnutzer*innen', 'sieben Tagen', 'https://console.cloud.google.com/', 'https://developers.google.com/workspace/calendar/api/auth', 'https://developers.google.com/identity/protocols/oauth2/web-server', 'rel="noopener noreferrer"'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Google-Registrierungsanleitung fehlt: {$contract}"); foreach (['initGoogleOAuth', "client.request('/api/admin/google-oauth'", "method: 'PUT'", "method: 'DELETE'", "secret.value = ''", 'navigator.clipboard.writeText'] as $contract) if (!str_contains($script, $contract)) throw new RuntimeException("Google-Admininteraktion fehlt: {$contract}"); +foreach (['initCalDavTest', "client.request('/api/admin/external-calendars/caldav/test'", "method: 'POST'", "password.value = ''"] as $contract) if (!str_contains($script, $contract)) throw new RuntimeException("CalDAV-Admininteraktion fehlt: {$contract}"); $style = file_get_contents($root . '/css/admin.css'); foreach (['.adc-google-registration-guide', '.adc-google-registration-guide summary:focus-visible', '.adc-google-registration-guide code'] as $contract) if ($style === false || !str_contains($style, $contract)) throw new RuntimeException("Google-Anleitungsdarstellung fehlt: {$contract}"); +foreach (['.adc-kopano-test-form', '.adc-kopano-test-status.is-success', '.adc-kopano-test-status.is-error'] as $contract) if ($style === false || !str_contains($style, $contract)) throw new RuntimeException("Kopano-Testdarstellung fehlt: {$contract}"); $settings = file_get_contents($root . '/lib/Settings/Admin.php'); foreach (['ShiftCalendarReconciliationStatusService', 'GoogleOAuthService', 'IDateTimeFormatter', "'calendarSyncStatus'", "'lastRunLabel'", "'googleOAuth'"] as $contract) if ($settings === false || !str_contains($settings, $contract)) throw new RuntimeException("Aggregierter DAV- oder Google-Adminstatus fehlt: {$contract}"); diff --git a/tests/Controller/ExternalCalendarAdminControllerExecutionTest.php b/tests/Controller/ExternalCalendarAdminControllerExecutionTest.php new file mode 100644 index 0000000..59f2416 --- /dev/null +++ b/tests/Controller/ExternalCalendarAdminControllerExecutionTest.php @@ -0,0 +1,73 @@ +data; } public function getStatus(): int { return $this->status; } } +} +namespace Psr\Log { interface LoggerInterface { public function error(string|\Stringable $message, array $context = []): void; } } +namespace OCA\AdCalendar\AppInfo { final class Application { public const APP_ID = 'adcalendar'; } } +namespace OCA\AdCalendar\CalendarSync { + final class ExternalCalendarConnectionException extends \RuntimeException { + public function userMessage(string $provider): string { return $provider === 'kopano' ? 'Der Kopano-Betreiber erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an dessen Administration.' : $this->getMessage(); } + } +} +namespace OCA\AdCalendar\Service { + final class ExternalCalendarService { + public array $calls = []; + public bool $blocked = false; + public function testCalDavConnection(string $provider, string $serverUrl, string $username, string $password): int { + $this->calls[] = [$provider, $serverUrl, $username, $password]; + if ($this->blocked) throw new \OCA\AdCalendar\CalendarSync\ExternalCalendarConnectionException('Blockiert.', 405); + return 207; + } + } +} + +namespace { + require_once __DIR__ . '/../../lib/Controller/ExternalCalendarAdminController.php'; + + use OCA\AdCalendar\Controller\ExternalCalendarAdminController; + use OCA\AdCalendar\Service\ExternalCalendarService; + use OCP\IGroupManager; + use OCP\IRequest; + use OCP\IUser; + use OCP\IUserSession; + use Psr\Log\LoggerInterface; + + $user = new class implements IUser { public function getUID(): string { return 'admin-a'; } }; + $session = new class($user) implements IUserSession { public function __construct(public ?IUser $user) {} public function getUser(): ?IUser { return $this->user; } }; + $groups = new class implements IGroupManager { public bool $admin = false; public function isAdmin(string $uid): bool { return $this->admin; } }; + $calendars = new ExternalCalendarService(); + $logger = new class implements LoggerInterface { public array $entries = []; public function error(string|\Stringable $message, array $context = []): void { $this->entries[] = [(string)$message, $context]; } }; + $controller = new ExternalCalendarAdminController(new class implements IRequest {}, $session, $groups, $calendars, $logger); + + if ($controller->testCalDav('https://calendar.example.test', 'person-a', 'secret')->getStatus() !== 403 || $calendars->calls !== []) { + throw new RuntimeException('Nicht-Admins können externe CalDAV-Zugangsdaten testen.'); + } + $groups->admin = true; + $result = $controller->testCalDav('https://calendar.example.test', 'person-a', 'secret'); + if ($result->getStatus() !== 200 || ($result->getData()['message'] ?? '') !== 'Kopano-CalDAV-Verbindung erfolgreich geprüft (HTTP 207).' || $calendars->calls !== [['kopano', 'https://calendar.example.test', 'person-a', 'secret']] || str_contains(json_encode($result->getData()), 'secret')) { + throw new RuntimeException('Administrativer Kopano-Test ist fehlerhaft oder gibt Zugangsdaten zurück.'); + } + $calendars->blocked = true; + $blocked = $controller->testCalDav('https://calendar.example.test', 'person-a', 'secret'); + if ($blocked->getStatus() !== 400 || ($blocked->getData()['error'] ?? '') !== 'Der Kopano-Betreiber erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an dessen Administration.') { + throw new RuntimeException('Administrativer Kopano-Test erklärt HTTP 405 nicht sicher.'); + } + if (($logger->entries[0][1] ?? null) !== ['provider' => 'kopano', 'status' => 405]) { + throw new RuntimeException('Administrativer Verbindungstest protokolliert sensible Verbindungsdetails.'); + } + + echo "ExternalCalendarAdminControllerExecutionTest: OK\n"; +} diff --git a/tests/Controller/ExternalCalendarControllerExecutionTest.php b/tests/Controller/ExternalCalendarControllerExecutionTest.php index b613047..66aef52 100644 --- a/tests/Controller/ExternalCalendarControllerExecutionTest.php +++ b/tests/Controller/ExternalCalendarControllerExecutionTest.php @@ -21,13 +21,21 @@ class RedirectResponse { public function __construct(private string $url) {} pub final class CalendarAccessService { public ?\OCP\IUser $user = null; public function currentUser(): ?\OCP\IUser { return $this->user; } } final class ExternalCalendarService { public array $calls = []; + public bool $blocked = false; public function status(string $uid): array { $this->calls[] = ['status', $uid]; return ['kopano' => ['connected' => false]]; } - public function connectCalDav(string $uid, string $provider, string $serverUrl, string $username, string $password): array { $this->calls[] = ['connect', $uid, $provider]; return ['kopano' => ['connected' => true]]; } + public function connectCalDav(string $uid, string $provider, string $serverUrl, string $username, string $password): array { $this->calls[] = ['connect', $uid, $provider]; if ($this->blocked) throw new \OCA\AdCalendar\CalendarSync\ExternalCalendarConnectionException('Der Kalenderanbieter erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an die Administration des Anbieters.', 405); return ['kopano' => ['connected' => true]]; } public function disconnect(string $uid, string $provider): array { $this->calls[] = ['disconnect', $uid, $provider]; return ['kopano' => ['connected' => false]]; } public function connectGoogle(string $uid, array $tokens): array { $this->calls[] = ['google', $uid]; return ['google' => ['connected' => true]]; } } } namespace OCA\AdCalendar\CalendarSync { + final class ExternalCalendarConnectionException extends \RuntimeException { + public function userMessage(string $provider): string { + return $provider === 'kopano' && $this->getCode() === 405 + ? 'Der Kopano-Betreiber erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an dessen Administration.' + : $this->getMessage(); + } + } final class GoogleOAuthService { public array $calls = []; public function authorizationUrl(string $uid): string { $this->calls[] = ['start', $uid]; return 'https://accounts.google.test/oauth'; } @@ -51,13 +59,17 @@ public function cancel(string $uid, string $state): void { $this->calls[] = ['ca $access = new CalendarAccessService(); $calendars = new ExternalCalendarService(); $google = new GoogleOAuthService(); + $logger = new class implements LoggerInterface { + public array $entries = []; + public function error(string|\Stringable $message, array $context = []): void { $this->entries[] = [(string)$message, $context]; } + }; $controller = new ExternalCalendarController( new class implements IRequest {}, $access, $calendars, $google, new class implements IURLGenerator { public function linkToRoute(string $routeName, array $arguments = []): string { return '/apps/adcalendar/'; } }, - new class implements LoggerInterface { public function error(string|\Stringable $message, array $context = []): void {} }, + $logger, ); if ($controller->connectCalDav('kopano', 'https://mail.adberlin.org', 'person-a', 'secret')->getStatus() !== 403 || $calendars->calls !== []) { @@ -68,11 +80,20 @@ public function cancel(string $uid, string $state): void { $this->calls[] = ['ca if ($connected->getStatus() !== 200 || $calendars->calls !== [['connect', 'person-a', 'kopano']] || str_contains(json_encode($connected->getData()), 'secret')) { throw new RuntimeException('Providerverbindung ist nicht auf das angemeldete Konto begrenzt oder gibt Geheimnisse aus.'); } + $calendars->blocked = true; + $blocked = $controller->connectCalDav('kopano', 'https://calendar.example.test', 'person-a', 'secret'); + if ($blocked->getStatus() !== 400 || ($blocked->getData()['error'] ?? '') !== 'Der Kopano-Betreiber erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an dessen Administration.') { + throw new RuntimeException('Sichere HTTP-405-Diagnose erreicht den persönlichen Kopano-Connector nicht.'); + } + if (($logger->entries[0][1] ?? null) !== ['provider' => 'kopano', 'status' => 405]) { + throw new RuntimeException('Erwartbarer HTTP-405-Fehler protokolliert unnötige Konto- oder Verbindungsdetails.'); + } + $calendars->blocked = false; if ($controller->googleStart()->getData()['authorizationUrl'] !== 'https://accounts.google.test/oauth' || $google->calls !== [['start', 'person-a']]) { throw new RuntimeException('Google-Autorisierung ist nicht auf das angemeldete Konto begrenzt.'); } $redirect = $controller->googleCallback('state-a', 'code-a'); - if (!str_contains($redirect->getRedirectURL(), 'google-connected') || $google->calls[1] !== ['exchange', 'person-a', 'state-a'] || $calendars->calls[1] !== ['google', 'person-a']) { + if (!str_contains($redirect->getRedirectURL(), 'google-connected') || $google->calls[1] !== ['exchange', 'person-a', 'state-a'] || $calendars->calls[array_key_last($calendars->calls)] !== ['google', 'person-a']) { throw new RuntimeException('Google-Callback bindet Status und Verbindung nicht an die aktuelle Sitzung.'); } diff --git a/tests/Migration/BackgroundJobMigrationSmokeTest.php b/tests/Migration/BackgroundJobMigrationSmokeTest.php new file mode 100644 index 0000000..cd19402 --- /dev/null +++ b/tests/Migration/BackgroundJobMigrationSmokeTest.php @@ -0,0 +1,20 @@ +has(', + '->add(', +] as $contract) { + if (!str_contains($migration, $contract)) { + throw new RuntimeException("Hintergrundjob-Migrationsvertrag fehlt: {$contract}"); + } +} + +echo "BackgroundJobMigrationSmokeTest: OK\n"; diff --git a/tests/Service/CalDavClientTest.php b/tests/Service/CalDavClientTest.php index 360e79a..7e9001c 100644 --- a/tests/Service/CalDavClientTest.php +++ b/tests/Service/CalDavClientTest.php @@ -13,9 +13,11 @@ interface IClientService { public function newClient(): IClient; } require_once __DIR__ . '/../../lib/CalendarSync/ShiftCalendarPublisher.php'; require_once __DIR__ . '/../../lib/CalendarSync/ShiftCalendarEventSerializer.php'; require_once __DIR__ . '/../../lib/CalendarSync/ExternalCalendarUrlValidator.php'; + require_once __DIR__ . '/../../lib/CalendarSync/ExternalCalendarConnectionException.php'; require_once __DIR__ . '/../../lib/CalendarSync/CalDavClient.php'; use OCA\AdCalendar\CalendarSync\CalDavClient; + use OCA\AdCalendar\CalendarSync\ExternalCalendarConnectionException; use OCA\AdCalendar\CalendarSync\ExternalCalendarUrlValidator; use OCA\AdCalendar\CalendarSync\ShiftCalendarEventSerializer; use OCA\AdCalendar\Model\CalendarEntry; @@ -81,5 +83,46 @@ public function newClient(): IClient { return $this->client; } } } + $probeClient = new class($response(207, $calendarXml)) implements IClient { + public array $calls = []; + public function __construct(private IResponse $response) {} + public function request(string $method, string $uri, array $options = []): IResponse { $this->calls[] = [$method, $uri, $options]; return $this->response; } + public function getResponseFromThrowable(\Throwable $error): IResponse { throw $error; } + }; + $probeClients = new class($probeClient) implements IClientService { + public function __construct(private IClient $client) {} + public function newClient(): IClient { return $this->client; } + }; + $probeStatus = (new CalDavClient($probeClients, new ExternalCalendarUrlValidator(), new ShiftCalendarEventSerializer()))->probe([ + 'serverUrl' => 'https://calendar.example.test/caldav/person/', + 'username' => 'person', + 'password' => 'secret', + ]); + if ($probeStatus !== 207 || array_column($probeClient->calls, 0) !== ['PROPFIND']) { + throw new RuntimeException('Administrativer CalDAV-Test ist nicht rein lesend.'); + } + + $blockedClient = new class($response(405)) implements IClient { + public function __construct(private IResponse $response) {} + public function request(string $method, string $uri, array $options = []): IResponse { throw new RuntimeException('HTTP 405'); } + public function getResponseFromThrowable(\Throwable $error): IResponse { return $this->response; } + }; + $blockedClients = new class($blockedClient) implements IClientService { + public function __construct(private IClient $client) {} + public function newClient(): IClient { return $this->client; } + }; + try { + (new CalDavClient($blockedClients, new ExternalCalendarUrlValidator(), new ShiftCalendarEventSerializer()))->connect([ + 'serverUrl' => 'https://calendar.example.test/caldav/person/', + 'username' => 'person', + 'password' => 'secret', + ]); + throw new RuntimeException('HTTP 405 wurde nicht als blockierter CalDAV-Zugang erkannt.'); + } catch (ExternalCalendarConnectionException $error) { + if ($error->getMessage() !== 'Der Kalenderanbieter erlaubt an dieser Adresse keine CalDAV-Verbindung (HTTP 405). Bitte wende dich an die Administration des Anbieters.') { + throw new RuntimeException('HTTP-405-Diagnose ist für Nutzer*innen nicht eindeutig.'); + } + } + echo "CalDavClientTest: OK\n"; } diff --git a/tests/Service/ExternalCalendarServiceTest.php b/tests/Service/ExternalCalendarServiceTest.php index c809069..a81df6a 100644 --- a/tests/Service/ExternalCalendarServiceTest.php +++ b/tests/Service/ExternalCalendarServiceTest.php @@ -13,7 +13,7 @@ public function connection(string $uid, string $provider): ?array { return $this public function delete(string $uid, string $provider): void { unset($this->data[$uid][$provider]); } } final class ExternalCalendarUrlValidator { public function normalize(string $url): string { if (!str_starts_with($url, 'https://')) throw new \InvalidArgumentException('HTTPS erforderlich.'); return rtrim($url, '/') . '/'; } } - final class CalDavClient { public array $connected = []; public function connect(array $connection): string { $this->connected[] = $connection; return $connection['serverUrl'] . 'calendars/ad-dienste/'; } } + final class CalDavClient { public array $connected = []; public array $probed = []; public function connect(array $connection): string { $this->connected[] = $connection; return $connection['serverUrl'] . 'calendars/ad-dienste/'; } public function probe(array $connection): int { $this->probed[] = $connection; return 207; } } final class ExternalShiftCalendarPublisher { public array $replaced = []; public array $removed = []; public bool $fail = false; public function replaceProvider(string $uid, string $provider, array $shifts): void { $this->replaced[] = [$uid, $provider, $shifts]; if ($this->fail) throw new \RuntimeException('Providerfehler'); } public function removeProviderCalendar(string $uid, string $provider, array $connection): void { $this->removed[] = [$uid, $provider, $connection]; } } final class GoogleOAuthService { public bool $configured = false; public function configured(): bool { return $this->configured; } } } @@ -36,6 +36,11 @@ final class GoogleOAuthService { public bool $configured = false; public functio $publisher = new ExternalShiftCalendarPublisher(); $service = new ExternalCalendarService($store, new ExternalCalendarUrlValidator(), $dav, $publisher, $entries, new GoogleOAuthService()); + $probe = $service->testCalDavConnection('kopano', '', 'person-a', 'secret'); + if ($probe !== 207 || ($dav->probed[0]['serverUrl'] ?? '') !== 'https://mail.adberlin.org/caldav/person-a/' || $store->data !== [] || $publisher->replaced !== []) { + throw new RuntimeException('Administrativer Kopano-Test nutzt nicht den realen Pfad oder verändert gespeicherte Verbindungen.'); + } + $status = $service->connectCalDav('person-a', 'kopano', '', 'person-a', 'secret'); $saved = $store->data['person-a']['kopano'] ?? []; if (($saved['serverUrl'] ?? '') !== 'https://mail.adberlin.org/caldav/person-a/' || ($saved['password'] ?? '') !== 'secret') throw new RuntimeException('Kopano-Vorgabe oder persönlicher CalDAV-Endpunkt wurde nicht gespeichert.'); diff --git a/tests/Ui/LayoutSmokeTest.php b/tests/Ui/LayoutSmokeTest.php index 7a6e09b..1352a82 100644 --- a/tests/Ui/LayoutSmokeTest.php +++ b/tests/Ui/LayoutSmokeTest.php @@ -10,7 +10,7 @@ if ($template === false || $css === false || $info === false) throw new RuntimeException('UI-Dateien konnten nicht gelesen werden.'); foreach (['partials/settings', 'partials/entry-dialog', 'partials/meeting-dialog'] as $partial) if (!str_contains($indexTemplate, "echo \$this->inc('{$partial}')")) throw new RuntimeException("Template-Partial fehlt: {$partial}"); if (str_contains($info, '') || str_contains($info, '')) throw new RuntimeException('Standalone-Appvertrag fehlt.'); -foreach (['role="tablist"', 'id="adc-tab-calendar"', 'id="adc-tab-settings"', 'id="adc-settings-view"', 'id="adc-shift-defaults-form"', 'id="adc-calendar-sync-form"', 'id="adc-calendar-sync-enabled"', 'Der Abgleich ist standardmäßig aktiv', 'Termine und Urlaube werden nicht synchronisiert', 'Änderungen im Zielkalender werden überschrieben', 'id="adc-external-calendars-heading"', 'data-external-connect="kopano"', 'data-external-connect="google"', 'data-external-connect="apple"', 'data-external-connect="manual"', 'https://mail.adberlin.org', 'id="adc-external-calendar-dialog"', 'Zugangsdaten werden verschlüsselt', '
', 'id="adc-filter-status"', 'id="adc-save-default"', 'id="adc-reset-selection"', 'Auswahl zurücksetzen', 'id="adc-entry-dialog"', 'id="adc-recurrence-fields"', 'id="adc-recurrence-frequency"', 'id="adc-recurrence-interval"', 'id="adc-recurrence-until"', 'name="adc-recurrence-weekday"', 'id="adc-meeting-dialog"', 'id="adc-meeting-duration"', 'id="adc-meeting-title"', 'class="adc-overview"', 'class="adc-overview-header"', 'class="adc-button-icon icon-calendar-dark" aria-hidden="true"', "\\OCP\\Util::addScript('adcalendar', 'models/organization')", "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-date')", "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-state')", "\\OCP\\Util::addScript('adcalendar', 'modules/entry-workflow')", "\\OCP\\Util::addScript('adcalendar', 'modules/meeting-capabilities')", "\\OCP\\Util::addScript('adcalendar', 'components/calendar-filters')", "\\OCP\\Util::addScript('adcalendar', 'components/calendar-cell')", "\\OCP\\Util::addScript('adcalendar', 'components/entry-dialog')", "\\OCP\\Util::addScript('adcalendar', 'components/meeting-finder')", "\\OCP\\Util::addScript('adcalendar', 'components/shift-defaults')", "\\OCP\\Util::addScript('adcalendar', 'components/shift-calendar-sync')", "\\OCP\\Util::addScript('adcalendar', 'components/external-calendars')", "\\OCP\\Util::addScript('adcalendar', 'components/tab-navigation')", "\\OCP\\Util::addScript('adcalendar', 'components/week-navigation')", "\\OCP\\Util::addScript('adcalendar', 'components/week-table')"] as $contract) { +foreach (['role="tablist"', 'id="adc-tab-calendar"', 'id="adc-tab-settings"', 'id="adc-settings-view"', 'id="adc-shift-defaults-form"', 'id="adc-calendar-sync-form"', 'id="adc-calendar-sync-enabled"', 'Der Abgleich ist standardmäßig aktiv', 'Termine und Urlaube werden nicht synchronisiert', 'Änderungen im Zielkalender werden überschrieben', 'id="adc-external-calendars-heading"', 'data-external-connect="kopano"', 'data-external-connect="google"', 'data-external-connect="apple"', 'data-external-connect="manual"', 'https://mail.adberlin.org', 'Der Kopano-Betreiber muss CalDAV', 'id="adc-external-calendar-dialog"', 'Zugangsdaten werden verschlüsselt', '
', 'id="adc-filter-status"', 'id="adc-save-default"', 'id="adc-reset-selection"', 'Auswahl zurücksetzen', 'id="adc-entry-dialog"', 'id="adc-recurrence-fields"', 'id="adc-recurrence-frequency"', 'id="adc-recurrence-interval"', 'id="adc-recurrence-until"', 'name="adc-recurrence-weekday"', 'id="adc-meeting-dialog"', 'id="adc-meeting-duration"', 'id="adc-meeting-title"', 'class="adc-overview"', 'class="adc-overview-header"', 'class="adc-button-icon icon-calendar-dark" aria-hidden="true"', "\\OCP\\Util::addScript('adcalendar', 'models/organization')", "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-date')", "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-state')", "\\OCP\\Util::addScript('adcalendar', 'modules/entry-workflow')", "\\OCP\\Util::addScript('adcalendar', 'modules/meeting-capabilities')", "\\OCP\\Util::addScript('adcalendar', 'components/calendar-filters')", "\\OCP\\Util::addScript('adcalendar', 'components/calendar-cell')", "\\OCP\\Util::addScript('adcalendar', 'components/entry-dialog')", "\\OCP\\Util::addScript('adcalendar', 'components/meeting-finder')", "\\OCP\\Util::addScript('adcalendar', 'components/shift-defaults')", "\\OCP\\Util::addScript('adcalendar', 'components/shift-calendar-sync')", "\\OCP\\Util::addScript('adcalendar', 'components/external-calendars')", "\\OCP\\Util::addScript('adcalendar', 'components/tab-navigation')", "\\OCP\\Util::addScript('adcalendar', 'components/week-navigation')", "\\OCP\\Util::addScript('adcalendar', 'components/week-table')"] as $contract) { if (!str_contains($template, $contract)) throw new RuntimeException("Kompakter Filtervertrag fehlt: {$contract}"); } foreach (["\\OCP\\Util::addScript('localbase', 'api/api-client')", "\\OCP\\Util::addScript('localbase', 'models/model')", "\\OCP\\Util::addScript('localbase', 'repositories/repository')", "\\OCP\\Util::addScript('localbase', 'ui/ui')"] as $contract) { @@ -20,6 +20,7 @@ if (!str_contains($template, $contract)) throw new RuntimeException("Monatsansicht-Vertrag fehlt: {$contract}"); } if (!str_contains($template, "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-timeline')")) throw new RuntimeException('Zeitachsenmodul fehlt im Template.'); +if (!str_contains($template, "\\OCP\\Util::addScript('adcalendar', 'modules/berlin-public-holidays')")) throw new RuntimeException('Berliner Feiertagsmodul wird nicht vor der Kalendermatrix geladen.'); foreach (['data-orgsuite data-suite="ad" data-current-app="adcalendar"'] as $contract) { if (!str_contains($template, $contract)) throw new RuntimeException("Suite-Navigationsvertrag fehlt: {$contract}"); } @@ -32,7 +33,7 @@ foreach (['.adc-cell-entries { display: grid', 'repeating-linear-gradient'] as $contract) { if (!str_contains($css, $contract)) throw new RuntimeException("Zeitachsen-Layoutvertrag fehlt: {$contract}"); } -foreach (['.adc-calendar tbody th[scope="row"]', 'position: sticky', 'inset-inline-start: 0', '.adc-outside-month', '.adc-month-weeks', '.adc-week-block'] as $contract) { +foreach (['.adc-calendar tbody th[scope="row"]', '.adc-person-heading', 'position: sticky', 'inset-inline-start: 0', '.adc-weekend', '.adc-holiday', '.adc-outside-month', '.adc-month-weeks', '.adc-week-block'] as $contract) { if (!str_contains($css, $contract)) throw new RuntimeException("Fixierter Monatslayoutvertrag fehlt: {$contract}"); } echo "LayoutSmokeTest: OK\n"; diff --git a/tests/js/admin-settings-smoke.mjs b/tests/js/admin-settings-smoke.mjs index 9bd8102..c662e97 100644 --- a/tests/js/admin-settings-smoke.mjs +++ b/tests/js/admin-settings-smoke.mjs @@ -14,6 +14,12 @@ const status = element(); const remove = element(); const copy = element(); const redirect = element('https://cloud.example.test/callback'); +const calDavForm = element(); +const calDavUrl = element('https://calendar.example.test'); +const calDavUsername = element('person-a'); +const calDavPassword = element('connection-secret'); +const calDavStatus = element(); +const calDavSubmit = element(); const elements = { 'adc-google-oauth-form': form, 'adc-google-client-id': clientId, @@ -22,11 +28,22 @@ const elements = { 'adc-google-oauth-remove': remove, 'adc-google-copy-redirect': copy, 'adc-google-redirect-uri': redirect, + 'adc-kopano-test-form': calDavForm, + 'adc-kopano-test-url': calDavUrl, + 'adc-kopano-test-username': calDavUsername, + 'adc-kopano-test-password': calDavPassword, + 'adc-kopano-test-status': calDavStatus, + 'adc-kopano-test-submit': calDavSubmit, }; const calls = []; +let failCalDav = false; class ApiClient { async request(path, options) { calls.push([path, options]); + if (path.includes('/external-calendars/caldav/test')) { + if (failCalDav) throw new Error('Der Kopano-Betreiber erlaubt keine CalDAV-Verbindung.'); + return { message: 'Kopano-CalDAV-Verbindung erfolgreich geprüft (HTTP 207).' }; + } return { googleOAuth: options.method === 'DELETE' ? { configured: false, clientId: '', secretConfigured: false, redirectUri: redirect.value } : { configured: true, clientId: clientId.value, secretConfigured: true, redirectUri: redirect.value } }; @@ -47,5 +64,12 @@ await copy.listeners.click(); if (copied !== redirect.value) throw new Error('Google-Redirect-URI kann nicht kopiert werden.'); await remove.listeners.click(); if (calls[1][1].method !== 'DELETE' || !remove.disabled || clientId.value !== '') throw new Error('Google-OAuth-Konfiguration kann nicht sicher entfernt werden.'); +await calDavForm.listeners.submit({ preventDefault() {} }); +const calDavCall = calls[2]; +if (calDavCall[0] !== '/api/admin/external-calendars/caldav/test' || calDavCall[1].method !== 'POST' || JSON.parse(calDavCall[1].body).password !== 'connection-secret') throw new Error('Administrativer Kopano-Test verwendet nicht den geschützten API-Pfad.'); +if (calDavPassword.value !== '' || calDavSubmit.disabled || !calDavStatus.textContent.includes('erfolgreich geprüft')) throw new Error('Administrativer Kopano-Test behält das Passwort oder zeigt kein Ergebnis.'); +failCalDav = true; calDavPassword.value = 'retry-secret'; +await calDavForm.listeners.submit({ preventDefault() {} }); +if (calDavPassword.value !== '' || calDavSubmit.disabled || !calDavStatus.textContent.includes('Kopano-Betreiber')) throw new Error('Fehlgeschlagener Kopano-Test räumt das Passwort nicht auf oder verschweigt die Providerdiagnose.'); console.log('Admin settings smoke: OK'); diff --git a/tests/js/calendar-workflow-smoke.mjs b/tests/js/calendar-workflow-smoke.mjs index dc81333..d414f88 100644 --- a/tests/js/calendar-workflow-smoke.mjs +++ b/tests/js/calendar-workflow-smoke.mjs @@ -13,6 +13,7 @@ const shiftDefaults = readFileSync(new URL('../../js/components/shift-defaults.j const shiftCalendarSync = readFileSync(new URL('../../js/components/shift-calendar-sync.js', import.meta.url), 'utf8'); const externalCalendars = readFileSync(new URL('../../js/components/external-calendars.js', import.meta.url), 'utf8'); const dateSource = readFileSync(new URL('../../js/modules/calendar-date.js', import.meta.url), 'utf8'); +const publicHolidaysSource = readFileSync(new URL('../../js/modules/berlin-public-holidays.js', import.meta.url), 'utf8'); const timelineSource = readFileSync(new URL('../../js/modules/calendar-timeline.js', import.meta.url), 'utf8'); const stateSource = readFileSync(new URL('../../js/modules/calendar-state.js', import.meta.url), 'utf8'); const entryWorkflow = readFileSync(new URL('../../js/modules/entry-workflow.js', import.meta.url), 'utf8'); @@ -114,6 +115,14 @@ navigation.setPeriod('week'); if (navigation.state.period !== 'week' || navigation.state.monday.getDay() !== 1) { throw new Error('Umschalter stellt beim Wechsel zur Woche keinen gültigen Wochenanfang her.'); } +const navigationElement = () => ({ hidden: null, value: '', textContent: '', attributes: {}, setAttribute(name, value) { this.attributes[name] = value; } }); +const monthNavigation = Object.create(navigationContext.window.AdCalendar.components.WeekNavigation.prototype); +monthNavigation.state = { monday: new Date(2026, 6, 13), month: new Date(2026, 6, 1), period: 'month', vertical: false }; +for (const property of ['label', 'weekNumber', 'monthNumber', 'weekPicker', 'monthPicker', 'previous', 'next', 'weekButton', 'monthButton', 'toggleView', 'heading']) monthNavigation[property] = navigationElement(); +monthNavigation.render(); +if (monthNavigation.toggleView.hidden !== false || monthNavigation.toggleView.textContent !== 'Personen als Zeilen' || monthNavigation.toggleView.attributes['aria-pressed'] !== 'true') { + throw new Error('Ausrichtungsumschalter bleibt in der Monatsansicht nicht sichtbar oder verliert seinen Zustand.'); +} class FakeNode { constructor(tag = 'div') { this.tagName = tag.toUpperCase(); this.children = []; this.dataset = {}; this.className = ''; this.textContent = ''; @@ -128,8 +137,16 @@ class FakeNode { const tableDocument = { createElement: tag => new FakeNode(tag) }; const tableContext = { window: {}, document: tableDocument, Date, Number, Set, Math }; runInNewContext(dateSource, tableContext); +runInNewContext(publicHolidaysSource, tableContext); runInNewContext(timelineSource, tableContext); runInNewContext(weekTable, tableContext); +const publicHolidays = new tableContext.window.AdCalendar.modules.BerlinPublicHolidays(); +if (publicHolidays.name(new Date(2026, 4, 1)) !== 'Tag der Arbeit' + || publicHolidays.name(new Date(2026, 3, 3)) !== 'Karfreitag' + || publicHolidays.name(new Date(2028, 5, 17)) !== '75. Jahrestag des Aufstandes vom 17. Juni 1953' + || publicHolidays.name(new Date(2026, 4, 2)) !== '') { + throw new Error('Gesetzliche Berliner Feiertage werden nicht vollständig und datumsstabil bestimmt.'); +} const clusterTable = Object.create(tableContext.window.AdCalendar.components.WeekTable.prototype); clusterTable.organization = () => ({ staffRoleGroups: () => [], staffBlockLabel: 'Leitungen', @@ -165,7 +182,22 @@ const flattenNodes = node => [node, ...node.children.flatMap(flattenNodes)]; const renderedNodes = flattenNodes(monthContainer); if (monthContainer.children.length !== 5 || !monthContainer.className.includes('adc-month-weeks')) throw new Error('Monatsansicht rendert nicht alle betroffenen Wochenblöcke.'); if (!renderedNodes.some(node => node.className.includes('adc-outside-month'))) throw new Error('Randtage der Monatsansicht werden nicht gekennzeichnet.'); -if (!renderedNodes.some(node => node.tagName === 'TH' && node.scope === 'row' && node.textContent === 'Person A')) throw new Error('Monatsansicht behält Personen als Zeilen bei.'); +if (!renderedNodes.some(node => node.tagName === 'TH' && node.scope === 'col' && node.className.includes('adc-person-heading') && node.textContent === 'Person A')) throw new Error('Monatsansicht übernimmt die gewählte Ausrichtung mit Personen als Spalten nicht.'); +if (renderedNodes.some(node => node.tagName === 'TH' && node.scope === 'row' && node.textContent === 'Person A')) throw new Error('Monatsansicht erzwingt trotz Umschaltung weiterhin Personen als Zeilen.'); +if (!renderedNodes.some(node => node.tagName === 'TH' && node.scope === 'row' && node.className.includes('adc-weekend') && node.textContent.includes('Wochenende'))) throw new Error('Wochenenden werden in der Tagesbeschriftung nicht barrierefrei gekennzeichnet.'); +if (!renderedNodes.some(node => node.tagName === 'TD' && node.className.includes('adc-weekend'))) throw new Error('Wochenendspalten oder -zeilen werden in der Kalendermatrix nicht markiert.'); +monthTable.render([{ uid: 'person-a', displayName: 'Person A', roles: ['ad-Buero'], areas: ['ad-Bereich-West'] }], { + period: 'month', month: new Date(2026, 6, 1), vertical: true, selected: new Set(), + data: { entries: [], absences: [] }, + visibleRange: () => monthDate.monthRange(new Date(2026, 6, 1)), +}); +const verticalMonthNodes = flattenNodes(monthContainer); +if (!verticalMonthNodes.some(node => node.tagName === 'TH' && node.scope === 'row' && node.className.includes('adc-person-heading') && node.textContent === 'Person A')) throw new Error('Personenspalte der Monatsansicht ist nicht als fixierter Personenbezug gekennzeichnet.'); +if (!verticalMonthNodes.some(node => node.tagName === 'TH' && node.scope === 'col' && node.className.includes('adc-weekend') && node.textContent.includes('Wochenende'))) throw new Error('Wochenendspalten werden in der vertikalen Monatsausrichtung nicht barrierefrei gekennzeichnet.'); +if (!monthTable.dayLabel(new Date(2026, 4, 1), { weekday: 'long', day: '2-digit', month: '2-digit' }).includes('Tag der Arbeit') + || !monthTable.dayClasses(new Date(2026, 4, 1), null).includes('adc-holiday')) { + throw new Error('Berliner Feiertage erhalten in der Kalendermatrix keine sichtbare und textliche Kennzeichnung.'); +} for (const contract of ["params.set('people'", "params.set('roles'", "params.set('areas'", "params.set('period', 'month')", 'this.data.defaultFilters ||', 'this.data.currentUserProfile?.roles', 'if (this.selected.size) return this.selected.has(employee.uid)', 'showLeadershipStaff: this.showLeadershipStaff', 'period: this.period']) { if (!stateSource.includes(contract)) throw new Error(`Kalenderzustandsvertrag fehlt: ${contract}`); } @@ -201,7 +233,7 @@ for (const contract of ['class ShiftDefaults', 'Array.from({ length: 7 }', 'data for (const contract of ['class ShiftCalendarSync', 'this.onSave(this.input.checked)', 'status.calendarName', 'Kalender ist aktiv']) { if (!shiftCalendarSync.includes(contract)) throw new Error(`Dienstkalender-Komponentenvertrag fehlt: ${contract}`); } -for (const contract of ['class ExternalCalendars', "provider === 'google'", 'window.location.assign(response.authorizationUrl)', 'this.dialog.showModal()', 'this.repository.connectCalDav', 'this.repository.disconnectExternalCalendar', 'window.confirm(', 'this.password.value = \'\'', 'https://mail.adberlin.org']) { +for (const contract of ['class ExternalCalendars', "provider === 'google'", 'window.location.assign(response.authorizationUrl)', 'this.dialog.showModal()', 'this.repository.connectCalDav', 'this.repository.disconnectExternalCalendar', 'window.confirm(', 'this.password.value = \'\'', 'https://mail.adberlin.org', 'Der Kopano-Betreiber muss CalDAV']) { if (!externalCalendars.includes(contract)) throw new Error(`Externe-Kalender-Komponentenvertrag fehlt: ${contract}`); } const externalElements = {}; From c945ba556a3343fa464aee8c6431ca78ad9be9d6 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 26 Jul 2026 20:12:25 +0200 Subject: [PATCH 2/4] feat: consume shared holiday calendar --- .agents/skills/test-driven-change/SKILL.md | 109 +++++++++++++++ .agents/skills/work-in-nextcloud-app/SKILL.md | 12 +- AGENTS.md | 16 +-- README.md | 2 +- ROADMAP.md | 40 ------ appinfo/info.xml | 2 +- docs/architecture.md | 124 ++++++++++++++++++ js/components/week-table.js | 4 +- js/main.js | 1 + js/modules/berlin-public-holidays.js | 72 ---------- js/modules/holiday-calendar.js | 49 +++++++ lib/Controller/ApiController.php | 15 +++ templates/index.php | 2 +- .../CalendarRangeControllerExecutionTest.php | 15 ++- .../CalendarSyncControllerExecutionTest.php | 4 +- tests/Ui/LayoutSmokeTest.php | 3 +- tests/js/calendar-workflow-smoke.mjs | 19 ++- 17 files changed, 354 insertions(+), 135 deletions(-) create mode 100644 .agents/skills/test-driven-change/SKILL.md create mode 100644 docs/architecture.md delete mode 100644 js/modules/berlin-public-holidays.js create mode 100644 js/modules/holiday-calendar.js diff --git a/.agents/skills/test-driven-change/SKILL.md b/.agents/skills/test-driven-change/SKILL.md new file mode 100644 index 0000000..6c148dd --- /dev/null +++ b/.agents/skills/test-driven-change/SKILL.md @@ -0,0 +1,109 @@ +--- +name: test-driven-change +description: Deliver observable behavior changes through a verified Red–Green–Refactor cycle. Use for new features, bug fixes, domain logic, permission changes, API behavior, data changes, and contract changes; do not use automatically for documentation-only, formatting, generated-file, or mechanical changes without testable behavior. +--- + +# Deliver a test-driven change + +## Establish the proof + +Before writing the test, record: + +- the domain invariant; +- the observable target behavior; +- the defect or regression the test must detect; +- the chosen test level; +- what the test proves and what it does not prove; +- relevant negative and boundary cases. + +Choose the lowest level that proves the real contract without replacing +observable behavior with implementation detail. Use a higher integration, +contract, smoke, or end-to-end level when the lower level cannot exercise the +relevant boundary truthfully. For a bug fix, make the smallest proof a +regression test. + +## Red + +1. Write the smallest meaningful test before changing production code. +2. Run that test and record the command, exit code, and concrete failure or + assertion difference. +3. Confirm that it fails for the expected domain reason: the target behavior + is missing or wrong. +4. Do not proceed to Green when: + - the test is immediately green and is not an explicitly justified + characterization test; + - it fails only because of infrastructure, syntax, fixture, or configuration + defects; + - it does not reach the target behavior; + - it checks only a mock call although observable behavior can be checked + meaningfully. + +Repair a defective test environment without adding the target production +behavior, then repeat Red. An immediately green test can preserve known +behavior as a characterization test, but is not evidence of a TDD Red step. + +## Green + +1. Make only the smallest production-code change needed to satisfy the test. +2. Add no speculative abstraction or unrelated refactoring. +3. Run the new test again. +4. Run the relevant existing regression suite. + +## Cover risk-specific evidence + +For permission changes, verify at least: + +- unauthorized access is rejected; +- authorized access succeeds; +- a foreign or manipulated object ID causes no data change. + +For database or migration changes, verify at least: + +- fresh installation on an empty schema; +- upgrade from at least the immediately relevant prior version; +- preservation or correct migration of existing data; +- no incomplete state after failure. + +For shared libraries or contracts, verify: + +- relevant provider and consumer contract tests; +- every affected dependent app; +- backward compatibility or an explicitly documented break. + +For write operations, verify: + +- the return value or HTTP result; +- the persisted state; +- absence of unwanted side effects; +- repeated execution when idempotency is relevant. + +These checks supplement applicable repository stop gates. They do not authorize +database, permission, cross-repository, or production changes. + +## Refactor + +Refactor only after the new test and relevant regression suite are green. +Change no domain behavior. Run the new test and relevant existing tests again +after refactoring. + +## Handle non-behavioral changes and deviations + +For documentation-only, formatting, generated-file, or mechanical changes +without meaningfully testable behavior, use the suitable deterministic syntax, +contract, generation, layout, link, or structure check instead of an artificial +TDD cycle. + +State and justify every deviation from the test-driven workflow. Do not treat a +test that was never observed red as TDD evidence. + +## Report + +Include: + +- the protected invariant and chosen test level; +- the initially red test and expected failure reason; +- the minimal implementation; +- executed focused, regression, and contract tests as applicable; +- concrete commands, results, and exit codes; +- remaining untested risks; +- justified deviations. diff --git a/.agents/skills/work-in-nextcloud-app/SKILL.md b/.agents/skills/work-in-nextcloud-app/SKILL.md index c06b2d3..35e0ff6 100644 --- a/.agents/skills/work-in-nextcloud-app/SKILL.md +++ b/.agents/skills/work-in-nextcloud-app/SKILL.md @@ -52,15 +52,23 @@ Stop immediately if production systems, Git history rewriting, new production de ## Test-first and coverage -- New development and bug fixes normally follow Red – Green – Refactor. Start a bug fix with a regression test and a refactoring with characterization tests. Develop domain logic, permissions, hierarchies, conflicts, and validation test-first. +- Use the locally available sibling skill `test-driven-change` for every new feature, bug fix, domain rule, permission change, API behavior, data change, or contract change. It is the sole detailed Red–Green–Refactor workflow; this section adds only Nextcloud-app test selection and coverage requirements. - API changes cover success, validation failure, and typical Allow/Deny cases. Cross-app contracts have provider and consumer contract tests. Use integration/DDEV tests for migrations and repository behavior when unit tests cannot represent the real contract. - Develop executable UI logic test-first; additionally cover layout, accessibility, and Nextcloud integration with suitable smoke or browser checks. -- Permitted test-first entry exceptions are time-boxed exploratory spikes, purely declarative text/metadata or trivial presentation changes, and hard-to-isolate Nextcloud integration where a broader integration test is more truthful. Discard spike code or characterize it before adoption; give declarative changes appropriate syntax, contract, layout, or visibility 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. - 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. +## Persistent state and migrations + +- Before implementing a feature that changes persistent domain objects, determine the complete state model: allowed and forbidden starting states, preconditions, target state, side effects, error states, retry or repetition behavior, and relevant concurrency conflicts. +- Do not expose unrestricted generic setters for status changes governed by domain transition rules. Encapsulate allowed transitions in the domain model or one clearly responsible application service and cover positive, negative, and failure cases. +- Before a database change that can encounter existing data, document the old and new schema, transformation rules, known existing-data variants, integrity conditions, transaction boundary, resumability, and rollback limits. +- Such a database change requires at least a fresh-install test, an upgrade test from the relevant previous version with synthetic existing data, domain data- and relationship-integrity checks, handling of invalid or contradictory legacy data, and an application test on the migrated schema. +- Never modify a published migration after the fact. Correct it with a new migration. + ## DDEV, Nextcloud, and hosting safety - Prefer local PHP/Node checks and batch DDEV checks. Control the shared DDEV project only from its documented `nextcloud-dev` root when that separate Parent workspace is actually available. diff --git a/AGENTS.md b/AGENTS.md index 8cd1302..c441ef8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ Nextcloud-App-ID: adcalendar Die priorisierte Produktplanung und offene Entscheidungen stehen in `ROADMAP.md`; verbindliche Fach-, Sicherheits- und Architekturregeln bleiben in dieser Datei. +Der ausführliche geltende Ist-Vertrag steht in `docs/architecture.md`; diese +Datei hält die bei jeder Arbeit benötigten Grenzen und Prüfungen. ## Zielsetzung und Fachkontext @@ -21,7 +23,7 @@ AD Kalender uebertraegt den fachlichen Kern des bisherigen WordPress-Plugins `ad Kernprozess: - Eine Wochenansicht zeigt Mitarbeiter*innen als Zeilen und Kalendertage als Spalten. -- Eine umschaltbare Monatsansicht zeigt die betroffenen Wochenblöcke untereinander und unterstützt wie die Wochenansicht „Tage als Zeilen“ sowie „Personen als Zeilen“. Die zu den Personen gehörende erste Spalte beziehungsweise Kopfzeile bleibt beim Scrollen sichtbar. Randtage werden gedimmt; Samstag und Sonntag werden flächig und zusätzlich mit dem Text „Wochenende“ gekennzeichnet. Gesetzliche Berliner Feiertage erscheinen mit ihrem Namen als reine Anzeigeebene und verändern weder Einträge noch Verfügbarkeit oder Rechte. +- Eine umschaltbare Monatsansicht zeigt die betroffenen Wochenblöcke untereinander und unterstützt wie die Wochenansicht „Tage als Zeilen“ sowie „Personen als Zeilen“. Die zu den Personen gehörende erste Spalte beziehungsweise Kopfzeile bleibt beim Scrollen sichtbar. Randtage werden gedimmt; Samstag und Sonntag werden flächig und zusätzlich mit dem Text „Wochenende“ gekennzeichnet. Gesetzliche Feiertage der organisationsweit konfigurierten Kalenderregion werden serverseitig über den gemeinsamen read-only LocalBase-Vertrag geliefert und erscheinen mit ihrem Namen als reine Anzeigeebene. `DE`, `DE-BE` und `Europe/Berlin` bleiben Bestandsdefaults; persönliche Zeitzonen beeinflussen nur individuelle Terminanzeigen. Feiertage verändern weder Einträge noch Verfügbarkeit oder Rechte. - Dienste besitzen Mitarbeiter*in, Beginn und Ende. Der Titel bleibt bei Diensten optional. - Termine besitzen zusaetzlich einen sprechenden Titel. - Termine innerhalb eines Dienstes werden diesem Dienst in der Darstellung zugeordnet. @@ -148,9 +150,9 @@ Urlaubsansichten sind dynamisch ergänzbare Rollen-/Bereichsschnitte. Die Standa ## DDEV -Die gemeinsame Umgebung liegt unter: - - ~/projects/br-nextcloud-apps/nextcloud-dev +Die gemeinsame Umgebung wird aus dem dokumentierten Parent-Unterverzeichnis +`nextcloud-dev` gesteuert. Bei einem eigenständigen Checkout ist der lokale +DDEV-Pfad zuerst anhand der realen Umgebung zu ermitteln. Geplante Checks: @@ -171,13 +173,9 @@ Geplante Checks: - Reale Tombstone-/Urlaubsintegration in DDEV: `ddev exec -d /var/www/html/html php custom_apps/adcalendar/tests/integration/DefaultShiftVacationSmoke.php` - Selbstaufräumender persönlicher DAV-Dienstabgleich in DDEV: `ddev exec -d /var/www/html/html php custom_apps/adcalendar/tests/integration/ShiftCalendarSyncSmoke.php` -## Learnings - -### Gemeinsame Suite-Navigation +## Verbindliche Navigation und optionale Integration - Ohne aktive OrgSuite registriert AD Kalender einen eigenen Nextcloud-Hauptnavigationseintrag. Ab zwei AD-Produkten ersetzt `orgsuite` diesen durch den gemeinsamen Einstieg `AD`. - Das Template stellt den optionalen Menühost mit `data-suite="ad"` und `data-current-app="adcalendar"` bereit, lädt aber keine OrgSuite-Assets direkt. - Ohne AD Urlaub bleiben Sperrtermine der manuelle Abwesenheitsweg; fehlende optionale Provider dürfen die Wochenansicht nicht verhindern. - Fachliche Lese- und Bearbeitungsrechte bleiben ausschliesslich serverseitig im AD Kalender; Menuesichtbarkeit ist keine Berechtigung. - -- App-spezifische Kandidaten zielen auf diese Datei; app-uebergreifende Kandidaten werden dem Parent nur als unverbindlicher Vorschlag berichtet. Bewertung und Freigabe folgen dem lokalen Skill `work-in-nextcloud-app`. diff --git a/README.md b/README.md index c03adf7..a443e65 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Wochen- und monatsbasierte Dienst- und Terminplanung mit wiederkehrenden Terminen, Personensuche, Gruppenfiltern, Meetinglückensuche, Standarddienstzeiten, read-only Urlaubsmarkierungen und persönlichem Dienstexport in Nextcloud sowie externe Kalender. -Die Monatsansicht stellt alle betroffenen Kalenderwochen untereinander dar und lässt sich wie die Wochenansicht zwischen „Tage als Zeilen“ und „Personen als Zeilen“ umschalten. Die zu den Personen gehörende erste Spalte beziehungsweise Kopfzeile bleibt beim Scrollen sichtbar. Tage außerhalb des gewählten Monats sind abgedunkelt; Samstag und Sonntag werden zusätzlich als „Wochenende“ beschriftet. Gesetzliche Berliner Feiertage werden nach dem [Berliner Feiertagsgesetz](https://gesetze.berlin.de/perma?j=FeiertG_BE) berechnet, namentlich gekennzeichnet und bleiben ohne Auswirkung auf Dienste, Termine oder Rechte. Zeitraum und Ausrichtung können zusammen mit den Filtern als persönlicher Standard gespeichert werden. +Die Monatsansicht stellt alle betroffenen Kalenderwochen untereinander dar und lässt sich wie die Wochenansicht zwischen „Tage als Zeilen“ und „Personen als Zeilen“ umschalten. Die zu den Personen gehörende erste Spalte beziehungsweise Kopfzeile bleibt beim Scrollen sichtbar. Tage außerhalb des gewählten Monats sind abgedunkelt; Samstag und Sonntag werden zusätzlich als „Wochenende“ beschriftet. Gesetzliche Feiertage der organisationsweit konfigurierten Kalenderregion werden über den gemeinsamen LocalBase-Kalendervertrag geliefert, namentlich gekennzeichnet und bleiben ohne Auswirkung auf Dienste, Termine oder Rechte. Berlin bleibt Bestandsdefault. Zeitraum und Ausrichtung können zusammen mit den Filtern als persönlicher Standard gespeichert werden. ## Staging-Kompatibilität diff --git a/ROADMAP.md b/ROADMAP.md index 483097a..9a02e59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,51 +11,11 @@ Diese Datei bündelt geplante Erweiterungen und offene Produktentscheidungen. Ve - Persönliche Kopano- und manuelle CalDAV-Verbindungen mit realen Testkonten auf Staging fachlich abnehmen. - Die fachliche Abnahme der Google- und Apple-Verbindungen ist auf Mitte bis Ende August 2026 verschoben. -## Umgesetzte Synchronisationsstufe - -- Der Abgleich eigener Dienste ist standardmäßig aktiv. Jede angemeldete Person kann ihn im Einstellungs-Tab als persönliches Opt-out deaktivieren und später wieder aktivieren. -- Bei vorhandenen Diensten erzeugt der Abgleich den privaten Nextcloud-Kalender „AD Dienste“ und übernimmt den vollständigen Dienstbestand. Danach werden nur AD-Dienste erstellt, aktualisiert und gelöscht; Termine und Urlaube werden nicht übertragen. -- Berechtigte Planer*innen können Dienste weiterhin nach dem normalen Rechtevertrag ändern. Die Veröffentlichung erfolgt ausschließlich aufgrund dieser bereits erlaubten AD-Mutation im Kalender der zugeordneten Person und erweitert weder Lese- noch Schreibrechte. -- Deterministische Kalender-, Objekt- und Ereigniskennungen machen Wiederholungen idempotent und halten eine spätere bidirektionale Erweiterung offen. -- Der interne Nextcloud-DAV-Zugriff ist in einem austauschbaren Provideradapter isoliert. Fremde Objekte in „AD Dienste“ bleiben unangetastet; beim Opt-out wird der Kalender nur entfernt, wenn danach keine fremden Objekte verbleiben. -- AD Kalender bleibt auch bei einem DAV-Fehler führend. Die fachliche Änderung wird gespeichert und der Übertragungsfehler sicher protokolliert. -- Ein nicht paralleler Nextcloud-Hintergrundjob wird alle 15 Minuten erneut fällig und gleicht standardmäßig aktive Konten mit Diensten sowie ausdrücklich aktivierte Konten vollständig mit dem führenden AD-Dienstbestand ab; gespeicherte Opt-outs bleiben ausgeschlossen. Der tatsächliche Start hängt von der konfigurierten Nextcloud-Cron-Ausführung ab. Bei einem späteren bidirektionalen Ausbau bleibt dieser Lauf als ausgehender Konsistenzschritt nach Import und Konfliktauflösung erhalten. -- Der Adminbereich zeigt den letzten Lauf nur aggregiert mit Zeitpunkt sowie geprüften, erfolgreichen und fehlgeschlagenen Abgleichen. Es werden keine Konto- oder Kalenderkennungen persistiert; die gespeicherte Richtung erlaubt später getrennte Import-/Export-Aggregate. -- Im persönlichen Einstellungs-Tab können Kopano, Google, Apple und generisches CalDAV parallel verbunden werden. Kopano verwendet die änderbare Vorgabe `https://mail.adberlin.org`; Apple und manuelles CalDAV erklären die erforderlichen Zugangsdaten im Dialog. -- Externe Anbieter erhalten einen sichtbaren, app-eigenen Kalender „AD Dienste“. Ihre Kalender werden nicht als zusätzliche Ansichten in AD Calendar eingeblendet. -- CalDAV-Zugangsdaten und Google-Refresh-Tokens werden mit Nextclouds `ICrypto` verschlüsselt und als sensible persönliche Konfiguration gespeichert. Statusantworten und Logs enthalten keine Geheimnisse oder Kontokennungen. -- Google verwendet Webserver-OAuth mit einmaligem, nutzergebundenem Statuswert, Offline-Zugriff und dem auf app-erzeugte Kalender begrenzten Scope. Ohne systemweit hinterlegten OAuth-Client bleibt die persönliche Schaltfläche sichtbar, aber deaktiviert. -- Providerfehler bleiben voneinander und von der führenden AD-Mutation isoliert. Der Hintergrundlauf bezieht verbundene externe Konten auch dann ein, wenn ihr interner Nextcloud-Kalender deaktiviert wurde. -- Der Adminbereich enthält einen rein lesenden Kopano-CalDAV-Verbindungstest. Er verwendet denselben URL-Vertrag wie der persönliche Connector, speichert keine Testzugangsdaten und erklärt insbesondere einen vom Betreiber abgewiesenen HTTP-405-Zugriff. - -## Umgesetzte Terminserien - -- Einzeltermine und Sperrtermine können täglich, wöchentlich oder monatlich mit Intervall, ausgewählten Wochentagen und verpflichtendem Enddatum wiederholt werden. -- Serien sind auf 500 Vorkommen begrenzt und werden nach vollständiger Rechte-, Urlaubs- und Dienstzuordnungsprüfung atomar materialisiert. Die lokale Uhrzeit bleibt über Zeitumstellungen stabil; nicht vorhandene Monatstage werden ausgelassen. -- Einzelne Vorkommen können als Ausnahme oder gemeinsam mit der vollständigen Serie bearbeitet und gelöscht werden. Gemeinsame Meeting-Blöcke und Dienste bleiben außerhalb dieses Serienvertrags. -- „Dieses und folgende“ sowie eine nachträgliche Änderung des Wiederholungsmusters bleiben mögliche spätere Erweiterungen nach konkretem Fachbedarf. - -## Umgesetzte Ansichtszeiträume - -- Zwischen Wochen- und Monatsansicht kann direkt in der Kalendernavigation umgeschaltet werden; der Zeitraum ist Teil des persönlichen Standards. -- Der Wechsel „Tage als Zeilen / Personen als Zeilen“ steht in Wochen- und Monatsansicht zur Verfügung und ist bereits Teil des persönlichen Standards. -- Die Monatsansicht zeigt die betroffenen Wochenblöcke untereinander und dimmt Tage außerhalb des gewählten Monats. -- Die zu den Personen gehörende Kopfzeile beziehungsweise erste Spalte bleibt in beiden Ausrichtungen beim Scrollen sichtbar. -- Samstag und Sonntag sind in beiden Ansichten und Ausrichtungen als Wochenendflächen und zusätzlich mit dem Text „Wochenende“ gekennzeichnet. Anlegen, Bearbeiten und Löschen verwenden unverändert den bestehenden serverseitigen Rechtevertrag. -- Gesetzliche Berliner Feiertage erscheinen in beiden Ansichten und Ausrichtungen mit Namen und eigener Markierung. Die Regeln werden lokal aus § 1 Feiertagsgesetz Berlin einschließlich beweglicher Feiertage berechnet; der einmalige Feiertag am 17. Juni 2028 ist berücksichtigt. Feiertage verändern weder Dienste noch Termine, Verfügbarkeit oder Berechtigungen. -- Die Meeting-Lückensuche bleibt bewusst auf die ausgewählte Wochenansicht begrenzt. - ## Geplante Erweiterungen - Für den Produktivbetrieb ist noch festzulegen, wie fehlgeschlagene Hintergrundläufe überwacht und administrativ sichtbar gemacht werden. - Weitere Auswertungszeiträume über Woche und Monat hinaus werden nach einem konkreten Fachbedarf festgelegt. -## Festgelegte Synchronisationsleitplanken - -- AD Kalender bleibt zunächst die alleinige Quelle der Wahrheit; Dienste werden nur aus AD Kalender in angebundene Kalender übertragen. -- Die erste Ausbaustufe importiert keine Änderungen aus privaten Nextcloud- oder externen Kalendern. -- Provideradapter und stabile Zuordnungskennungen halten eine spätere bidirektionale Synchronisation offen. - ## Vor bidirektionalen Synchronisationsstufen zu klären - Konfliktauflösung, Löschungen und Zuständigkeit bei einem späteren Rückimport. diff --git a/appinfo/info.xml b/appinfo/info.xml index ed0d9f8..7571ba1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ AD Kalender Dienst- und Terminplanung für Mitarbeiter*innen im Büro. Zeigt Dienste, darin liegende Termine und externe Sperrtermine in einer gemeinsamen Wochen- oder Monatsübersicht. - 0.12.0-rc.11 + 0.13.0-rc.1 agpl Simon https://github.com/Filzmann/ad-suite diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..77fe4c7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,124 @@ +# Fach- und Integrationsarchitektur von AD Kalender + +Diese Datei dokumentiert den geltenden Ist-Vertrag. Zukünftige Ziele und +offene Entscheidungen stehen ausschließlich in `ROADMAP.md`; kurze harte +Arbeits-, Rechte- und Testregeln stehen in `AGENTS.md`. + +## Kalendereinträge und Ansichten + +AD Kalender unterscheidet Dienste und Termine in einem gemeinsamen +Kalendereintragsmodell mit explizitem Typ. Dienste besitzen Mitarbeiter*in, +Beginn und Ende; ihr Titel ist optional. Termine besitzen einen sprechenden +Titel. Termine innerhalb eines Dienstes referenzieren ihn über +`parent_entry_id`; Termine ohne Parent sind Sperrtermine. + +Die Wochenansicht und die aus Wochenblöcken zusammengesetzte Monatsansicht +unterstützen „Tage als Zeilen“ und „Personen als Zeilen“. Personenachsen +bleiben beim Scrollen sichtbar, Randtage werden gedimmt und Wochenenden +zusätzlich textlich gekennzeichnet. Gesetzliche Feiertage werden über den +gemeinsamen read-only LocalBase-Kalendervertrag geliefert und verändern weder +Einträge, Verfügbarkeit noch Rechte. + +Filter nach Personen, Rollen und Bereichen verwenden den konfigurierten +Organisationsvertrag. Rollen und Bereiche werden jeweils als ODER-Auswahl und +miteinander als Schnittmenge ausgewertet. Die im Organisationsvertrag erste +passende Rolle ist die vorrangige Kalenderrolle. Persönliche Filter-, +Zeitraum- und Ausrichtungskonfigurationen werden nur durch „Zum Standard +machen“ als Nextcloud-Benutzerwert gespeichert. + +## Standarddienste, Meetings und Serien + +Jedes Konto kann persönliche Standard-Dienstzeiten je Wochentag speichern. +Bewusst gespeicherte Defaults werden beim Wochenaufruf materialisiert. +`default_modified` schützt Einzelabweichungen; +`default_deleted` bewahrt Löschungen als datumsbezogene Tombstones. + +Die Meeting-Lückensuche schneidet Dienste ausgewählter Personen innerhalb +einer Woche und zieht Termine ab. Gemeinsame Blöcke werden nur atomar +gespeichert, geändert oder gelöscht, wenn der Akteur jeden Zielkalender nach +dem normalen Rechtevertrag bearbeiten darf. Zeitraum und Titel bleiben für +alle Beteiligten gemeinsam. + +Einzeltermine und Sperrtermine können täglich, wöchentlich oder monatlich mit +Intervall und verpflichtendem Enddatum wiederholt werden. Eine Serie enthält +mindestens zwei und höchstens 500 materialisierte Vorkommen. Die beim Anlegen +verwendete IANA-Zeitzone hält lokale Uhrzeiten über Zeitumstellungen stabil; +nicht vorhandene Monatstage werden ausgelassen. + +Serien werden vollständig vorgeprüft und atomar gespeichert. Ein blockierender +Urlaub oder eine andere serverseitige Sperre bricht die gesamte Mutation ab. +Einzelvorkommen können als Ausnahme oder gemeinsam mit der vollständigen Serie +bearbeitet und gelöscht werden. „Dieses und folgende“, Dienste und gemeinsam +verknüpfte Meetings gehören nicht zu diesem Serienvertrag. + +Bestehende Einträge wechseln ihren Typ nicht. Beim Löschen eines Dienstes wird +ausdrücklich zwischen dem gemeinsamen Löschen zugeordneter Termine und deren +Erhalt als Sperrtermine gewählt. + +## Persönlicher Kalenderabgleich + +AD Kalender ist zunächst alleinige Quelle der Wahrheit. Der standardmäßig +aktive persönliche Abgleich veröffentlicht ausschließlich Dienste der +jeweiligen Person in einem privaten Nextcloud-Kalender „AD Dienste“. Termine +und Urlaube werden nicht übertragen. Ein bewusstes Opt-out entfernt nur die +von AD Kalender erzeugten Objekte; fremde Objekte bleiben unangetastet und der +Kalender wird nur gelöscht, wenn er danach leer ist. + +Deterministische Kalender-, Objekt- und Ereigniskennungen machen Wiederholungen +idempotent. DAV-Fehler rollen führende AD-Daten nicht zurück, sondern werden +sicher protokolliert. Der nicht parallele Hintergrundjob wird alle 15 Minuten +fällig, gleicht vorhandene Dienste vollständig ab, respektiert Opt-outs und +isoliert Fehler je Konto. Sein Adminstatus enthält nur Zeitpunkt, Richtung und +aggregierte Anzahlen. + +Der interne DAV-Zugriff ist hinter `ShiftCalendarPublisher` gekapselt. Der +interne Nextcloud-DAV-Vertrag bleibt auf +`NextcloudDavShiftCalendarPublisher` begrenzt. + +## Externe Kalender + +Kopano, Google, Apple und generisches CalDAV können parallel verbunden werden. +Sie erhalten einen sichtbaren, app-eigenen Kalender „AD Dienste“ und +exportieren ebenfalls ausschließlich Dienste. Providerinhalte werden nicht in +AD Kalender eingeblendet oder zurückimportiert. + +Persönliche CalDAV-Zugangsdaten und Google-Tokens werden mit Nextclouds +Kryptodienst verschlüsselt als sensible Benutzerkonfiguration gespeichert. +Antworten und Logs enthalten keine Passwörter, Tokens, Konto- oder +Kalenderkennungen. Der Google-Web-OAuth-Client ist eine systemweite +App-Admin-Einstellung; sein Secret ist nur schreibbar, `lazy` und `sensitive`. + +Provideradapter verwenden ausschließlich den Nextcloud-HTTP-Client. +Nutzerkonfigurierte CalDAV-Adressen müssen HTTPS verwenden, auf demselben +Ursprung bleiben und unterliegen Nextclouds SSRF-Schutz. Zugangsdaten werden +nie an einen Discovery-Ursprung auf einem anderen Host weitergereicht. +Providerfehler bleiben voneinander und von der führenden AD-Mutation isoliert. + +## Organisation, Rechte und optionale Urlaube + +Alle angemeldeten Nutzer*innen dürfen Kalenderdaten lesen und eigene Einträge +bearbeiten. Fremdbearbeitung folgt ausschließlich der konfigurierten +Organisationshierarchie und freigegebenen Peergrenzen; Nextcloud-Admins dürfen +alle Einträge verwalten. Jeder API-Endpunkt erzwingt dies serverseitig über +den zentralen `CalendarAccessService`. Listen werden bereits serverseitig auf +den erlaubten Personenkreis begrenzt. + +Rollen, Bereiche, sichtbare Bezeichnungen, Reihenfolge, Peer-Fähigkeit, +Assistenzteam-Konventionen und Hierarchiekanten stammen aus +`AdOrganizationDefinition`. Fachcode führt kein paralleles Rollenregister. +Eine Änderung technischer Gruppen-IDs verschiebt keine bestehenden +Nextcloud-Mitgliedschaften. + +Ist AD Urlaub aktiv, erscheinen geplante Urlaube als read-only `U?` ohne +Blockade. Genehmigte Urlaube erscheinen als `U`, blockieren neue Dienste und +Termine, verhindern Standarddienst-Materialisierung und werden aus +Meetinglücken entfernt. Ohne AD Urlaub bleiben manuelle Sperrtermine der +gültige Standalone-Weg. + +## Demo- und Legacy-Grenzen + +WordPress-Code, Rollen, Nonces, Shortcodes, Tabellen und Bestandsdaten werden +nicht übernommen. Das Demo-Pack läuft nur nach ausdrücklicher Bestätigung, +verwendet neutrale synthetische Konten und übernimmt niemals fremde oder +LDAP-verwaltete Konten. Read-only LDAP-Gruppen brechen den Preflight vor der +ersten Mutation ab. diff --git a/js/components/week-table.js b/js/components/week-table.js index 2ef4b2a..e9ffded 100644 --- a/js/components/week-table.js +++ b/js/components/week-table.js @@ -12,9 +12,11 @@ this.calendarCell = options.calendarCell; this.organization = options.organization; this.timeline = new window.AdCalendar.modules.CalendarTimeline(); - this.holidays = options.holidays || new window.AdCalendar.modules.BerlinPublicHolidays(); + this.holidays = options.holidays || new window.AdCalendar.modules.HolidayCalendar(); } + setHolidays(calendars) { this.holidays.set(calendars); } + render(employees, state) { const orderedEmployees = this.orderedEmployees(employees); const range = state.visibleRange(); diff --git a/js/main.js b/js/main.js index b3cc0e9..4e00c9a 100644 --- a/js/main.js +++ b/js/main.js @@ -120,6 +120,7 @@ data.entries = EntryModel.get_all(data.entries); meetingCapabilities.apply(data.entries, data.employees); state.data = data; + weekTable.setHolidays(data.holidayCalendars || []); applyOrganization(data.organization); state.applyInitialFilters(); if (state.period !== requestedPeriod) { diff --git a/js/modules/berlin-public-holidays.js b/js/modules/berlin-public-holidays.js deleted file mode 100644 index e2467ad..0000000 --- a/js/modules/berlin-public-holidays.js +++ /dev/null @@ -1,72 +0,0 @@ -(function () { - 'use strict'; - - /** - * Zweck: Bestimmt die gesetzlichen Berliner Feiertage ohne Laufzeitabhängigkeit zu einem Fremddienst. - * Quelle: § 1 Feiertagsgesetz Berlin; bewegliche Feiertage werden vom Ostersonntag abgeleitet. - */ - class BerlinPublicHolidays { - constructor() { - this.cache = new Map(); - } - - name(date) { - return this.forYear(date.getFullYear()).get(this.isoDay(date)) || ''; - } - - forYear(year) { - if (this.cache.has(year)) return this.cache.get(year); - const holidays = new Map([ - [`${year}-01-01`, 'Neujahr'], - [`${year}-03-08`, 'Internationaler Frauentag'], - [`${year}-05-01`, 'Tag der Arbeit'], - [`${year}-10-03`, 'Tag der Deutschen Einheit'], - [`${year}-12-25`, '1. Weihnachtsfeiertag'], - [`${year}-12-26`, '2. Weihnachtsfeiertag'], - ]); - const easter = this.easterSunday(year); - for (const [offset, name] of [ - [-2, 'Karfreitag'], - [1, 'Ostermontag'], - [39, 'Christi Himmelfahrt'], - [50, 'Pfingstmontag'], - ]) holidays.set(this.isoDay(this.addDays(easter, offset)), name); - if (year === 2028) holidays.set('2028-06-17', '75. Jahrestag des Aufstandes vom 17. Juni 1953'); - this.cache.set(year, holidays); - return holidays; - } - - easterSunday(year) { - const a = year % 19; - const b = Math.floor(year / 100); - const c = year % 100; - const d = Math.floor(b / 4); - const e = b % 4; - const f = Math.floor((b + 8) / 25); - const g = Math.floor((b - f + 1) / 3); - const h = (19 * a + b - d - g + 15) % 30; - const i = Math.floor(c / 4); - const k = c % 4; - const l = (32 + 2 * e + 2 * i - h - k) % 7; - const m = Math.floor((a + 11 * h + 22 * l) / 451); - const month = Math.floor((h + l - 7 * m + 114) / 31); - const day = ((h + l - 7 * m + 114) % 31) + 1; - return new Date(year, month - 1, day, 12); - } - - addDays(date, offset) { - const result = new Date(date); - result.setDate(result.getDate() + offset); - return result; - } - - isoDay(date) { - const pad = value => String(value).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - } - } - - window.AdCalendar = window.AdCalendar || {}; - window.AdCalendar.modules = window.AdCalendar.modules || {}; - window.AdCalendar.modules.BerlinPublicHolidays = BerlinPublicHolidays; -}()); diff --git a/js/modules/holiday-calendar.js b/js/modules/holiday-calendar.js new file mode 100644 index 0000000..7d29995 --- /dev/null +++ b/js/modules/holiday-calendar.js @@ -0,0 +1,49 @@ +(function () { + 'use strict'; + + /** Datengetriebener read-only Lookup der vom gemeinsamen LocalBase-Vertrag gelieferten Feiertage. */ + class HolidayCalendar { + constructor(calendars = []) { + this.names = new Map(); + this.set(calendars); + } + + set(calendars) { + this.names.clear(); + for (const calendar of Array.isArray(calendars) ? calendars : []) { + for (const holiday of Array.isArray(calendar?.publicHolidays) ? calendar.publicHolidays : []) { + const name = String(holiday?.name || '').trim(); + const start = this.date(holiday?.startDate); + const end = this.date(holiday?.endDate); + if (!name || !start || !end || end < start) continue; + for (let day = start; day <= end; day = this.addDay(day)) this.names.set(day, name); + } + } + } + + name(date) { + return this.names.get(this.isoDay(date)) || ''; + } + + date(value) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return ''; + const parsed = new Date(`${value}T12:00:00Z`); + return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value ? '' : value; + } + + addDay(value) { + const date = new Date(`${value}T12:00:00Z`); + date.setUTCDate(date.getUTCDate() + 1); + return date.toISOString().slice(0, 10); + } + + isoDay(date) { + const pad = value => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } + } + + window.AdCalendar = window.AdCalendar || {}; + window.AdCalendar.modules = window.AdCalendar.modules || {}; + window.AdCalendar.modules.HolidayCalendar = HolidayCalendar; +}()); diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 4c350be..80ead80 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -13,6 +13,7 @@ use OCA\AdCalendar\Service\CalendarPreferenceService; use OCA\AdCalendar\Service\RecurringAppointmentService; use OCA\AdCalendar\Service\ShiftCalendarSyncService; +use OCA\LocalBase\Calendar\HolidayCalendarService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -30,6 +31,7 @@ public function __construct( private CalendarPreferenceService $preferences, private RecurringAppointmentService $recurrences, private ShiftCalendarSyncService $shiftSync, + private HolidayCalendarService $holidays, private LoggerInterface $logger, ) { parent::__construct(Application::APP_ID, $request); @@ -234,9 +236,22 @@ private function withViewContext(array $response, array $employees): array { $response['shiftDefaults'] = $this->preferences->shiftDefaults($uid); $response['calendarSync'] = $this->shiftSync->status($uid); $response['organization'] = $this->settingsService->organization(); + $response['holidayCalendars'] = $this->holidayCalendars($response); return $response; } + private function holidayCalendars(array $response): array { + $start = $this->date((string)($response['start'] ?? '')); + $end = $this->date((string)($response['end'] ?? '')); + if ($end <= $start) throw new InvalidArgumentException('Ungültiger Kalenderbereich.'); + $lastVisibleDay = $end->modify('-1 day'); + $calendars = []; + for ($year = (int)$start->format('Y'); $year <= (int)$lastVisibleDay->format('Y'); $year++) { + $calendars[] = $this->holidays->forYear($year)->toArray(); + } + return $calendars; + } + private function preferencesFor(array $employees): ?array { $user = $this->access->currentUser(); if ($user === null) return null; diff --git a/templates/index.php b/templates/index.php index 9721083..212fca0 100644 --- a/templates/index.php +++ b/templates/index.php @@ -7,7 +7,7 @@ \OCP\Util::addScript('adcalendar', 'models/organization'); \OCP\Util::addScript('adcalendar', 'repositories/calendar-repository'); \OCP\Util::addScript('adcalendar', 'modules/calendar-date'); -\OCP\Util::addScript('adcalendar', 'modules/berlin-public-holidays'); +\OCP\Util::addScript('adcalendar', 'modules/holiday-calendar'); \OCP\Util::addScript('adcalendar', 'modules/calendar-state'); \OCP\Util::addScript('adcalendar', 'modules/calendar-timeline'); \OCP\Util::addScript('adcalendar', 'modules/entry-workflow'); diff --git a/tests/Controller/CalendarRangeControllerExecutionTest.php b/tests/Controller/CalendarRangeControllerExecutionTest.php index 6a248f6..de43dc4 100644 --- a/tests/Controller/CalendarRangeControllerExecutionTest.php +++ b/tests/Controller/CalendarRangeControllerExecutionTest.php @@ -24,6 +24,13 @@ public function getStatus(): int { return $this->status; } } namespace Psr\Log { interface LoggerInterface { public function error(string|\Stringable $message, array $context = []): void; } } namespace OCA\AdCalendar\AppInfo { final class Application { public const APP_ID = 'adcalendar'; } } +namespace OCA\LocalBase\Calendar { + class HolidayCalendar { public function __construct(private int $year) {} public function toArray(): array { return ['year' => $this->year, 'publicHolidays' => []]; } } + class HolidayCalendarService { + public array $calls = []; + public function forYear(int $year): HolidayCalendar { $this->calls[] = $year; return new HolidayCalendar($year); } + } +} namespace OCA\AdCalendar\Service { class CalendarAccessService { public bool $view = false; @@ -58,18 +65,20 @@ class ShiftCalendarSyncService { public function status(string $uid): array { re use OCA\AdCalendar\Service\CalendarSettingsService; use OCA\AdCalendar\Service\RecurringAppointmentService; use OCA\AdCalendar\Service\ShiftCalendarSyncService; + use OCA\LocalBase\Calendar\HolidayCalendarService; use OCP\IRequest; use Psr\Log\LoggerInterface; $access = new CalendarAccessService(); $calendar = new CalendarService(); + $holidays = new HolidayCalendarService(); $logger = new class implements LoggerInterface { public array $errors = []; public function error(string|\Stringable $message, array $context = []): void { $this->errors[] = (string)$message; } }; $controller = new ApiController( new class implements IRequest {}, $access, $calendar, new CalendarSettingsService(), - new CalendarPreferenceService(), new RecurringAppointmentService(), new ShiftCalendarSyncService(), $logger, + new CalendarPreferenceService(), new RecurringAppointmentService(), new ShiftCalendarSyncService(), $holidays, $logger, ); if ($controller->range('2026-06-29', '2026-08-03')->getStatus() !== 403 || $calendar->calls !== []) { @@ -77,9 +86,11 @@ public function error(string|\Stringable $message, array $context = []): void { } $access->view = true; $response = $controller->range('2026-06-29', '2026-08-03'); - if ($response->getStatus() !== 200 || $calendar->calls !== [['2026-06-29', '2026-08-03']] || ($response->getData()['defaultFilters']['period'] ?? '') !== 'month') { + if ($response->getStatus() !== 200 || $calendar->calls !== [['2026-06-29', '2026-08-03']] || $holidays->calls !== [2026] || ($response->getData()['holidayCalendars'][0]['year'] ?? null) !== 2026 || ($response->getData()['defaultFilters']['period'] ?? '') !== 'month') { throw new RuntimeException('Berechtigte Monatsabfrage liefert nicht den vollständigen persönlichen Ansichtskontext.'); } + $controller->range('2026-12-28', '2027-01-11'); + if ($holidays->calls !== [2026, 2026, 2027]) throw new RuntimeException('Ein Kalenderbereich über den Jahreswechsel lädt nicht beide gemeinsamen Jahresstände.'); if ($controller->range('kein-datum', '2026-08-03')->getStatus() !== 400 || $logger->errors === []) { throw new RuntimeException('Ungültiger Monatsbereich wird nicht sicher behandelt und protokolliert.'); } diff --git a/tests/Controller/CalendarSyncControllerExecutionTest.php b/tests/Controller/CalendarSyncControllerExecutionTest.php index 7304156..878ff81 100644 --- a/tests/Controller/CalendarSyncControllerExecutionTest.php +++ b/tests/Controller/CalendarSyncControllerExecutionTest.php @@ -24,6 +24,7 @@ public function getStatus(): int { return $this->status; } } namespace Psr\Log { interface LoggerInterface { public function error(string|\Stringable $message, array $context = []): void; } } namespace OCA\AdCalendar\AppInfo { final class Application { public const APP_ID = 'adcalendar'; } } +namespace OCA\LocalBase\Calendar { class HolidayCalendarService {} } namespace OCA\AdCalendar\Service { class CalendarAccessService { public ?\OCP\IUser $user = null; @@ -54,6 +55,7 @@ public function configure(string $uid, bool $enabled): array { use OCA\AdCalendar\Service\CalendarSettingsService; use OCA\AdCalendar\Service\RecurringAppointmentService; use OCA\AdCalendar\Service\ShiftCalendarSyncService; + use OCA\LocalBase\Calendar\HolidayCalendarService; use OCP\IRequest; use OCP\IUser; use Psr\Log\LoggerInterface; @@ -65,7 +67,7 @@ public function configure(string $uid, bool $enabled): array { public array $errors = []; public function error(string|\Stringable $message, array $context = []): void { $this->errors[] = [(string)$message, $context]; } }; - $controller = new ApiController($request, $access, new CalendarService(), new CalendarSettingsService(), new CalendarPreferenceService(), new RecurringAppointmentService(), $sync, $logger); + $controller = new ApiController($request, $access, new CalendarService(), new CalendarSettingsService(), new CalendarPreferenceService(), new RecurringAppointmentService(), $sync, new HolidayCalendarService(), $logger); if ($controller->saveCalendarSync(true)->getStatus() !== 403) throw new RuntimeException('Nicht angemeldete Person kann die Synchronisation aktivieren.'); $access->user = new class implements IUser { public function getUID(): string { return 'sync-person'; } }; diff --git a/tests/Ui/LayoutSmokeTest.php b/tests/Ui/LayoutSmokeTest.php index 1352a82..856dbc8 100644 --- a/tests/Ui/LayoutSmokeTest.php +++ b/tests/Ui/LayoutSmokeTest.php @@ -20,7 +20,8 @@ if (!str_contains($template, $contract)) throw new RuntimeException("Monatsansicht-Vertrag fehlt: {$contract}"); } if (!str_contains($template, "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-timeline')")) throw new RuntimeException('Zeitachsenmodul fehlt im Template.'); -if (!str_contains($template, "\\OCP\\Util::addScript('adcalendar', 'modules/berlin-public-holidays')")) throw new RuntimeException('Berliner Feiertagsmodul wird nicht vor der Kalendermatrix geladen.'); +if (!str_contains($template, "\\OCP\\Util::addScript('adcalendar', 'modules/holiday-calendar')")) throw new RuntimeException('Datengetriebenes Feiertagsmodul wird nicht vor der Kalendermatrix geladen.'); +if (str_contains($template, 'berlin-public-holidays')) throw new RuntimeException('Manuelle Berliner Feiertagsberechnung wird weiterhin geladen.'); foreach (['data-orgsuite data-suite="ad" data-current-app="adcalendar"'] as $contract) { if (!str_contains($template, $contract)) throw new RuntimeException("Suite-Navigationsvertrag fehlt: {$contract}"); } diff --git a/tests/js/calendar-workflow-smoke.mjs b/tests/js/calendar-workflow-smoke.mjs index d414f88..a584deb 100644 --- a/tests/js/calendar-workflow-smoke.mjs +++ b/tests/js/calendar-workflow-smoke.mjs @@ -13,7 +13,7 @@ const shiftDefaults = readFileSync(new URL('../../js/components/shift-defaults.j const shiftCalendarSync = readFileSync(new URL('../../js/components/shift-calendar-sync.js', import.meta.url), 'utf8'); const externalCalendars = readFileSync(new URL('../../js/components/external-calendars.js', import.meta.url), 'utf8'); const dateSource = readFileSync(new URL('../../js/modules/calendar-date.js', import.meta.url), 'utf8'); -const publicHolidaysSource = readFileSync(new URL('../../js/modules/berlin-public-holidays.js', import.meta.url), 'utf8'); +const publicHolidaysSource = readFileSync(new URL('../../js/modules/holiday-calendar.js', import.meta.url), 'utf8'); const timelineSource = readFileSync(new URL('../../js/modules/calendar-timeline.js', import.meta.url), 'utf8'); const stateSource = readFileSync(new URL('../../js/modules/calendar-state.js', import.meta.url), 'utf8'); const entryWorkflow = readFileSync(new URL('../../js/modules/entry-workflow.js', import.meta.url), 'utf8'); @@ -37,6 +37,7 @@ for (const contract of [ 'repository.range(range.start, range.end)', "state.period === 'month'", 'meetingCapabilities.apply(data.entries, data.employees)', + 'weekTable.setHolidays(data.holidayCalendars || [])', "state.isUnfiltered() ? 'Alle Personen'", 'const sequence = ++loadSequence', 'if (sequence !== loadSequence) return;', @@ -140,12 +141,21 @@ runInNewContext(dateSource, tableContext); runInNewContext(publicHolidaysSource, tableContext); runInNewContext(timelineSource, tableContext); runInNewContext(weekTable, tableContext); -const publicHolidays = new tableContext.window.AdCalendar.modules.BerlinPublicHolidays(); +const publicHolidays = new tableContext.window.AdCalendar.modules.HolidayCalendar([{ + year: 2026, + publicHolidays: [ + { name: 'Karfreitag', startDate: '2026-04-03', endDate: '2026-04-03' }, + { name: 'Tag der Arbeit', startDate: '2026-05-01', endDate: '2026-05-01' }, + ], +}, { + year: 2028, + publicHolidays: [{ name: '75. Jahrestag des Aufstandes vom 17. Juni 1953', startDate: '2028-06-17', endDate: '2028-06-17' }], +}]); if (publicHolidays.name(new Date(2026, 4, 1)) !== 'Tag der Arbeit' || publicHolidays.name(new Date(2026, 3, 3)) !== 'Karfreitag' || publicHolidays.name(new Date(2028, 5, 17)) !== '75. Jahrestag des Aufstandes vom 17. Juni 1953' || publicHolidays.name(new Date(2026, 4, 2)) !== '') { - throw new Error('Gesetzliche Berliner Feiertage werden nicht vollständig und datumsstabil bestimmt.'); + throw new Error('Gemeinsame gesetzliche Feiertage werden nicht vollständig und datumsstabil gelesen.'); } const clusterTable = Object.create(tableContext.window.AdCalendar.components.WeekTable.prototype); clusterTable.organization = () => ({ @@ -172,6 +182,7 @@ const monthTable = new tableContext.window.AdCalendar.components.WeekTable({ calendarCell: { render: () => '' }, organization: clusterTable.organization, }); +monthTable.setHolidays([{ year: 2026, publicHolidays: [{ name: 'Tag der Arbeit', startDate: '2026-05-01', endDate: '2026-05-01' }] }]); const monthDate = tableContext.window.AdCalendar.modules.CalendarDate; monthTable.render([{ uid: 'person-a', displayName: 'Person A', roles: ['ad-Buero'], areas: ['ad-Bereich-West'] }], { period: 'month', month: new Date(2026, 6, 1), vertical: false, selected: new Set(), @@ -196,7 +207,7 @@ if (!verticalMonthNodes.some(node => node.tagName === 'TH' && node.scope === 'ro if (!verticalMonthNodes.some(node => node.tagName === 'TH' && node.scope === 'col' && node.className.includes('adc-weekend') && node.textContent.includes('Wochenende'))) throw new Error('Wochenendspalten werden in der vertikalen Monatsausrichtung nicht barrierefrei gekennzeichnet.'); if (!monthTable.dayLabel(new Date(2026, 4, 1), { weekday: 'long', day: '2-digit', month: '2-digit' }).includes('Tag der Arbeit') || !monthTable.dayClasses(new Date(2026, 4, 1), null).includes('adc-holiday')) { - throw new Error('Berliner Feiertage erhalten in der Kalendermatrix keine sichtbare und textliche Kennzeichnung.'); + throw new Error('Gemeinsame Feiertage erhalten in der Kalendermatrix keine sichtbare und textliche Kennzeichnung.'); } for (const contract of ["params.set('people'", "params.set('roles'", "params.set('areas'", "params.set('period', 'month')", 'this.data.defaultFilters ||', 'this.data.currentUserProfile?.roles', 'if (this.selected.size) return this.selected.has(employee.uid)', 'showLeadershipStaff: this.showLeadershipStaff', 'period: this.period']) { if (!stateSource.includes(contract)) throw new Error(`Kalenderzustandsvertrag fehlt: ${contract}`); From 1a5f819284d66c004757a10d998aec4475f35bb3 Mon Sep 17 00:00:00 2001 From: filzmann Date: Sun, 26 Jul 2026 21:30:08 +0200 Subject: [PATCH 3/4] docs: align local TDD risk checks --- .agents/skills/test-driven-change/SKILL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/test-driven-change/SKILL.md b/.agents/skills/test-driven-change/SKILL.md index 6c148dd..fff4b71 100644 --- a/.agents/skills/test-driven-change/SKILL.md +++ b/.agents/skills/test-driven-change/SKILL.md @@ -55,13 +55,17 @@ For permission changes, verify at least: - unauthorized access is rejected; - authorized access succeeds; -- a foreign or manipulated object ID causes no data change. +- a foreign or manipulated object ID grants no access and causes no data + change; +- a rejected request changes no data; +- UI visibility is never used as a substitute for server-side access control. For database or migration changes, verify at least: - fresh installation on an empty schema; - upgrade from at least the immediately relevant prior version; - preservation or correct migration of existing data; +- required constraints and indexes exist and preserve integrity; - no incomplete state after failure. For shared libraries or contracts, verify: From 4704e048ee3d1105807ff4a30c34700306548ba1 Mon Sep 17 00:00:00 2001 From: filzmann Date: Mon, 27 Jul 2026 13:44:52 +0200 Subject: [PATCH 4/4] ci: enforce measured coverage gates --- .github/workflows/tests.yml | 27 ++++++++++++++++--- tests/js/admin-settings-smoke.mjs | 3 ++- tests/js/calendar-workflow-smoke.mjs | 39 ++++++++++++++++------------ 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2170c07..fd6501a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,11 +46,24 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - coverage: none + coverage: ${{ matrix.php-version == '8.3' && 'xdebug' || 'none' }} tools: none - name: PHP-Tests + if: matrix.php-version == '8.5' working-directory: app run: php tests/run.php + - name: PHP-Coverage-Tooling installieren + if: matrix.php-version == '8.3' + run: composer install --working-dir=localbase/tests/coverage --no-interaction --no-progress --prefer-dist + - name: PHP-Coverage + if: matrix.php-version == '8.3' + working-directory: app + run: | + mkdir -p "$RUNNER_TEMP/php-coverage" + PHP_COVERAGE_COMMAND="$GITHUB_WORKSPACE/localbase/tests/coverage/vendor/bin/phpcov" \ + PHP_COVERAGE_OUTPUT_DIR="$RUNNER_TEMP/php-coverage" \ + php tests/run.php + php "$GITHUB_WORKSPACE/localbase/tests/coverage/merge-clover.php" adcalendar "$RUNNER_TEMP/php-coverage" 58.06 javascript: name: JavaScript @@ -82,6 +95,14 @@ jobs: with: node-version: 24 package-manager-cache: false - - name: JavaScript-Tests + - name: JavaScript-Coverage-Tooling installieren + run: npm ci --prefix localbase/tests/coverage --ignore-scripts + - name: JavaScript-Tests mit Coverage working-directory: app - run: node tests/run-js.mjs + run: | + ../localbase/tests/coverage/node_modules/.bin/c8 \ + --all \ + '--include=js/**/*.js' \ + --check-coverage \ + --lines=53.84 \ + node tests/run-js.mjs diff --git a/tests/js/admin-settings-smoke.mjs b/tests/js/admin-settings-smoke.mjs index c662e97..5ace0d2 100644 --- a/tests/js/admin-settings-smoke.mjs +++ b/tests/js/admin-settings-smoke.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; const source = readFileSync(new URL('../../js/admin.js', import.meta.url), 'utf8'); @@ -55,7 +56,7 @@ const context = { document: { getElementById: id => elements[id] || null }, navigator: { clipboard: { writeText: async value => { copied = value; } } }, }; -runInNewContext(source, context); +runInNewContext(source, context, { filename: fileURLToPath(new URL('../../js/admin.js', import.meta.url)) }); await form.listeners.submit({ preventDefault() {} }); if (calls[0][0] !== '/api/admin/google-oauth' || calls[0][1].method !== 'PUT' || JSON.parse(calls[0][1].body).clientSecret !== 'new-secret') throw new Error('Google-OAuth-Adminformular speichert nicht über den geschützten API-Pfad.'); diff --git a/tests/js/calendar-workflow-smoke.mjs b/tests/js/calendar-workflow-smoke.mjs index a584deb..da4cf33 100644 --- a/tests/js/calendar-workflow-smoke.mjs +++ b/tests/js/calendar-workflow-smoke.mjs @@ -1,6 +1,11 @@ import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; +const execute = (relativePath, source, context) => runInNewContext(source, context, { + filename: fileURLToPath(new URL(relativePath, import.meta.url)), +}); + const source = readFileSync(new URL('../../js/main.js', import.meta.url), 'utf8'); const repository = readFileSync(new URL('../../js/repositories/calendar-repository.js', import.meta.url), 'utf8'); const model = readFileSync(new URL('../../js/models/calendar-entry.js', import.meta.url), 'utf8'); @@ -49,7 +54,7 @@ for (const contract of ['class EntryWorkflow', "['delete', 'Dienst und Termine l if (!entryWorkflow.includes(contract)) throw new Error(`Eintragsworkflow-Vertrag fehlt: ${contract}`); } const workflowContext = { window: { confirm: () => true }, document: {}, Element: class {}, Date, Number, Promise }; -runInNewContext(entryWorkflow, workflowContext); +execute('../../js/modules/entry-workflow.js', entryWorkflow, workflowContext); const workflow = Object.create(workflowContext.window.AdCalendar.modules.EntryWorkflow.prototype); let deletionError = null; workflow.repository = { remove: async () => { throw new Error('Löschen fehlgeschlagen'); } }; @@ -58,7 +63,7 @@ workflow.reload = async () => {}; await workflow.remove({ id: 7, type: 'appointment', meetingUid: null }); if (deletionError?.message !== 'Löschen fehlgeschlagen') throw new Error('Fehler beim abschließenden Löschen wird nicht angezeigt.'); const capabilitiesContext = { window: {}, Map }; -runInNewContext(meetingCapabilities, capabilitiesContext); +execute('../../js/modules/meeting-capabilities.js', meetingCapabilities, capabilitiesContext); const capabilities = new capabilitiesContext.window.AdCalendar.modules.MeetingCapabilities(); const mixedMeeting = [{ employeeUid: 'a', meetingUid: 'meeting-1' }, { employeeUid: 'b', meetingUid: 'meeting-1' }]; capabilities.apply(mixedMeeting, [{ uid: 'a', canManage: true }, { uid: 'b', canManage: false }]); @@ -76,7 +81,7 @@ for (const contract of ['class WeekTable', 'adc-group-heading', 'adc-week-block' if (!weekTable.includes(contract)) throw new Error(`Wochenmatrix-Komponentenvertrag fehlt: ${contract}`); } const timelineContext = { window: {}, Date, Set, Math }; -runInNewContext(timelineSource, timelineContext); +execute('../../js/modules/calendar-timeline.js', timelineSource, timelineContext); const CalendarTimeline = timelineContext.window.AdCalendar.modules.CalendarTimeline; const calendarTimeline = new CalendarTimeline(); const timelineDay = new Date(2026, 6, 6); @@ -92,8 +97,8 @@ for (const contract of ['class WeekNavigation', "this.setPeriod('week')", "this. if (!weekNavigation.includes(contract)) throw new Error(`Wochennavigations-Komponentenvertrag fehlt: ${contract}`); } const navigationContext = { window: {}, document: {}, Date, Number, String }; -runInNewContext(dateSource, navigationContext); -runInNewContext(weekNavigation, navigationContext); +execute('../../js/modules/calendar-date.js', dateSource, navigationContext); +execute('../../js/components/week-navigation.js', weekNavigation, navigationContext); const navigation = Object.create(navigationContext.window.AdCalendar.components.WeekNavigation.prototype); let navigationPersisted = false; let navigationLoaded = false; navigation.state = { monday: new Date(2026, 0, 5), persist: () => { navigationPersisted = true; } }; @@ -137,10 +142,10 @@ class FakeNode { } const tableDocument = { createElement: tag => new FakeNode(tag) }; const tableContext = { window: {}, document: tableDocument, Date, Number, Set, Math }; -runInNewContext(dateSource, tableContext); -runInNewContext(publicHolidaysSource, tableContext); -runInNewContext(timelineSource, tableContext); -runInNewContext(weekTable, tableContext); +execute('../../js/modules/calendar-date.js', dateSource, tableContext); +execute('../../js/modules/holiday-calendar.js', publicHolidaysSource, tableContext); +execute('../../js/modules/calendar-timeline.js', timelineSource, tableContext); +execute('../../js/components/week-table.js', weekTable, tableContext); const publicHolidays = new tableContext.window.AdCalendar.modules.HolidayCalendar([{ year: 2026, publicHolidays: [ @@ -221,7 +226,7 @@ for (const contract of ['class Organization extends BaseModel', 'roleLabel(group if (!organizationModel.includes(contract)) throw new Error(`Organisationsmodell-Vertrag fehlt: ${contract}`); } const organizationContext = { window: { LocalBase: { models: { Model: class {} } } }, Number, String, JSON }; -runInNewContext(organizationModel, organizationContext); +execute('../../js/models/organization.js', organizationModel, organizationContext); const sortableOrganization = new organizationContext.window.AdCalendar.models.Organization({ roles: { office: { groupId: 'ad-Buero', label: 'Büro', sortOrder: 20 } }, areas: { northeast: { groupId: 'ad-Bereich-Nordost', label: 'Nordost', sortOrder: 30 } }, @@ -272,7 +277,7 @@ const externalContext = { }, Object, Promise, }; -runInNewContext(externalCalendars, externalContext); +execute('../../js/components/external-calendars.js', externalCalendars, externalContext); const externalComponent = new externalContext.window.AdCalendar.components.ExternalCalendars({ repository: externalRepository, onMessage() {} }); await externalComponent.load(); await externalComponent.connect('kopano'); @@ -290,7 +295,7 @@ const syncContext = { window: {}, document: { getElementById: id => ({ 'adc-calendar-sync-enabled': syncInput, 'adc-calendar-sync-status': syncStatus, }[id]) } }; -runInNewContext(shiftCalendarSync, syncContext); +execute('../../js/components/shift-calendar-sync.js', shiftCalendarSync, syncContext); let savedSync = null; const syncComponent = new syncContext.window.AdCalendar.components.ShiftCalendarSync({ onSave: async enabled => { savedSync = enabled; } }); syncComponent.set({ enabled: true, calendarName: 'AD Dienste' }); @@ -304,7 +309,7 @@ const componentContext = { window: { LocalBase: { ui: { esc: value => String(value ?? '') } } }, Date, }; -runInNewContext(calendarCell, componentContext); +execute('../../js/components/calendar-cell.js', calendarCell, componentContext); const cell = new componentContext.window.AdCalendar.components.CalendarCell(); const cellHtml = cell.render([ { id: 1, type: 'shift', start: '2026-07-06T08:00:00Z', end: '2026-07-06T16:00:00Z', title: '', parentEntryId: null }, @@ -326,7 +331,7 @@ const seriesHtml = cell.render([ if (!seriesHtml.includes('adc-entry__series-marker') || !seriesHtml.includes('Serientermin')) throw new Error('Serientermin wird nicht zusätzlich zur visuellen Markierung textlich gekennzeichnet.'); const tabContext = { window: {} }; -runInNewContext(tabNavigation, tabContext); +execute('../../js/components/tab-navigation.js', tabNavigation, tabContext); const fakeButton = () => ({ listeners: {}, attributes: {}, addEventListener(type, listener) { this.listeners[type] = listener; }, setAttribute(name, value) { this.attributes[name] = value; }, click() { this.listeners.click(); } }); const calendarButton = fakeButton(); const settingsButton = fakeButton(); const calendarPanel = { hidden: false }; const settingsPanel = { hidden: true }; const tabChanges = []; @@ -337,8 +342,8 @@ if (!calendarPanel.hidden || settingsPanel.hidden || settingsButton.attributes[' } const stateContext = { window: {}, Date, Set, URLSearchParams, Number }; -runInNewContext(dateSource, stateContext); -runInNewContext(stateSource, stateContext); +execute('../../js/modules/calendar-date.js', dateSource, stateContext); +execute('../../js/modules/calendar-state.js', stateSource, stateContext); const historyCalls = []; const CalendarState = stateContext.window.AdCalendar.modules.CalendarState; const CalendarDate = stateContext.window.AdCalendar.modules.CalendarDate; @@ -441,7 +446,7 @@ emptyFilterState.period = 'month'; if (emptyFilterState.toPreference().period !== 'month') throw new Error('Der Ansichtszeitraum wird nicht im persönlichen Standard gespeichert.'); const dialogContext = { window: {}, document: {}, Date }; -runInNewContext(entryDialog, dialogContext); +execute('../../js/components/entry-dialog.js', entryDialog, dialogContext); const dialog = Object.create(dialogContext.window.AdCalendar.components.EntryDialog.prototype); let startValidity = ''; let endValidity = '';