Projektportal Architektur-Audit: Schwachstellen und Deepening-Opportunities
Code-Architektur-Audit des gesamten Projektportals (PPP) — die beiden Modulverbünde
solcom_project_offer (Salesforce-/RSM-Projektangebot-Schiene) und solcom_projektpartner
(Registrierung, Profil, Login, Neos-Migration) inklusive aller Submodule. Diese Achse ergänzt die fünf
bestehenden Projektportal-Reports, die funktionale Parität und Journey-Gaps abdecken:
hier geht es um Architektur-Tiefe, Drupal-Idiomatik, Security und Test-Oberfläche. Jeder Befund ist gegen den
echten Code mit file:line belegt und durch einen adversarialen Verifikations-Pass gegangen
(real / nicht durch accepted ADR entschieden / besteht den Deletion-Test).
1. Methodik & Vertrauensgrad
Der Audit lief als orchestrierter Workflow in drei Phasen. Phase 1 (Digest) hat die fünf bestehenden
Projektportal-Reports plus die projektportal-relevanten ADRs zu einem Exclusion-Brief verdichtet (damit dieser
Audit keine bereits gefundenen oder bewusst auf Post-MVP verschobenen Gaps neu meldet) und drei gezielte
Drupal-11-Idiomatik-Recherchen gefahren (Migrate für laufende Sync, Private-File-Owner-Zugriff,
QueueWorker-Idempotenz). Phase 2 (Audit) hat zehn Analyse-Slices über das Projektportal aufgefächert —
sechs davon mit den spezialisierten Drupal-Reviewer-Personas (Security, Performance, Config, Test-Coverage,
Migration-Safety, Architektur) als Regel-Korpus. Phase 3 (Verify) hat jeden Befund einem skeptischen
Verifikations-Pass unterzogen: drei Checks gegen den echten Code — (1) ist das Problem an der
file:line real, (2) ist es durch einen accepted ADR bereits entschieden, (3) besteht der
Deletion-Test (konzentriert die vorgeschlagene Änderung Komplexität, oder verschiebt sie nur?).
high_water fast den ganzen Katalog
löschen) und einen Stil-Befund zum echten Defekt hochgestuft (CDATA-Breakout ist XML-Injection, nicht bloß
Idiomatik). Ungeprüfte Audit-Schwere ist regelmäßig falsch kalibriert — die acht verworfenen Befunde
(§7) zeigen den Filter in Aktion.
Vokabular (aus dem improve-codebase-architecture-Skill): Modul = Interface + Implementation;
Seam = Ort, an dem Verhalten geändert werden kann, ohne dort zu editieren; tief = viel Verhalten
hinter kleinem Interface; shallow = Interface fast so komplex wie Implementation; Deletion-Test =
Modul gedanklich löschen — verschwindet Komplexität (Pass-Through) oder taucht sie bei N Callern wieder auf
(verdient seinen Platz)?
2. Defekte vor Go-Live
Korrektheits-, Datenverlust- und Security-Befunde, die vor dem MVP-Go-Live (08.07.2026) adressiert gehören. Sortiert nach Schwere. Die Salesforce-Sync-Schiene trägt den schwersten Cluster — und die drei Hard-Delete-Befunde sind gekoppelt (siehe §6).
2.1 Sicherheit & Datenschutz
| # | Befund | Datei(en) | Schwere | Aufwand |
|---|---|---|---|---|
| S1 PP-PROFILE-1 ≡ SEC-03 |
Migrierte Privatdokumente bekommen keinen uid — Owner kann eigene Dokumente nicht laden.
NeosPrivateDocuments::transform() erzeugt die File-Entity nur mit uri/filename/status, ohne uid → Drupal setzt uid=0. Der Owner-only-Download-Hook fällt für uid=0 bewusst fail-closed aus. Folge: jeder aus Neos migrierte Projektpartner ist von seinen eigenen historischen Unterlagen ausgesperrt (nur Admins kommen ran). Von zwei Dimensionen unabhängig gefunden (Profil + Security) — hohe Konfidenz.
Fix: den per migration_lookup bereits aufgelösten Owner-uid beim create() der File durchreichen. Re-Import der bereits migrierten uid=0-Files nötig. |
NeosPrivateDocuments.php:96-101 · neos_profile.yml:55-92 · solcom_projektpartner.module:1094-1111 | high | S |
| S2 SEC (Lauf 1) |
JSON-LD-Block: JSON_UNESCAPED_SLASHES entfernt den eingebauten </script>-Breakout-Schutz — stored XSS.
Der JobPosting-JSON-LD jeder Projektangebot-Seite wird mit json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) serialisiert und roh als #value ins <script type="application/ld+json"> geschrieben (kein Twig-Autoescape). Ein SF-Feldwert (Titel, Branche, Beschreibung) mit </script>...</script> wird verbatim ausgeführt. Jeder mit SF-Schreibzugriff kann stored XSS auslösen.
Fix: JSON_HEX_TAG ergänzen (oder JSON_UNESCAPED_SLASHES streichen). Gleiche Flags auch in der zweiten json_encode-Stelle (:322) prüfen. Reproduktions-Test mit </script> im Titel. |
solcom_project_offer.module:47 (auch :322) | high | S |
| S3 SEC-01 |
Kein Flood-Schutz auf dem öffentlichen Alert-Subscribe-Formular — Mail-Bombing.
submitForm() legt bei jedem anonymen Request sofort eine project_offer_alert-Entity an und verschickt eine Double-Opt-In-Mail an die frei wählbare Adresse — ohne Rate-Limiting, Honeypot oder CAPTCHA. Ein Angreifer kann beliebige Drittadressen mit Bestätigungsmails bombardieren und die Alert-Tabelle mit Junk füllen.
Fix: Core- FloodInterface in validateForm()/submitForm() (Fenster pro IP und pro E-Mail), idiomatisch und ohne neue Infrastruktur. |
ProjectOfferAlertSubscribeForm.php:200-251 · *.routing.yml · user.role.anonymous.yml:18 | high | S |
| S4 SEC (Lauf 1) |
CDATA-Breakout in den XML-Vendor-Feeds — jobTitle sogar ungefiltert.
freelancermap- und freelance.de-Feeds wrappen Job-Felder in <![CDATA[ {{ p.feld|raw }} ]]>. Ein SF-Wert mit der Sequenz ]]> schließt den CDATA-Block vorzeitig → Folgeinhalt wird vom Vendor-Parser als Markup gelesen (XML-Injection). jobTitle (VendorFeedBuilder:301) läuft komplett ohne Sanitisierung; stripTagsKeepBr() macht htmlspecialchars (innerhalb CDATA semantisch falsch) und neutralisiert ]]> nicht.
Fix: ]]> in allen CDATA-Feldern inkl. jobTitle splitten (]]> → ]]]]><![CDATA[>) oder CDATA fallenlassen und Twig-XML-Autoescape nutzen. Well-formedness per DOMDocument::loadXML testen. |
solcom-feed-freelancermap.html.twig:21-24 · solcom-feed-freelance.html.twig:25-28 · VendorFeedBuilder.php:301,351 | medium | S |
| S5 SEC-04 |
PppAlert-Entity nutzt Core-Default-AccessControlHandler ohne Owner-Enforcement — latentes IDOR.
Heute kein aktuelles Risiko (Entity nur über das owner-scoped PppAlertForm erreichbar, admin_permission keiner Rolle zugewiesen). Wird latent zum IDOR, sobald ein generischer Endpoint (JSON:API, View, Route) hinzukommt.
Fix: dedizierten EntityAccessControlHandler mit Owner-checkAccess() registrieren — idiom-konform zum Schwester-Modul. |
PppAlert.php:33-35 · solcom_projektpartner.module:1042-1066 | low | S |
| S6 CONFIG-02 |
Drei fcsb-Secret-Config-Keys sind tot und ein gefährlicher Footgun.
fcsb_client_id/_secret/_refresh_token sind in Schema + settings.yml deklariert, werden aber nirgends gelesen (Secrets kommen aus dem key-Modul). Die Labels „FCSB Client Secret"/„Refresh Token" laden einen Maintainer ein, echte Geheimnisse nach config/sync/ (= Git) zu schreiben.
Fix: die drei Keys aus Schema + install + sync entfernen; nur fcsb_token_url behalten (der einzige real aus Config gelesene Wert). |
solcom_project_offer.schema.yml:5-13 · solcom_project_offer.settings.yml · SalesforceClient.php:134-138 | low | S |
2.2 Salesforce-Sync & Datenverlust
| # | Befund | Datei(en) | Schwere | Aufwand |
|---|---|---|---|---|
| S7 PO-SYNC-01 |
HardDeleteSubscriber und push-urls fragen das nicht existierende Bundle sf_publication ab — beide Operationen sind permanente No-ops.
Die Migration schreibt nach Bundle project_offer; ein node.type.sf_publication.yml existiert nicht. Die Entity-Query liefert immer null Knoten → der Reconciliation-Sweep hat seit der Bundle-Umbenennung nie einen Stale-Knoten gelöscht, und drush solcom-sf:push-urls queued nichts. Beide melden Erfolg.
Fix: 'sf_publication' → 'project_offer' an beiden Stellen — aber nur zusammen mit S8. |
HardDeleteSubscriber.php:82 · SfPublicationCommands.php:68 | high | S |
| S8 PO-SYNC-02 |
Hard-Delete-Reconciliation ist strukturell inkompatibel mit den high_water-Inkrementalläufen.
Der Sweep liest die kumulative migrate_map-Tabelle als „aktuelles SF-Set". Da high_water ohne --sync läuft, wächst die Map nur — ein SF-seitig gelöschter Record behält seine sourceid1 für immer, der in_array-Guard schlägt nie an. Stale-Knoten häufen sich unbegrenzt an. Der im Docblock behauptete Zwei-Bedingungs-Guard existiert im Code nicht.
Fix (und Achtung): NICHT gegen das „aktuelle Run-Set" vergleichen — unter high_water ist das nur eine Handvoll geänderter Records, das würde fast den ganzen Katalog löschen. Stattdessen gegen einen autoritativen Id-only-Vollpull (SELECT Id ... WHERE RecordType='Publication', kein high_water-Filter) reconcilen, plus Empty-Set-Abort. |
HardDeleteSubscriber.php:67-76 · migrate_plus.migration.solcom_project_offer.yml:95-96 | high | M |
| S9 PO-SYNC-03 |
SOQL hat kein ORDER BY LastModifiedDate ASC — high_water kann geänderte Records still überspringen.
Migrate erwartet die Quelle aufsteigend nach dem high_water-Feld sortiert. Bei ungeordneten SF-Ergebnissen kann der gespeicherte Mark unter das echte Maximum der Charge fallen; Records mit früherem Timestamp werden bei späteren Inkrementalläufen nie wieder importiert — stiller Datenverlust nach dem ersten Cron-Pull.
Fix: +ORDER+BY+LastModifiedDate+ASC an die SOQL-URL anhängen. Einzeiler. |
migrate_plus.migration.solcom_project_offer.yml:11, 95-96 | high | S |
| S10 PO-SYNC-06 |
GeocodeCity cached negative Nominatim-Ergebnisse 30 Tage — ein transienter Ausfall vergiftet das Geocoding.
Der contrib-Geocoder fängt transiente Exceptions (429, Timeout) und gibt NULL zurück; das Plugin cached dieses NULL mit derselben 30-Tage-TTL wie einen Treffer. Ein Nominatim-Ausfall während eines Bootstraps cached betroffene Städte einen Monat als „nicht geocodierbar" → field_geo bleibt leer, Standort-Filter brechen. (Das 1,1s-Rate-Limit-usleep ist korrekt, kein Defekt.)
Fix: Negativ-Resultate nicht oder nur mit kurzer TTL (z. B. 1h) cachen, damit ein transienter Ausfall beim nächsten Lauf selbst heilt. |
GeocodeCity.php:132-141 | medium | S |
| S11 PO-SYNC-04 |
SalesforceUrlWriteback wirft bei jedem HTTP-Fehler eine RuntimeException — ein permanenter 4xx wird zum ewig loopenden Poison-Item.
Core re-queued bei Exception ohne Retry-Limit/Backoff. Ein permanenter 404 (SF-Job gelöscht) oder 403 feuert jeden Cron-Lauf erneut und flutet das Watchdog-Log.
Fix: bei 4xx (permanent) loggen + return (Item acken/droppen), nur bei 5xx/Netzwerk werfen. 429 (Rate-Limit) vom Drop ausnehmen. Kein neues contrib-Modul. |
SalesforceUrlWriteback.php:98-106 | medium | S |
2.3 Alert / Suchagent — Korrektheit
| # | Befund | Datei(en) | Schwere | Aufwand |
|---|---|---|---|---|
| S12 PO-SUB-2 |
Alert-Matcher liest field_sf_utilisation (Auslastung) als Remote-Signal — „Remote ausschließen" kippt jeden SF-Offer aus dem Digest.
Das Feld ist laut Config „Auslastung" (SC_Utilisation__c, z. B. '80'), nicht Remote-Anteil. matchesExcludedLocation() behandelt jeden Offer mit Auslastung > 0 als „remote". Hakt ein Abonnent „Remote ausschließen" an, bekommt er still einen leeren Digest.
Fix: den Remote-Zweig entfernen, bis ein echtes Remote-Anteil-Feld existiert (Layer-B P2503-148); bis dahin die Checkbox als No-op behandeln oder entfernen. |
ProjectOfferAlertMatcher.php:259-262 · SubscribeForm.php:138-159 · field_sf_utilisation.yml | medium | S |
| S13 PERF-03 |
Alert-Cron-Digest: range(0,50) ohne orderBy — stille Starvation der Alerts jenseits 50.
Die Fälligkeitsprüfung läuft in PHP nach dem Range, also verbrauchen nicht-fällige Alerts Slots der 50; ohne deterministische Sortierung werden Alerts bei Wachstum > 50 nie zuverlässig zugestellt.
Fix: ->sort('last_digest_sent','ASC') (NULLs zuerst) + ->sort('id','ASC') als Tiebreaker. Additiv, kein gemeinsamer Node-Pool (der verschöbe nur Komplexität). |
solcom_project_offer_alert.module:99-104, 118 | medium | S |
| S14 PP-PROFILE-2 |
PppAlertCronWorker setzt den High-Water-Timestamp VOR dem Matching/Mailing — stiller, permanenter Verlust von Treffern.
Der Mark wird auf now gesetzt, dann erst gematcht/gemailt (Mail-Fehler werden geschluckt). Bei Mail-Fehler, mid-loop Fatal oder Timeout sind alle Treffer im Fenster dauerhaft verloren (At-most-once statt At-least-once).
Fix (mit Vorsicht): now einmal als feste Obergrenze einfrieren und an findNewMatches als Ceiling übergeben (Methode braucht einen Upper-Bound), Mark erst nach durablem Versand setzen. Der naive „Mark nach Erfolg" allein erzeugt eine mid-run-Verlust-Regression. |
PppAlertCronWorker.php:46-102 · PppAlertMatcher.php | medium | M |
| S15 PPP-PROFILE-1 |
Konto-Löschen entfernt den ppp_alert-Suchagenten nicht — persönliche Suchkriterien überleben den Account dauerhaft (DSGVO-Aufbewahrung).
ProjektpartnerAccountPurger::purge() räumt Profil, Dokumente und das Private-Verzeichnis ab, fasst den Suchagenten aber nie an; solcom_projektpartner_alert implementiert kein hook_user_predelete/_delete. Region, Skills und Auslastungsschwellen des gelöschten Partners bleiben als ppp_alert-Entity zurück — verwaiste Personendaten ohne Eigentümer.
Fix: hook_user_predelete() im Alert-Modul, das die zum uid gehörenden ppp_alert-Entities löscht (Drupal-idiomatischer Ort für eigentümergebundenes Cleanup); alternativ den Purge-Service um den Alert-Pfad erweitern. Vor dem Migrations-Cutover, da migrierte Partner sonst beim ersten Löschvorgang Datenreste hinterlassen. |
ProjektpartnerAccountPurger.php · solcom_projektpartner_alert.module | medium | S |
2.4 Config-Hygiene & Test-Treue
| # | Befund | Datei(en) | Schwere | Aufwand |
|---|---|---|---|---|
| S16 CFG-01 (≡ CFG-02) |
Die config/install-Seeds divergieren vom exportierten config/sync-Zustand — ein Fresh-Install (und jeder installConfig()-Kernel-Test) baut eine andere Konfiguration als die Produktion.
Drei Instanzen, gleiche Wurzel (Seed nie aus dem Export nachgezogen): (1) user.role.projektpartner — Sync trägt die solcom_projektpartner_alert-Dependency + das Permission 'manage own ppp alert', der Install-Seed beide nicht; auf einem Fresh-Install ohne nachfolgendes cim fehlt dem Projektpartner-Role das Alert-Recht. (2) password_policy.projektpartner — Sync hat send_reset_email/show_policy_table etc., der Seed nicht. (3) easy_email.easy_email_type.ppp_verify — Seed deklariert eine nicht-enforced dependencies.module, Sync hat dependencies: {} (Orphan-Verhalten beim Uninstall divergiert). Gefährlich ist, dass installConfig()-Kernel-Tests gegen den veralteten Seed assertieren und so genau die Permission-Lücke maskieren, die ein echter Fresh-Install hätte.
Fix: Die drei Install-Seeds aus dem aktuellen config/sync-Export neu materialisieren (oder, wo der Seed nur Minimal-Bootstrap braucht, die produktiv geltende Config bewusst dort spiegeln). Danach config:status clean halten. |
solcom_projektpartner/config/install/{user.role.projektpartner,password_policy.password_policy.projektpartner,easy_email.easy_email_type.ppp_verify}.yml vs. config/sync/… | medium | S |
3. Test-Oberfläche
Im Sinne von „das Interface IST die Test-Oberfläche": Wo wurden reine Funktionen nur zur Testbarkeit extrahiert, während die echten Bugs in der Verdrahtung sitzen — und welche kritischen, irreversiblen Seams sind ganz ungetestet? Diese Befunde sind defect-now, weil sie genau die Pfade absichern, die beim Go-Live nicht brechen dürfen.
| # | Befund | Datei(en) | Schwere | Aufwand |
|---|---|---|---|---|
| T1 TEST-2 |
HardDeleteSubscriber: Empty-Feed-Schutz und Reconciliation-Sweep des SF-Primärimports sind komplett ungetestet.
Der einzige Schutz gegen Mass-Delete des kompletten Live-Katalogs bei leerem SF-Response lebt in einem einzigen if — und kein Test übt ihn je aus. Der kritischste irreversible Pfad des Moduls.
Fix: Kernel-Test mit befüllter migrate_map: (1) Empty-Map → löscht nichts, loggt Warnung; (2) Teilmenge → nur fehlende Knoten; (3) Vollabdeckung → keine Löschung. |
HardDeleteSubscriber.php:54, 73, 90 | critical | M |
| T2 TEST-4 |
Neos-Profil-Migration: nur Source-Iterator, Process-Plugins und Config-YAML getestet — die Pipeline-Verdrahtung wird nie als Migrate-Run ausgeführt.
Der einmalige Go-Live-Cutover für alle Bestandsnutzer ist nur an den Rändern getestet. Kein Test ruft MigrateExecutable->import() für die Neos-Migration; die echten Bugs sitzen im Source→Process→Destination-Mapping (User+Profil-Erzeugung, PPP-ID, Passwort-/Skill-/File-Felder).
Fix: Kernel-Migrate-Run-Test, der die Neos-User- und -Profile-Migration end-to-end fährt und die entstehenden Entities samt field_ppp_id assertiert. |
NeosSqlSourceTest.php · NeosDoctrineArrayTest.php · NeosPrivateDocumentsTest.php · NeosMigrationConfigTest.php | high | L |
| T3 TEST-1 |
Alert-Digest-Cron testet an der Logik vorbei: hook_cron wird nie aufgerufen, der Test re-implementiert nur die Cleanup-Query inline.
Ungetestet bleiben das Frequency-Gate (daily/weekly), das last_digest_sent-Update, der Mailversand und eine continue-Stelle, die Matcher-Exceptions still überspringt (silent failure).
Fix: Kernel-Test, der solcom_project_offer_alert_cron() real aufruft (test_mail_collector), zwei Alerts mit verschiedener Frequency, assertiert wer eine Digest-Mail bekommt und dass der Mark aktualisiert wird. |
ProjectOfferAlertSubscriptionTest.php:155, 182 · solcom_project_offer_alert.module:74, 113, 123 | high | M |
| T4 TEST-3 |
SalesforceUrlWriteback-QueueWorker: Outbound-REST-Fehlerpfad und NULL-url-Clear ungetestet.
Der einzige Outbound-Schreibpfad nach Salesforce. Ungetestet: der ≥400-Wurf, der Unpublish-Clear (NULL-url) und der korrekte PATCH-Body.
Fix: Worker-Test mit gemocktem SalesforceClient (Stub existiert bereits): 200-string-url, NULL-url-Clear, 400-Wurf. |
SalesforceUrlWriteback.php:80, 98, 105 | medium | S |
| T5 TEST-5 |
Test-Surface-Inversion: die SF-Primärquelle (ADR-0048) ist nur an Fetcher-Pagination + Config-YAML getestet, während der RSM-Fallback den vollen Pipeline-Test besitzt.
Der laut ADR-0048 wertvollere Primärpfad ist schwächer abgesichert als sein Fallback. Kein Test fährt die solcom_project_offer-Migration als Migrate-Run gegen gemockte SF-Responses.
Fix: Kernel-Migrate-Run-Test mit gemockten SF-JSON-Responses (Fixtures aus SfHttpFetcherTest wiederverwendbar): Feld-Mapping, idempotenter Re-Import, Zusammenspiel mit T1. |
ProjectOfferMigrationConfigTest.php · SfHttpFetcherTest.php · RsmImportCronTest.php:134 | medium | L |
4. Deepening-Opportunities
Architektur-Refactors, die shallow Stellen vertiefen oder Locality zurückgewinnen — post-MVP, kein Go-Live-Blocker. Das sind die Kandidaten für die Grilling-Schleife: Hier lohnt es, das Ziel-Interface gemeinsam durchzudenken, bevor Code entsteht.
D1 — Watchlist: Controller↔PageBuilder duplizieren Persist-Parse + Empty-State (PO-SVC-1)
Problem: Die Persist-Mode-/IDs-CSV-Parse-Logik und das resolve()→empty-state-Muster (inkl. wörtlicher Copy der Empty-State-Meldung) existieren doppelt — einmal im Controller, einmal im PageBuilder. Ein Maintainer muss beide Dateien konsistent halten; die Locality ist gebrochen.
Seam & Deletion-Test: Die geteilten Stücke extrahieren, NICHT die beiden Builder mergen — applyAll() fügt legitim Webform-Form, Request-Attribut und Login-Choice hinzu, ein Full-Merge würde Komplexität nur verschieben. Extraktion der geteilten Geschäftsregel hinter ein Interface besteht den Deletion-Test; Full-Merge nicht.
Nutzen: Persist-Verhalten und Empty-State-Copy ändern sich an einem Ort; der Seam zwischen Controller und PageBuilder wird dicht und hinter dem Interface testbar (Voraussetzung: D2).
D2 — WatchlistPageBuilder hat keinen dedizierten Test (PO-SVC-3)
Problem: Die verzweigteste Klasse der Watchlist-Trias ist die einzige ohne eigenen Test (Storage und Resolver haben je einen). Die Trias ist test-asymmetrisch.
Interface = Test-Oberfläche: Ein Kernel-Test kann direkt auf das zurückgegebene Render-Array assertieren ($build['page']['banner'] bei stale>0, Cards bei visible≠[], apply-all-URL je Persist-Mode) — das Array ist der Interface-Rückgabewert, kein DOM nötig. Macht D1 sicher refaktorierbar.
D3 — RSM-Cron-Import dupliziert die komplette Migration (PO-SUB-1)
Problem: Drei vollständige Implementierungen desselben Imports nebeneinander — die deklarative Migration (YAML), der RsmXmlSource-SourcePlugin und der hook_cron, der den XML-Parser als Near-Verbatim-Kopie hält und jedes Feld-Mapping in PHP nachbaut. Da ADR-0048 den Cron zum primären Live-Pfad macht, ist die Parallel-Pipeline die belastete Variante; jede Feld-Änderung muss an drei Stellen gepflegt werden.
Seam & Deletion-Test: Cron über programmatisches MigrateExecutable laufen lassen — Parse-Logik bleibt im SourcePlugin, Mapping im YAML-Process-Block. Löscht man den Cron-Duplikat-Pfad, verschwindet die Komplexität ersatzlos (sie ist anderswo bereits da). Orchestrierung (Fallback-Flag-Guard, Stale-Unpublish, XML-Cleanup) bleibt im hook_cron.
Risiko: Das heutige Cron-Verhalten muss exakt erhalten bleiben (No-Revision-Save, Lookup über field_rsm_lfd_nr, Stale-Unpublish). Regression-Test gegen echten OpenPos-XML-Fixture nötig.
D4 — hook_cron lädt alle project_offer-Nodes ohne Chunking (PO-SYNC-05)
Problem: Die Status-Reconciliation lädt jeden Knoten in einen PHP-Array pro Cron-Request und setzt den Entity-Static-Cache nie zurück — Speicher wächst linear mit der Knotenzahl. Volumen-Prämisse unverifiziert: beim aktuellen Katalog evtl. nie ein Problem, daher Härtung statt akuter Defekt.
Fix: array_chunk + loadMultiple pro Chunk mit resetCache() dazwischen. Kein $sandbox (den hat hook_cron nicht — das ist Batch/Update-API).
D5 — Exposed-Form feuert drei ungecachte SELECT DISTINCT-Queries (PERF-02)
Problem: Für die drei Filter-Selects (Einsatzort, Skills, Remote-Anteil) laufen bei jedem Form-Build drei rohe Multi-JOIN-Queries außerhalb jedes Cache-Layers. Auf Page-Cache-Hit (anonym) greift das nicht — aber auf jedem Cache-Miss (eingeloggt, abgelaufen) trifft es den Listing-Pfad.
Fix: die drei Option-Listen über cache() mit node_list-/taxonomy_term_list-Cache-Tags memoizen (automatische Invalidierung).
D6 — Vier Profil-Blocks duplizieren Owner-Resolution + Portal-Zugriffsregel verbatim (PP-PROFILE-3)
Problem: Die Regel in_array('projektpartner', $account->getRoles()) || hasPermission('administer users') plus identische Cache-Contexts und die Helfer loadAccount()/loadOwnProfile() stehen Wort für Wort in allen vier Blocks. Eine Änderung der Zugriffsregel muss vier Stellen treffen.
Seam: Geteilte Regel + Helfer in eine abstrakte Basis-Block-Klasse ohne #[Block]-Attribut (von der Plugin-Discovery ignoriert) oder einen Trait heben — etabliertes Contrib-Idiom, kein Kampf gegen die Discovery. Optional die Rollen-String-Prüfung durch eine echte Permission ersetzen (konsistent zum bereits sauberen Alert-Block). Der Alert-Block nutzt eine andere Permission und bleibt bewusst separat.
D7 — PppAlertMatcher::detectCountry() klassifiziert jede unerkannte Region als 'DE' (PPP-PROFILE-4 · Alert-Modul)
Problem: Der Matcher leitet das Land eines Offers aus einem String-Muster ab und fällt für alles, was kein erkennbares AT/CH-Muster trägt, auf 'DE' zurück (Zeile 183). Ein Abonnent, der „nur DE" wünscht, bekommt damit auch Offers ohne sauberes Länder-Signal — eine Matching-Korrektheits-Kante, die heute mangels strukturiertem Länder-Feld auf dem Offer nicht sauber lösbar ist (upstream-abhängig). Bei einem deutschen Kernmarkt ist der Fehlerfall selten, daher low.
Seam: Sobald ein strukturiertes Länder-/Region-Feld am project_offer existiert (Layer-B), die heuristische String-Erkennung durch eine Feldlesung ersetzen; bis dahin den Fallback explizit als „unbekannt" statt „DE" führen, damit ein striktes DE-Abo nicht übermatcht. Kein eigenständiger Go-Live-Blocker.
5. Idiom-Verstöße
Low-Effort-Bereinigungen, die den „Drupal-Way" wiederherstellen. Opportunistisch (z. B. wenn die Datei ohnehin angefasst wird).
| # | Befund | Datei(en) | Fix |
|---|---|---|---|
| I1 PO-SVC-2 |
Publishing-Window-Prädikat in _expected_status() ist Byte-für-Byte-Reimplementierung von ProjectOfferWindowAccess::isInWindow() statt es aufzurufen. |
module:955-971 | An isInWindow($node,$now) delegieren; lokale strtotime-Kopie entfällt. (Der Views-Query-SQL ist bewusste Layer-Differenz — nicht anfassen.) |
| I2 PO-SVC-4 |
SfPublicationStatusController liest migrate_map-Status über Magic-Index $result[0]/$result[3] statt benannter Konstanten. |
SfPublicationStatusController.php:62-77 | MigrateIdMapInterface::STATUS_IMPORTED/_FAILED verwenden. |
| I3 PP-AUTH-02 |
createBlockedUser() speichert ohne Entity-Validation — Email-Uniqueness nur im Form-Layer abgesichert. Ein zweiter Caller (CLI, Batch) bräche die Invariante lautlos. |
RegistrationSubmitter.php:106 · Handler:109 | Uniqueness-Check (loadByProperties(['mail'=>…])) oder gezielter validate()-Pass vor save() im Submitter. |
| I4 PP-AUTH-03 |
Passwort-Equality-Prüfung doppelt: einmal im WebformElement-Plugin, einmal im Handler::validateForm() (liest rohe getUserInput()). |
WebformElement…PasswordConfirm.php:41 · Handler:87 | Handler-Block entfernen; Element als Single-Source-of-Truth für die Equality. |
| I5 PP-PROFILE-4 |
NeosSkillReview meldet unbekannte Skills über einen bespoke State-Key statt über die native Migrate-Message-Schiene. |
NeosSkillReview.php:76-95, 163-176 | Auf $migrate_executable->saveMessage() umstellen (sichtbar via drush migrate:messages); State-Key entfällt. |
6. Empfohlene Reihenfolge
- Security-Quick-Wins zuerst (alle S): S2 (JSON-LD-XSS), S3 (Flood), S6 (Secret-Keys). Kleine, isolierte Einzeiler/Methoden ohne Architektur-Risiko.
- Vor dem Migrations-Cutover (S1 + S15): S1 (Privatdokument-Owner inkl. Re-Import-Plan) blockiert den Self-Service-Download für alle migrierten Partner; S15 (Suchagent-Cleanup beim Konto-Löschen) verhindert, dass migrierte Partner beim ersten Löschvorgang DSGVO-Datenreste hinterlassen.
- SF-Sync-Cluster als Einheit: S9 (ORDER BY) sofort; dann S7 + S8 + T1 zusammen (Bundle-String, korrekte Reconciliation gegen autoritativen Id-Pull, Empty-Feed-Test). Niemals S7 ohne S8.
- Alert-Korrektheit: S12 (Remote-Fehlinterpretation), S13 (Starvation), S10 (Geocode-Negativ-Cache), S11 (Poison-Item) — alle S, je ein Reproduktions-Test.
- Config-/Test-Treue (S16): die drei
config/install-Seeds aus dem aktuellenconfig/sync-Export neu materialisieren — sonst prüfeninstallConfig()-Kernel-Tests gegen veraltete Config und maskieren die Permission-Lücke. Voraussetzung für verlässliche Test-Aussagen der folgenden Schritte. - Migrations-Test-Sicherheit vor Go-Live: T2 (Neos-Pipeline-Run-Test) und T3 (Alert-Cron-Test) — der Cutover und der Digest sind die irreversiblen/geschäftskritischen Pfade.
- Deepening (post-MVP): D1+D2 zusammen (Watchlist), dann D3 (RSM-Migration-Konsolidierung), D6 (Profil-Block-Basis), D4/D5 (Cron-/Query-Härtung) nach Volumen-Bestätigung; D7 (detectCountry) erst nach Einführung eines strukturierten Länder-Felds (upstream-abhängig).
7. Verworfene Befunde
Acht Audit-Verdachtsfälle haben den Verifikations-Pass nicht bestanden — aufgeführt zur Transparenz und damit künftige Audits sie nicht erneut aufwerfen.
| Audit-ID | Verdacht | Warum verworfen |
|---|---|---|
| PO-SUB-3 | Alert-Entity trägt tote Filter-Felder (PLZ/Radius) | ADR-0036 [accepted] etabliert den Präzedenzfall für Phase-2-Stub-Felder — nicht re-litigieren. |
| PP-AUTH-01 | MailManager vereint drei Verantwortlichkeiten | Deletion-Test fällt: jeder Auth-Flow hat genau einen Caller, die Komplexität ist bereits konzentriert — Split verschöbe sie nur. |
| PP-AUTH-04 | AccountPurger löscht file_usage per Raw-SQL | Befund-Prämissen gegen den echten Code falsch (keine IN-Clause; FileUsageInterface gar nicht im Konstruktor) — wie beschrieben nicht real. |
| PP-AUTH-05 | Legacy @WebformHandler-Annotations statt PHP-8-Attributes | Fix nicht umsetzbar: webform-contrib bietet keine #[WebformHandler]-Attribut-Klassen; Docblock-Annotation ist hier die einzig gültige Form. |
| SEC-02 | Alert-Token verfallen nie | created-Feld existiert; reales Risiko gering, Befund-Kern (kein Expiry-Feld) nicht hoch genug für defect-now. |
| PERF-01 | hook_cron lädt alle Nodes (als „critical" gemeldet) | Deletion-Test: die Reconciliation MUSS jeden Knoten besuchen; der Save ist geguarded, Cron läuft 3-stündlich. Als Härtung in D4 (PO-SYNC-05) überführt, nicht critical. |
| PERF-04 | DurationBucket-Filter nutzt REGEXP | Deletion-Test schwach: ein numerisches Feld verschöbe die Parse-Komplexität in die Migration; bei ~871 Zeilen kein realer Perf-Defekt. |
| CONFIG-01 | SOQL ohne ORDER BY | Real — aber als S9 / PO-SYNC-03 bereits in §2.2 geführt (Doppelfund zweier Dimensionen, dort konsolidiert). |
8. Quellen & Evidence
- Code:
web/modules/custom/solcom_project_offer/(+ Submodule_alert,_feeds,_rsm_import),web/modules/custom/solcom_projektpartner/(+solcom_projektpartner_alert/), zugehörigeconfig/sync/- undconfig/install/-Files. - Domain & Entscheidungen:
CONTEXT.md; ADR-0029/0031/0032/0034/0035/0036/0048 (SF-Sync, RSM-Fallback), ADR-0052/0053 (Profil-Datenmodell, Shared-Login). Accepted ADRs wurden im Verify-Pass als „nicht re-litigieren" behandelt. - Vorgänger-Reports (Exclusion-Brief): 2026-06-17 Parity-Audit, 2026-06-18 Code-Match-Rest-Gap, 2026-06-18 User-Journeys, 2026-06-19 Form-Webform-Parität, 2026-06-19 Neos-Parität-Zielbild. Diese decken funktionale Parität/Journey-Gaps ab; dieser Report ergänzt die Code-Architektur-Achse.
- Methode: Workflow-Audit über 10 Dimensionen, sechs mit spezialisierten Drupal-Reviewer-Personas, plus adversarialer Verifikations-Pass je Befund. Drupal-11-Idiomatik durch gezielte Recherche (Migrate-für-Sync, Private-File-Access, QueueWorker-Idempotenz) abgesichert.
Hinweis zur Vollständigkeit: Die Liste umfasst 33 verifizierte Befunde. Der Audit lief in zwei Durchläufen (Lauf 1 pausiert, Lauf 2 fortgesetzt), die je Dimension durch die stochastische Variation teils unterschiedliche Befunde zogen. Die finale Liste ist die Vereinigung beider Läufe: Über einen Titel-Diff (nicht ID — die kollidieren über Läufe hinweg) wurden die Residuen ermittelt, gegen die finale Kill-Liste geprüft und einzeln direkt gegen den Code bestätigt, bevor sie aufgenommen wurden. Betroffen waren drei Dimensionen: Security (S2 JSON-LD-XSS, S4 CDATA), Profil (S15 DSGVO-Suchagent-Retention, D7 detectCountry) und Config (S16 install↔sync-Divergenz). Bewusste Entscheidung für Vollständigkeit gegenüber Lauf-Reproduzierbarkeit.