Commit Graph

152 Commits

Author SHA1 Message Date
schalli 48d1246043 fix(10): resolve GET /source-config 404 shadowed by :id route
The admin source-config settings form failed to load with "Failed to
fetch tender-radar source config". Network trace showed
GET /modules/tender-radar/source-config returning 404.

Root cause: NestJS RouterExplorer maps routes in method-declaration
order. `@Get(':id')` was declared before `@Get('source-config')`, so
the param route captured "source-config" as an id and shadowed the
static handler (401 unauthenticated, 404 past the guard — no Tender
with id "source-config").

Fix: declare `@Get('source-config')` before `@Get(':id')`. Add a
declaration-order regression test — unit tests call controller methods
directly, bypass routing, and could never catch route shadowing.

Verified live: settings form now loads real config, interval save
persists and live-re-registers the scheduler (INGEST-06). Phase 10
verification raised human_needed -> passed after full browser UAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:52:17 +02:00
schalli eaff1d86bb test(10-05): controller spec — global read not tenant-scoped, admin config applies to scheduler
- GET / findMany where clause asserted to have no tenantId key
- GET /:id returns tender / throws NotFoundException for missing id
- PUT /source-config isActive=true+pollIntervalMin=30 -> setInterval(30) single-arg
- PUT /source-config isActive=false -> stopJob()
2026-07-21 11:18:35 +02:00
schalli 7b9b6b8c1c feat(10-05): wire TendersController — global read + admin source-config
- GET / and GET /🆔 paginated global Tender catalog, @UseModule('tender-radar')-gated, never row-scoped by tenant id
- GET/PUT /source-config: @Roles(ADMIN, SUPER_ADMIN)-guarded singleton doe-opendata config
- PUT /source-config live-applies pollIntervalMin/isActive to TenderSchedulerService (setInterval/stopJob, no tenant arg) — INGEST-06
- Registered TendersController in TendersModule.controllers
2026-07-21 11:17:13 +02:00
schalli f6e636c37d feat(10-05): add SourceConfigDto and TenderQueryDto
- SourceConfigDto: pollIntervalMin (@Min(5) @Max(1440)), isActive
- TenderQueryDto: page/limit pagination (copied from DkvHistoryQueryDto) + status filter
2026-07-21 11:15:36 +02:00
schalli 444c68b8ec test(10-04): add two-tenant safety integration test (Success Criteria 4 & 5)
Proves the phase's headline acceptance criterion: activating tender-radar
for a 2nd tenant triggers zero additional DÖE calls, zero additional cron
jobs (still exactly one 'tender-doe-poll'), and zero additional Tender rows.
Drives the real (unmocked) ModuleRegistryService against a fake prisma to
exercise the genuine activateForTenant() call path.

Passes immediately because Task 2's TenderSchedulerService already
implements the poll-once-fan-out-many invariant correctly — this test
locks in and regression-proofs that already-correct architecture rather
than driving new production code (documented in SUMMARY under TDD Gate
Compliance).
2026-07-21 11:11:09 +02:00
schalli 0ba74108db feat(10-04): implement TenderSchedulerService — single global cron (poll-once-fan-out-many)
- One named cron job 'tender-doe-poll' for the whole platform; setInterval()
  takes no tenant argument (INGEST-06) — reuses DkvSchedulerService's
  CronJob require()-resolution + SchedulerRegistry mechanics, drops the
  per-tenant activeTenantId framing entirely
- onModuleInit() loads the singleton doe-opendata config via findUnique on
  the fixed sourceType slug, never findFirst (Pitfall D)
- Day-cursor gate stays inside TenderIngestionService.pollDueSources() —
  this scheduler only controls cron-tick frequency (Pitfall A separation)
- Registered in TendersModule.providers; ScheduleModule already global via
  AppModule, no re-registration needed
2026-07-21 11:10:21 +02:00
schalli 6fb734bf3c feat(10-04): implement TenderIngestionService — day-cursor gate, upsert change-detect, D-05 retention
- pollDueSources(): singleton doe-opendata config via findUnique (fixed slug,
  not findFirst); day-cursor gate (nextDayToFetch) no-ops when nothing new
  (Pitfall A); catch-up loop from lastIngestedDay+1 to today-1 with a polite
  1.5s delay between successive day-fetches
- prisma.tender.upsert({ where: { dedupKey } }) — SCHEMA-02 change-detection
  seam: identical notice does not duplicate, changed contentHash updates in
  place
- pruneExpiredTenders(): marks active+past-deadline rows 'expired', deletes
  expired rows older than 90 days, never touches deadlineAt=null rows (D-05)
- Plain PrismaService throughout — no tenant RLS extension on the global
  Tender/TenderSourcePollConfig tables (D-03, T-10-09)
- Registered in TendersModule.providers
2026-07-21 11:08:59 +02:00
schalli f9e52ab95f test(10-04): add failing spec for TenderIngestionService (day-cursor gate, SCHEMA-02, D-05)
- Day-cursor no-op gate (Pitfall A)
- SCHEMA-02 change detection: fresh insert, identical no-dup, changed-content update
- Catch-up cursor advance across missed days
- D-05 retention: expire past-deadline, prune >90d expired, never touch null-deadline
- Multi-tenant safety: asserts no forTenant() call in the implementation
2026-07-21 11:07:49 +02:00
schalli 31607df48c feat(10-03): implement TenderNormalizerService — fields, dedupKey, hash
- normalize(raw): eForms-DE XML primary for deadlineAt/estimatedValue/
  procedureType (RESEARCH Pattern 3); OCDS primary for ocid/buyerName/
  title/cpvCodes/region/plz
- deadlineAt/estimatedValue nullable by mandate (RESEARCH Pattern 4) —
  missing data normalizes to null, never thrown or zero
- dedupKey priority: ocid -> sourcePortal:sourceNoticeId fallback
- contentHash = sha256(title+deadlineAt+estimatedValue+status), stable
  across repeat calls, changes when the deadline changes (SCHEMA-02 hook)
- Register TenderNormalizerService in TendersModule.providers
- All tender-normalizer.service.spec.ts tests green (6/6); full API
  suite green (59/59); tsc --noEmit clean
2026-07-21 10:58:33 +02:00
schalli 3764feb50b feat(10-03): implement DoeOpenDataAdapter — fetch, extract, D-02 filter
- Native fetch + AbortController 15s timeout (icon-discovery idiom, no
  axios); URL host hardcoded, only the internally-computed dayCursor is
  interpolated (T-10-06)
- HTTP 400 treated as an expected no-op (pubDay today/future) -> []
- adm-zip extraction with a pre-extraction decompression-bomb ceiling
  check (sum entry.header.size vs ~50MB) before any entry buffer is read
  (T-10-07); entries are never written to disk
- D-02 open-tender filter: positive tag.includes('tender') match only —
  award/planning/untagged-with-awards excluded (Pitfall C)
- Register DoeOpenDataAdapter in TendersModule.providers
- All doe-opendata.adapter.spec.ts tests green (6/6)
2026-07-21 10:57:29 +02:00
schalli f88e2a8141 test(10-03): add failing specs + real DÖE fixtures for adapter/normalizer
- Capture real, trimmed DÖE day-export fixtures (pubDay=2026-07-19, 8
  notices spanning tender/award/planning/untagged-with-awards tag classes)
  under tenders/__fixtures__/, live-downloaded from oeffentlichevergabe.de
- Define RawTenderRecord/NormalizedTenderFields/SourceType (tender.types.ts)
  and TenderSourceAdapter (day-cursor fetchTenders signature per RESEARCH
  Pattern 1)
- RED: doe-opendata.adapter.spec.ts asserts D-02 exact-count filtering,
  HTTP-400 no-op, and a zip-bomb ceiling guard (T-10-07)
- RED: tender-normalizer.service.spec.ts asserts eForms-primary deadline
  recovery where OCDS is null (RESEARCH Pattern 3), nullable deadline/value,
  dedupKey priority (ocid -> sourcePortal:sourceNoticeId), and a stable
  sha256 contentHash
2026-07-21 10:56:13 +02:00
schalli 9a7ed71d7a test(10-02): add seed registration coverage for tender-radar (CONFIG-01)
- asserts seedModule called once with slug 'tender-radar', isSystem: true,
  and a bilingual (de/en) description object
2026-07-21 10:43:43 +02:00
schalli e7b40946c8 feat(10-02): register TendersModule with marketplace self-seed + singleton poll config
- tenders.seed.ts seeds slug 'tender-radar' (isSystem: true, category 'procurement')
- tenders.module.ts self-seeds registry on boot (mirrors DkvModule pattern)
- upserts singleton doe-opendata TenderSourcePollConfig row (global, no forTenant())
- TendersModule added to app.module.ts imports
2026-07-21 10:42:44 +02:00
schalli 9b65ac63c3 fix(favorites): normalize scheme-less URLs so favicons resolve
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Successful in 40s
Tessera CI/CD / Build & Publish Images (push) Successful in 22s
A favorite entered as a bare host ("ctl.de") passed @IsUrl() but had no
scheme, so `new URL()` threw inside icon discovery and it silently fell
back to a relative "/favicon.ico" — which 502'd through the icon proxy
and left the widget showing the first-letter placeholder ("C").

- add normalizeUrl() (prepend https:// when no scheme present)
- apply it in discoverFavoriteIconUrl and when storing the favorite url,
  so both the link and discovery use the normalized value
- on update, re-run discovery when the icon field is cleared, so editing
  a previously-broken favorite repairs its icon
- tests: normalizeUrl cases + end-to-end discovery (apple-touch extraction,
  scheme-less fallback stays absolute)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:58:44 +02:00
schalli 9d1323fe97 feat(ldap): per-user exclude/denylist filter for sync
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 43s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m46s
Add a per-username denylist so individual accounts (service accounts like
administrator, krbtgt, guest, ldap$) can be excluded from LDAP sync,
independent of the group/OU include-filter which only scopes the search.

- schema: LdapConfig.userExcludeList String[] (+ migration)
- sync: skip excluded usernames (case-insensitive) before recording the DN,
  so an already-imported user added to the list gets deactivated next sync
- DTO / config service / controller / scheduler: thread userExcludeList through
- web: exclude-list admin UI section (add/remove/save) + de/en translations
- tests: 3 specs covering empty list, case-insensitive skip, deactivation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:34:01 +02:00
schalli 246dc89a98 fix(ldap): treat ldapts empty-array attributes as absent, not "undefined"
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 25s
ldapts represents a missing/absent LDAP attribute as an empty array
([]), not undefined -- entry['mail'] is [] when an account has no mail
set. The field-mapping loop did Array.isArray(value) ? String(value[0])
: ..., and String(undefined) is the literal string "undefined". Every
synced entry without that attribute got mappedData['email'] = "undefined"
(a truthy string, so the `|| fallback` never kicked in), and the second
such entry onward crashed with a unique constraint violation on email
since they all shared the exact same literal string.

Found live: syncing against a real Zentyal/Samba AD directory failed
on every entry after the first (Kevin Schaller, krbtgt, Guest, the DC
computer object, etc.) with "Unique constraint failed on the fields:
(email)".

Fix: resolve array values to their first element (or use the raw
value for non-arrays) and only keep it when actually present and
non-empty, so a genuinely missing attribute falls through to the
`${username}@ldap.local` fallback instead of the string "undefined".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:28:48 +02:00
schalli baff7ce4db fix(auth): make usernames case-insensitive
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 24s
Username lookups (login, admin seed, LDAP sync) compared case-sensitively
against a stored value with whatever casing it was created with, so
"Admin" and "admin" were treated as different accounts.

Normalizes at every write and read path: UserService.create/update
lowercase the username before persisting, findByUsername lowercases
the lookup input, AuthService.validateUser lowercases before the login
query, AdminSeedService lowercases the configured admin username, and
the LDAP sync loop lowercases the mapped sAMAccountName before using it
for lookup/create/update -- so AD casing differences don't create
duplicate accounts either.

Added a data migration to lowercase any existing mixed-case usernames.
It relies on the User.username unique constraint to fail loudly if two
existing accounts would collide after normalizing, rather than silently
merging them.

Verified locally: logged in with "ADMIN" (uppercase) against the
existing lowercase "admin" account after rebuilding the API image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:20:49 +02:00
schalli 010aceb1ac feat(ldap): support anonymous bind (no bind DN/password required)
Tessera CI/CD / Lint & Type Check (push) Successful in 41s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m40s
bindDn and bindPassword are now optional on LdapConfig (nullable
migration) and throughout the DTOs/service/client -- an admin can
leave both blank to connect to directories that permit anonymous
read access. LdapService.bind() falls back to an RFC 4513 anonymous
bind (empty DN + empty password) whenever either field is missing,
shared across testConnection, listGroups, and syncUsersForTenant.

Frontend: removed the required attribute from Bind-DN/Bind-Passwort,
added a placeholder hint ("leer = anonymous bind"), and the
"Verbindung testen" button now only needs a Server-URL to enable
(not bindDn+bindPassword). Config responses now return bindPassword
as null (not a misleading "********") when no password is set.

Verified locally: submitted only a Server-URL with both bind fields
empty and confirmed the request reached the anonymous-bind code path
(DNS failure for the unreachable test host, not a validation error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 12:57:26 +02:00
schalli 39aa4bff2a feat(ldap): allow testing connection before saving a config
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m35s
"Verbindung testen" only appeared after a config was already saved,
so a fresh/never-configured tenant had no way to validate connection
details at all. Now the button is always visible; POST /ldap/test-connection
accepts optional ad-hoc serverUrl/bindDn/bindPassword and falls back
field-by-field to the saved config for anything omitted (bindPassword
in particular, since the form never re-sends the masked saved password).

Verified locally: tested with typed-but-unsaved values (base DN left
blank, nothing saved) and got a real connection attempt/error back,
not a 404 "no config" response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 10:55:46 +02:00
schalli f06a2ff397 fix(favorites): use realistic browser User-Agent for icon byte-fetch
Reproducibly confirmed (3/3 vs 3/3 direct comparison inside the API
container) that chatgpt.com's Cloudflare WAF returns 403 for the
"tessera/1.0" User-Agent regardless of Accept header, and 200 for a
real Chrome UA string. Parameterized fetchWithRedirectGuard's
User-Agent (defaulting to the existing "tessera/1.0") and override it
only for fetchIconBytes -- the HTML-discovery path (discoverFavoriteIconUrl)
keeps its original User-Agent unchanged, per the no-regression constraint
on that flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:41:32 +02:00
schalli 30d6e0a5df fix(favorites): use browser-like Accept header for icon byte-fetch
A bare "image/*" Accept paired with the tessera/1.0 User-Agent tripped
Cloudflare bot mitigation on some sites -- caught live testing against
chatgpt.com/favicon.ico, which returned 403 with this combo but 200
with a realistic browser-style image Accept list. Isolated via direct
fetch comparison inside the API container before landing the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:39:02 +02:00
schalli d99253ba79 feat(favorites): GET /favorites/:id/icon proxy endpoint
Ownership-scoped (userId, matching update/remove) icon byte proxy.
Loads the row's stored iconUrl server-side and streams it through
IconDiscoveryService.fetchIconBytes -- never accepts a client-supplied
URL, so this can't become an open SSRF proxy.

Not-found/not-owned/no-icon -> 404. Upstream fetch failure (unreachable,
timeout, non-image, SSRF-blocked) -> 502, never a 200 with a placeholder.
Success sets Cache-Control so the browser doesn't refetch every load.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:34:02 +02:00
schalli 30f62682c6 feat(favorites): shared SSRF-guarded icon byte-fetch (icon-discovery)
Extracts the manual-redirect/per-hop-revalidation/timeout loop from
fetchHtml into a shared fetchWithRedirectGuard, exports isPublicHttpUrl,
and adds fetchIconBytes() -- an image-content-type-gated, 1MB-capped
byte fetch reusing the same SSRF guard as the existing HTML discovery
path. discoverFavoriteIconUrl behavior is unchanged.

Prepares the fix for favicon hotlinks breaking on sites that send
Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai) -- Tessera
will proxy the bytes through its own origin instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:32:54 +02:00
schalli a5c500dbb3 fix(ldap): remove DTO default initializers that clobbered partial updates
CreateLdapConfigDto's optional fields (searchFilter, syncIntervalMin,
isActive, groupFilterDns) had class-field default initializers.
NestJS's ValidationPipe instantiates DTOs via plainToInstance, which
applies those defaults even when the field is absent from the request
body -- so any partial PATCH not including a given field silently
reset it to the hardcoded default instead of leaving it untouched.

Caught by testing the new groupFilterDns-only PATCH: saving the group
filter alone reset searchFilter back to "(objectClass=person)",
clobbering the configured Active Directory filter. The service layer
already has its own `?? default` fallback for create, so the DTO
initializers were redundant and unsafe. Removing them makes updateConfig's
existing "only set if dto.field !== undefined" pattern behave correctly
for every optional field, not just the ones sent together in one request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:57:24 +02:00
schalli 04fc33fc2a feat(ldap): group/OU discovery endpoint + selective sync filter
Adds listGroups() to browse AD groups/OUs under base DN, and
collectSearchEntries() to restrict syncUsersForTenant to members of
selected groups or users under selected OUs. Group DNs are matched
via escaped memberOf clauses (RFC 4515); OU DNs become extra search
bases. Empty groupFilterDns keeps the original single-base-DN search
unchanged. Controller sync endpoint and the sync scheduler both pass
groupFilterDns through so manual and scheduled syncs honor it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:36:58 +02:00
schalli 3c057f863d feat(ldap): add groupFilterDns column for selective import filter
Persists per-tenant AD group/OU DNs to restrict which directory
entries get synced. Empty array (default) preserves current
behavior — import everyone under base DN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:34:27 +02:00
schalli 819d50a222 feat(cert-manager): cert role badges + ZIP download in split view
- API: detectCertRole() classifies certs as root/intermediate/end-entity
  via basicConstraints.cA + self-signed check (subject.hash === issuer.hash)
- API: SplitEntry gains certRole field; filenames now reflect role
  (root-ca.pem, intermediate-1.pem, cert.pem)
- Web: SplitTab shows colour-coded role badge per cert
  (red=Root-CA, amber=Zwischen-CA, blue=Zertifikat)
- Web: "Alle als ZIP herunterladen" button via fflate (client-side)
- i18n: add certRole labels + downloadZip action key (de + en)
- i18n: add missing accentColor* and deleteAvatar* keys (de + en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 10:40:39 +02:00
schalli be3680d0df feat(user-settings): avatar delete + accent color
- DELETE /users/me/avatar endpoint with file cleanup
- PATCH /users/me/accent-color with hex validation (#rrggbb)
- auth.service.ts: include accentColor in user select
- AccountSettingsForm: delete-avatar button + accent color picker/save/reset
- auth-actions.ts: deleteAvatarAction + updateAccentColorAction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 08:57:31 +02:00
schalli 6326064ad3 feat(09-06): GREEN — implement mergeCerts + PFX-create + convertCert pfx output
- CertManagerService.mergeCerts: parse all files via detectFormat/parsePemChain/toForgeBuffer,
  concatenate PEM chain or build PKCS12 via toPkcs12Asn1
- Open Question 1 resolved: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0
  (null private key accepted — cert-only PFX without fallback needed)
- PFX output requires non-empty password → BadRequestException if missing (T-09-02)
- All forge calls in try/catch → BadRequestException; password never logged (T-09-02)
- bytesToHex→Buffer.from(hex,'hex')→base64 for binary safety (Pitfall 1 avoidance)
- convertCert gains pfx output target (reuses same null-key toPkcs12Asn1 pattern)
- FORMAT_MIME extended with pfx: 'application/x-pkcs12'
- NotImplementedException import removed (no longer used)
- 27/27 API cert-manager tests green; tsc --noEmit exits 0
2026-07-02 07:49:42 +02:00
schalli f43e92c49f test(09-06): RED — failing mergeCerts spec (PEM chain + password-PFX round-trip)
- 4 new mergeCerts tests: PEM chain 2 blocks, PFX round-trip with password,
  missing PFX password → BadRequestException, garbage input → BadRequestException
- Controller enforces 2-file minimum; service tests use 1-2 files directly
- All 4 fail against NotImplementedException stub (RED confirmed)
- Prior 23 tests remain green
2026-07-02 07:47:28 +02:00
schalli 59694642dd feat(09-05): implement convertCert + wire POST /convert (GREEN)
- Add FileResponse interface and FORMAT_MIME map to service
- Implement convertCert: parses any input format (PEM/DER/PFX/P7B) via
  same logic as parseCert; serializes to pem/der/p7b targetFormat
- DER output uses bytesToHex→Buffer.from(hex,'hex') to avoid utf-8
  corruption (Pitfall 1 / T-09-06)
- P7B output: pkcs7.createSignedData + pem.encode (PEM-wrapped PKCS7)
- Wrap all forge ops in try/catch → BadRequestException (T-09-01)
- Controller: add @Body('pemText') + reject when neither file nor pemText
- Fix: re-add NotImplementedException import for mergeCerts stub
- All 23 API cert-manager tests green (including 4 new convertCert)
2026-07-02 07:37:41 +02:00
schalli 37db58b816 test(09-05): add failing convertCert spec (RED)
- PEM→DER round-trip identity test (re-parses DER base64 → verify CN)
- DER→PEM round-trip identity test (re-parses PEM base64 → verify CN)
- PEM→P7B: asserts mimeType + P7B contains ≥1 cert
- malformed input: expects BadRequestException
- All 4 fail against NotImplementedException stub (RED confirmed)
2026-07-02 07:35:51 +02:00
schalli 2c4ada347c feat(09-04): GREEN — implement splitCerts + SplitResponse interface
Tessera CI/CD / Tests (push) Has been cancelled
Tessera CI/CD / Build & Publish Images (push) Has been cancelled
Tessera CI/CD / Lint & Type Check (push) Has been cancelled
- Export SplitEntry + SplitResponse interfaces
- splitCerts: PEM chain path via parsePemChain; P7B path via messageFromPem (PEM) or messageFromAsn1 (DER)
- Each cert entry: index, filename cert-N.pem, content base64 PEM, subject.cn, validity.notAfter
- BadRequestException on malformed input / unsupported format (T-09-01)
- POST /split already wired in controller with 5MB file limit (T-09-03, T-09-04)
- All 19 API tests green; type-check clean
2026-07-02 07:14:22 +02:00
schalli eec66311a7 test(09-04): RED — failing splitCerts spec (fullchain PEM, P7B bundle, malformed)
- splitCerts fullchain PEM: expects count 2, two single-PEM-block certs with correct CN
- splitCerts P7B PEM bundle: expects at least one cert in result
- splitCerts malformed input: expects BadRequestException
- All three tests FAIL against NotImplementedException stub (RED confirmed)
- All 16 prior tests still pass
2026-07-02 07:12:37 +02:00
schalli ba994635e8 feat(09-03): GREEN — implement parseCert + export CertDetails interface
- Implemented CertManagerService.parseCert for PEM/DER/PFX/P7B inputs
- Exported CertDetails interface (subject, issuer, validity, san, keyType, keyBits, serialNumber, signatureAlgorithm, fingerprint, pemPreview)
- PFX with wrong password → BadRequestException (T-09-02: never logged, never echoed)
- All forge operations wrapped in try/catch → BadRequestException (T-09-01)
- buildReverseOids() converts OID → human-readable algorithm name
- P7B handles both PEM-wrapped and binary DER (RESEARCH Pitfall 4)
- Controller already wired correctly from Plan 01 (fileSize 5MB, pemText, file, password)
- All 16 cert-manager tests pass (16/16)
2026-07-01 23:41:03 +02:00
schalli 7c2e506a2e test(09-03): RED — failing parseCert spec (PEM/DER/PFX/wrong-password/malformed)
Tessera CI/CD / Tests (push) Has been cancelled
Tessera CI/CD / Build & Publish Images (push) Has been cancelled
Tessera CI/CD / Lint & Type Check (push) Has been cancelled
- Added parseCert describe block with 5 failing tests
- Covers PEM input, DER input, PFX+correct-password, PFX+wrong-password (BadRequestException), malformed input (BadRequestException)
- Existing 11 helper tests still pass
- Fixtures built via node-forge (RSA-1024, DER from asn1.toDer, PFX via toPkcs12Asn1)
2026-07-01 23:39:39 +02:00
schalli 637f4674ca merge(09-01): resolve app.module.ts conflict (CertManagerModule + FavoritesModule)
Tessera CI/CD / Lint & Type Check (push) Failing after 37s
Tessera CI/CD / Tests (push) Has been skipped
Tessera CI/CD / Build & Publish Images (push) Has been skipped
2026-07-01 23:25:48 +02:00
schalli 8bb5cf208d feat(09-01): scaffold cert-manager module + shared node-forge helpers (GREEN)
- cert-manager.module.ts: OnModuleInit + seedCertManagerModule (CERT-06)
- cert-manager.seed.ts: slug='cert-manager', category='security-tools', isSystem=true
- cert-manager.service.ts: detectFormat, toForgeBuffer, getFingerprint, parsePemChain;
  operation stubs parseCert/splitCerts/mergeCerts/convertCert throw NotImplementedException
- cert-manager.controller.ts: 4 POST routes with FileInterceptor/FilesInterceptor
  (5 MB limit each), @UseModule('cert-manager') guard, BadRequestException on missing input
- dto/: ParseCertDto, MergeCertsDto, ConvertCertDto
- app.module.ts: CertManagerModule added to imports array
- All 11 Vitest tests pass; type-check clean
2026-07-01 23:21:36 +02:00
schalli a06694f915 test(09-01): add failing spec for cert-manager seed + helpers (RED)
- Test: seedCertManagerModule calls seedModule with slug='cert-manager',
  category='security-tools', isSystem=true
- Test: detectFormat returns pem/der/pfx/p7b based on extension + content sniff
- Test: getFingerprint returns uppercase colon-separated hex (sha1 + sha256)
- Test: parsePemChain returns array of length 2 for two concatenated PEMs
2026-07-01 23:18:08 +02:00
schalli e35276243a fix(calendar): EWS uses NTLM auth + edit form stays open after save
- Replace ews-javascript-api (Basic Auth only) with httpntlm for EWS connections
- testEwsConnection uses GetFolder SOAP via NTLM
- fetchViaEws uses FindItem CalendarView SOAP via NTLM
- Edit form no longer auto-closes on save — shows "Erfolgreich gespeichert" instead
- Test button in edit mode uses saved credentials via /sources/:id/test endpoint
- Add saveSuccess i18n key (de/en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 14:05:07 +02:00
schalli 51d8c2f14e fix(calendar): SSRF exception for Exchange + error messages + domain in edit
- SSRF check skipped for Exchange type (internal EWS servers are common)
- testConnectionFromConfig catches SSRF/validation errors, returns {success:false,error} instead of throwing 403
- updateSource reads existing.type to determine effective type for SSRF check
- Panel shows saveError/editSaveError on failed add/update
- Edit form initialValues now includes domain field
- i18n: calendar.saveError key added (de+en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 13:41:32 +02:00
schalli 42daa87e5e feat(calendar): add domain field and test-connection button to Exchange sources
- Prisma: domain String? added to CalendarSource model (db push applied)
- DTOs: domain in CreateCalendarSourceDto, UpdateCalendarSourceDto, new TestCalendarSourceConfigDto
- Service: domain in SOURCE_SAFE_SELECT, addSource, updateSource; new testConnectionFromConfig method
- Controller: POST /calendar/sources/test-config (before :id routes to avoid collision)
- ExchangeProvider: domain in all source interfaces; passed as 3rd arg to EWS WebCredentials
- Frontend: domain in CalendarSource/CreateSourcePayload/UpdateSourcePayload; testSourceConfig API fn
- Form: domain field (Exchange-only), "Test connection" button with idle/loading/success/error states
- i18n: de+en keys for formFieldDomain, formFieldDomainHint, formTestConnection, formTesting, formTestSuccess, formTestFailed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 13:15:13 +02:00
schalli eebceb298d fix(08): apply code review findings (CR-01, CR-02, WR-01–05, IN-01)
- CR-01: fix SSRF bypass — isPrivateIpv6 now delegates ::ffff:<ipv4> to
  isPrivateIpv4, covering 172.16-31.x and 169.254.x ranges
- CR-02: add ParseUUIDPipe to GET /favorites widgetId param + service guard
  so missing widgetId returns 400 instead of leaking all user favorites
- WR-01: link-widget — replace raw 'link.error' key with t('link.error') (4 sites)
- WR-02: favorites-widget — fix load-path error to use t('favorites.error')
- WR-03: widget-catalog-modal — move aria-hidden from outer wrapper to backdrop
- WR-04: calculator — remove duplicate M button (MR clone); MC/MR/M+/M−/MS remain
- WR-05: schema — add FavoriteLink→WidgetInstance FK with onDelete:Cascade
- IN-01: create-widget.dto.ts — update comment from four to eight supported types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 11:03:16 +02:00
schalli 758d246e98 feat(08-03): FavoriteLink schema + FavoritesModule (CRUD + SSRF icon discovery)
- Add FavoriteLink Prisma model (userId/tenantId/widgetId scope, iconUrl nullable, position)
- IconDiscoveryService: port SSRF-protected icon discovery with redirect: 'manual',
  private IP / blocked-hostname checks, 4000ms timeout, 200k HTML cap (T-08-05)
- FavoritesService: list/create/update/remove all scoped by userId (T-08-06 / Pitfall 3)
- FavoritesController: GET /favorites?widgetId, POST, PATCH :id, DELETE :id
- FavoritesModule registered in AppModule
- tsc --noEmit passes for @tessera/api
2026-07-01 10:25:52 +02:00
schalli 63ec93bd35 feat(08-01): registry foundation for 4 new widget types + Calculator widget (GREEN)
- widget-registry.tsx: extend WidgetType union with calculator/favorites/link/stopwatch
- widget-registry.tsx: add WIDGET_CONSTRAINTS entries with per-widget grid constraints (DASH-11)
- widget-registry.tsx: add SVG icons (CalculatorIcon, FavoritesIcon, LinkIcon, StopwatchIcon)
- widget-registry.tsx: add WIDGET_REGISTRY entries and wire functions for all 4 new types
- calculator-widget.tsx: full arithmetic implementation ported from personal-dashboard
  (parseDisplay, formatNumber, calculate, keyboard handler with stopPropagation)
- widget-catalog-modal.tsx: extend WIDGET_TYPES to include all 8 types
- create-widget.dto.ts: extend @IsIn to accept 8 widget types (T-08-01 mitigated)
- page.tsx: import CalculatorWidget and call wireCalculatorWidget()
- de.json / en.json: add i18n keys for calculator, favorites, link, stopwatch
- All 16 tests passing (GREEN)
2026-07-01 09:57:44 +02:00
schalli 0fba45d2c2 feat(quick-260630-gbh-01): avatar storage endpoints + enriched /auth/me
- Add avatarPath String? column to User model (migration: add_user_avatar)
- POST /users/me/avatar: 2MB limit, image/png/jpeg/webp allowlist, writes to user-files/avatars/{userId}.{ext}
- GET /users/me/avatar: streams avatar with Cache-Control: no-store
- AuthService.getMe(): returns isLocalUser + hasAvatar without leaking passwordHash/ldapDn
- AuthController GET /auth/me: now returns enriched profile via getMe()
2026-06-30 11:57:33 +02:00
schalli dcba4b9977 fix(dkv): search msgfolderroot for EWS subfolder resolution
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 24s
FindFolder was searching only under inbox DistinguishedFolderId, missing
folders at mailbox root level. Now searches msgfolderroot (full mailbox)
so custom folders like DKV are found regardless of placement.

Also adds HTTP status check and debug logging for FindFolder responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:23:30 +02:00
schalli 30eb40c184 feat(dkv): Exchange subfolder support via EWS FindFolder
Tessera CI/CD / Lint & Type Check (push) Successful in 40s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 22s
Custom folder names (e.g. "DKV" or "INBOX/DKV") now resolved by calling
EWS FindFolder deep-search under inbox. Well-known names still map to
DistinguishedFolderId directly. Falls back to inbox with a warning log
when the subfolder cannot be found.

IMAP already supported subfolder paths natively via ImapFlow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:04:24 +02:00
schalli b5bf3ed8c0 fix(dkv): return username in GET /dkv/config response
Tessera CI/CD / Lint & Type Check (push) Successful in 40s
Tessera CI/CD / Tests (push) Successful in 37s
Tessera CI/CD / Build & Publish Images (push) Successful in 22s
loadConfig used CONFIG_SAFE_SELECT which excludes encryptedInboxCreds entirely,
so username was never returned to the frontend — form always showed empty username.

Added getConfigForApi() which loads the safe config + decrypts encryptedInboxCreds
to extract username (never password) and adds hasPassword boolean. Controller
getConfig now calls getConfigForApi instead of loadConfig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 09:58:59 +02:00
schalli a44e40f101 fix(dkv): correct invoice date extraction; add Exchange IsRead filter + mark-as-read
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 44s
Tessera CI/CD / Build & Publish Images (push) Successful in 21s
Date fix: previous regex matched payment-due date ("10 Tage nach Rechnungsdatum...
10.04.2026") instead of actual Rechnungsdatum. New approach anchors on the
invoice number line (DD/DDDDDDDDD/DDD) and takes the date on the next line,
which is always the actual Rechnungsdatum in DKV PDFs.

Exchange dedup: FindItem now filters IsRead=false (combined with sender filter
via <t:And>), so already-processed emails are skipped automatically.
After downloading attachments, UpdateItem marks the message as read
(using ItemId + ChangeKey from GetItem response), mirroring IMAP \Seen behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 09:47:17 +02:00