feat: admin panel, Mistral client, attribute management, API key command
- Fix EasyAdmin 5 routing: #[AdminDashboard] attribute + easyadmin.routes loader - Fix login: _username/_password field names, CSRF stateless token config, sessions directory, Opcache reload after cache:clear - Add MistralClient behind OllamaClientInterface — switchable via services.yaml alias - Add Attribute CRUD with EnumType form + ChoiceField display (enum-safe rendering) - Add Article Type CRUD with AssociationField for attribute assignments - Add app:api-keys:create console command (bcrypt-hashed, never stored as plaintext) - Add redis ext to Docker image + symfony/redis-messenger, start workers - Translate all UI strings to English - Add tests: MistralClient, ApiKey, CreateApiKeyCommand, StringArrayType, ArticleTypeCrudController, AttributeDefinitionCrudController (82 tests total) - Update design doc: tech stack, AI backend switching guide, ops section Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
edc0cdfd5d
commit
f915bba966
31 changed files with 3514 additions and 117 deletions
|
|
@ -10,6 +10,12 @@
|
||||||
"doctrine/doctrine-bundle": "*",
|
"doctrine/doctrine-bundle": "*",
|
||||||
"doctrine/doctrine-migrations-bundle": "*",
|
"doctrine/doctrine-migrations-bundle": "*",
|
||||||
"doctrine/orm": "*",
|
"doctrine/orm": "*",
|
||||||
|
"easycorp/easyadmin-bundle": "*",
|
||||||
|
"endroid/qr-code": "*",
|
||||||
|
"endroid/qr-code-bundle": "*",
|
||||||
|
"scheb/2fa-backup-code": "*",
|
||||||
|
"scheb/2fa-bundle": "*",
|
||||||
|
"scheb/2fa-totp": "*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
"symfony/dotenv": "8.0.*",
|
"symfony/dotenv": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
|
|
@ -17,6 +23,8 @@
|
||||||
"symfony/http-client": "8.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/mailer": "8.0.*",
|
"symfony/mailer": "8.0.*",
|
||||||
"symfony/messenger": "8.0.*",
|
"symfony/messenger": "8.0.*",
|
||||||
|
"symfony/monolog-bundle": "^4.0",
|
||||||
|
"symfony/redis-messenger": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/security-bundle": "8.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "8.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
|
|
|
||||||
2078
composer.lock
generated
2078
composer.lock
generated
File diff suppressed because it is too large
Load diff
9
config/packages/csrf.yaml
Normal file
9
config/packages/csrf.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Enable stateless CSRF protection for forms and logins/logouts
|
||||||
|
framework:
|
||||||
|
form:
|
||||||
|
csrf_protection:
|
||||||
|
token_id: submit
|
||||||
|
|
||||||
|
csrf_protection:
|
||||||
|
stateless_token_ids:
|
||||||
|
- submit
|
||||||
52
config/packages/monolog.yaml
Normal file
52
config/packages/monolog.yaml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
monolog:
|
||||||
|
channels:
|
||||||
|
- deprecation
|
||||||
|
|
||||||
|
when@dev:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: stream
|
||||||
|
path: '%kernel.logs_dir%/%kernel.environment%.log'
|
||||||
|
level: debug
|
||||||
|
channels: ['!event']
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ['!event', '!doctrine', '!console']
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
buffer_size: 50
|
||||||
|
nested:
|
||||||
|
type: stream
|
||||||
|
path: php://stderr
|
||||||
|
level: debug
|
||||||
|
formatter: monolog.formatter.json
|
||||||
|
database:
|
||||||
|
type: service
|
||||||
|
id: App\Infrastructure\Logging\DatabaseLogHandler
|
||||||
|
level: warning
|
||||||
|
channels: ['!doctrine']
|
||||||
|
deprecation:
|
||||||
|
type: stream
|
||||||
|
channels: [deprecation]
|
||||||
|
path: php://stderr
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
nested:
|
||||||
|
type: stream
|
||||||
|
path: '%kernel.logs_dir%/%kernel.environment%.log'
|
||||||
|
level: debug
|
||||||
8
config/packages/scheb_2fa.yaml
Normal file
8
config/packages/scheb_2fa.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
scheb_two_factor:
|
||||||
|
security_tokens:
|
||||||
|
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
|
||||||
|
|
||||||
|
totp:
|
||||||
|
enabled: true
|
||||||
|
issuer: SuperSeller3000
|
||||||
|
template: security/2fa.html.twig
|
||||||
|
|
@ -1,39 +1,49 @@
|
||||||
security:
|
security:
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
|
||||||
password_hashers:
|
password_hashers:
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
|
||||||
providers:
|
providers:
|
||||||
users_in_memory: { memory: null }
|
app_user_provider:
|
||||||
|
id: App\Infrastructure\Security\UserProvider
|
||||||
|
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
# Ensure dev tools and static assets are always allowed
|
|
||||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||||
security: false
|
security: false
|
||||||
|
|
||||||
|
api:
|
||||||
|
pattern: ^/api/
|
||||||
|
stateless: true
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Infrastructure\Security\ApiKeyAuthenticator
|
||||||
|
|
||||||
main:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: users_in_memory
|
provider: app_user_provider
|
||||||
|
form_login:
|
||||||
|
login_path: app_login
|
||||||
|
check_path: app_login
|
||||||
|
enable_csrf: true
|
||||||
|
default_target_path: easyadmin
|
||||||
|
logout:
|
||||||
|
path: app_logout
|
||||||
|
target: app_login
|
||||||
|
two_factor:
|
||||||
|
auth_form_path: 2fa_login
|
||||||
|
check_path: 2fa_login_check
|
||||||
|
enable_csrf: true
|
||||||
|
|
||||||
# Activate different ways to authenticate:
|
|
||||||
# https://symfony.com/doc/current/security.html#the-firewall
|
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security/impersonating_user.html
|
|
||||||
# switch_user: true
|
|
||||||
|
|
||||||
# Note: Only the *first* matching rule is applied
|
|
||||||
access_control:
|
access_control:
|
||||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||||
# - { path: ^/profile, roles: ROLE_USER }
|
- { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
|
||||||
|
- { path: ^/admin, roles: ROLE_USER }
|
||||||
|
- { path: ^/totp, roles: ROLE_USER }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
security:
|
security:
|
||||||
password_hashers:
|
password_hashers:
|
||||||
# Password hashers are resource-intensive by design to ensure security.
|
|
||||||
# In tests, it's safe to reduce their cost to improve performance.
|
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||||
algorithm: auto
|
algorithm: auto
|
||||||
cost: 4 # Lowest possible value for bcrypt
|
cost: 4
|
||||||
time_cost: 3 # Lowest possible value for argon
|
time_cost: 3
|
||||||
memory_cost: 10 # Lowest possible value for argon
|
memory_cost: 10
|
||||||
|
|
|
||||||
5
config/packages/translation.yaml
Normal file
5
config/packages/translation.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
framework:
|
||||||
|
default_locale: en
|
||||||
|
translator:
|
||||||
|
default_path: '%kernel.project_dir%/translations'
|
||||||
|
providers:
|
||||||
6
config/packages/twig.yaml
Normal file
6
config/packages/twig.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
twig:
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
twig:
|
||||||
|
strict_variables: true
|
||||||
5
config/packages/twig_component.yaml
Normal file
5
config/packages/twig_component.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
twig_component:
|
||||||
|
anonymous_template_directory: 'components/'
|
||||||
|
defaults:
|
||||||
|
# Namespace & directory for components
|
||||||
|
App\Twig\Components\: 'components/'
|
||||||
|
|
@ -11,4 +11,5 @@ controllers:
|
||||||
resource:
|
resource:
|
||||||
path: ../src/Infrastructure/Http/Controller/
|
path: ../src/Infrastructure/Http/Controller/
|
||||||
namespace: App\Infrastructure\Http\Controller
|
namespace: App\Infrastructure\Http\Controller
|
||||||
|
exclude: '../src/Infrastructure/Http/Controller/Admin'
|
||||||
type: attribute
|
type: attribute
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
_security_logout:
|
easyadmin:
|
||||||
resource: security.route_loader.logout
|
resource: .
|
||||||
type: service
|
type: easyadmin.routes
|
||||||
|
|
|
||||||
7
config/routes/scheb_2fa.yaml
Normal file
7
config/routes/scheb_2fa.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
2fa_login:
|
||||||
|
path: /2fa
|
||||||
|
defaults:
|
||||||
|
_controller: "scheb_two_factor.form_controller::form"
|
||||||
|
|
||||||
|
2fa_login_check:
|
||||||
|
path: /2fa_check
|
||||||
|
|
@ -6,11 +6,15 @@ RUN apk add --no-cache \
|
||||||
libzip-dev \
|
libzip-dev \
|
||||||
unzip \
|
unzip \
|
||||||
git \
|
git \
|
||||||
|
$PHPIZE_DEPS \
|
||||||
&& docker-php-ext-install \
|
&& docker-php-ext-install \
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
intl \
|
intl \
|
||||||
zip \
|
zip \
|
||||||
opcache
|
opcache \
|
||||||
|
&& pecl install redis \
|
||||||
|
&& docker-php-ext-enable redis \
|
||||||
|
&& apk del $PHPIZE_DEPS
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
|
|
||||||
479
docs/superpowers/specs/2026-05-13-superseller3000-design.md
Normal file
479
docs/superpowers/specs/2026-05-13-superseller3000-design.md
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
# SuperSeller3000 — Design-Dokument
|
||||||
|
|
||||||
|
**Datum:** 2026-05-13 · **Zuletzt aktualisiert:** 2026-05-17
|
||||||
|
**Status:** In Betrieb (ss3k.schaunwama.de)
|
||||||
|
**Kontext:** Middleware zur Artikelverwaltung, KI-gestützten Erfassung und Multi-Plattform-Verkauf von refurbished IT-Hardware.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Projektziel & Kontext
|
||||||
|
|
||||||
|
SuperSeller3000 ist die **führende Schaltzentrale** für den Verkauf von refurbished IT-Hardware. Alle externen Systeme (eBay, Frappe ERP) sind Werkzeuge — die Middleware ist Master.
|
||||||
|
|
||||||
|
**Betrieb heute:** Dropshipping. Lieferant erhält Rechnung per Mail und versendet manuell. Eigener Versand folgt später.
|
||||||
|
**Team:** 2–3 Mitarbeiter
|
||||||
|
**Artikel:** ~500 heute, Ziel ~5.000
|
||||||
|
**Plattformen:** eBay (initial), weitere folgen (Amazon, Kaufland, …)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architektur
|
||||||
|
|
||||||
|
### 2.1 Hexagonale Architektur (Ports & Adapters)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
Domain/ # Reines PHP — Article, ArticleType, Order, Customer …
|
||||||
|
# Keine Framework-Imports, vollständig unit-testbar
|
||||||
|
Application/ # UseCases, Commands, Handlers
|
||||||
|
# Orchestriert Domain über Interfaces (Ports)
|
||||||
|
Infrastructure/
|
||||||
|
Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter]
|
||||||
|
AI/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface)
|
||||||
|
# OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent
|
||||||
|
Persistence/ # Doctrine Repositories (PostgreSQL)
|
||||||
|
Logging/ # DatabaseLogHandler, ArchiveCommand
|
||||||
|
Http/ # Symfony Controller, Webhook-Listener, EasyAdmin
|
||||||
|
Queue/ # Symfony Messenger, Redis Transport
|
||||||
|
Storage/ # StorageManager
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt.
|
||||||
|
|
||||||
|
### 2.2 Tech-Stack
|
||||||
|
|
||||||
|
| Bereich | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Sprache / Framework | PHP 8.4 · Symfony 8.4 |
|
||||||
|
| ORM | Doctrine ORM |
|
||||||
|
| Queue | Symfony Messenger + Redis Transport (`symfony/redis-messenger`) |
|
||||||
|
| Mailer | Symfony Mailer (SMTP) |
|
||||||
|
| Tests | PHPUnit 11 + Pest |
|
||||||
|
| Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) |
|
||||||
|
| Cache / Queue | Redis 7 |
|
||||||
|
| AI | **Ollama** (lokal, SSH-Tunnel + autossh) **oder Mistral Cloud API** — per Alias in `services.yaml` umschaltbar; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar |
|
||||||
|
| Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) |
|
||||||
|
| Auth | Symfony Security + scheb/two-factor-bundle (TOTP) |
|
||||||
|
| API-Auth | API-Key (eigene Entity, bcrypt-Hash) + Symfony Voters; Keys via `app:api-keys:create` anlegen |
|
||||||
|
| Reverse Proxy | Caddy 2 (Auto-HTTPS via Let's Encrypt, `php_fastcgi app:9000`) — Domain: `ss3k.schaunwama.de` |
|
||||||
|
| Containerisierung | Docker Compose (VPS) — `app`, `caddy`, `postgres`, `redis`, `worker-ai`, `worker-orders`, `worker-channel`, `cron` |
|
||||||
|
| Versionskontrolle | Gitea (self-hosted) + Gitea Actions (CI) |
|
||||||
|
| Backups | pg_dump täglich, Docker-Volume-Backup, gitea dump |
|
||||||
|
|
||||||
|
### 2.3 Async Queue-Architektur
|
||||||
|
|
||||||
|
Drei isolierte Transports — ein ausgefallener Worker blockiert die anderen nie:
|
||||||
|
|
||||||
|
```
|
||||||
|
redis://...?queue_name=ai_pipeline # AI-Agents (Vision, Specs, JSON, Text)
|
||||||
|
redis://...?queue_name=orders # Order-Processing, CustomerResolver, Invoice
|
||||||
|
redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ollama-Ausfall:** AI-Pipeline-Jobs verbleiben in der Queue. Symfony Messenger wiederholt mit Backoff, nach 3 Versuchen → Failed Transport (persistent, nicht verloren). Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn Ollama wieder verfügbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Domain-Modell
|
||||||
|
|
||||||
|
### 3.1 Artikel & Mapping
|
||||||
|
|
||||||
|
```
|
||||||
|
ArticleType
|
||||||
|
id: UUID (stabil) name: string (änderbar)
|
||||||
|
→ AttributeDefinition[]
|
||||||
|
|
||||||
|
AttributeDefinition
|
||||||
|
id: UUID (stabil) name: string (änderbar)
|
||||||
|
type: string|int|float|bool|select|multi_select
|
||||||
|
unit: string? options: string[]?
|
||||||
|
|
||||||
|
Platform
|
||||||
|
id: UUID type: string label: string config: jsonb
|
||||||
|
|
||||||
|
ChannelField # Registry aller Plattform-Felder
|
||||||
|
id: UUID (stabil) platform_id
|
||||||
|
label: string (änderbar) path: string
|
||||||
|
# z.B. path = "ItemSpecifics[name=Prozessor]" für eBay
|
||||||
|
|
||||||
|
ArticleTypePlatformConfig # 1× pro ArticleType × Platform
|
||||||
|
article_type_id platform_id
|
||||||
|
category_id: string # eBay-Kategorie-ID, ERP-Warengruppe etc.
|
||||||
|
→ AttributeMapping[]
|
||||||
|
|
||||||
|
AttributeMapping
|
||||||
|
attribute_definition_id channel_field_id
|
||||||
|
transformer: string? # z.B. "kg_to_g"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stabilitätsprinzip:** Alle Mappings referenzieren UUIDs, nie Strings. Name/Label eines Attributs oder ChannelFields kann jederzeit geändert werden ohne ein Mapping zu brechen.
|
||||||
|
|
||||||
|
### 3.2 Artikel
|
||||||
|
|
||||||
|
```
|
||||||
|
Article
|
||||||
|
id: UUID article_type_id sku: string inventory_number: string
|
||||||
|
status: ingesting|draft|needs_review|active|listed|sold
|
||||||
|
stock: int
|
||||||
|
condition: new|like_new|good|acceptable
|
||||||
|
condition_notes: text? # Freitext, z.B. "leichte Kratzer Unterseite"
|
||||||
|
listing_price: decimal? # eBay-Verkaufspreis
|
||||||
|
serial_number: string?
|
||||||
|
ebay_listing_id: string?
|
||||||
|
ebay_title: string? # AI-generiert, editierbar vor Freigabe
|
||||||
|
ebay_description: text? # AI-generiert, editierbar vor Freigabe
|
||||||
|
→ AttributeValue[]
|
||||||
|
→ ArticlePhoto[]
|
||||||
|
|
||||||
|
AttributeValue
|
||||||
|
article_id attribute_definition_id value: string
|
||||||
|
# Typ-Cast beim Lesen anhand AttributeDefinition.type
|
||||||
|
|
||||||
|
ArticlePhoto
|
||||||
|
id: UUID article_id storage_path_id filename: string
|
||||||
|
is_main: bool sort_order: int
|
||||||
|
# Voller Pfad = StoragePath.base_path + '/' + filename
|
||||||
|
|
||||||
|
StoragePath
|
||||||
|
id: UUID label: string base_path: string
|
||||||
|
quota_bytes: bigint priority: int is_active: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
**StorageManager:** Wählt aktiven Pfad nach Priorität, prüft Quota vor jedem Write, fällt auf nächsten zurück. Migration: nur `base_path` ändern, alle Referenzen lösen automatisch korrekt auf.
|
||||||
|
|
||||||
|
**Artikel-Status-Maschine:**
|
||||||
|
```
|
||||||
|
ingesting → draft → active → listed → sold
|
||||||
|
↓
|
||||||
|
needs_review (AI-Fehler nach 3 Versuchen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Bestellungen & Kunden
|
||||||
|
|
||||||
|
```
|
||||||
|
Customer
|
||||||
|
id: UUID name email address: jsonb
|
||||||
|
frappe_customer_id: string?
|
||||||
|
platform_ids: jsonb # {"ebay": "user123", "amazon": "buyer456"}
|
||||||
|
# Frappe-Seite: Custom Fields superseller_customer_id + platform-IDs
|
||||||
|
|
||||||
|
Order
|
||||||
|
id: UUID article_id customer_id platform_id
|
||||||
|
platform_order_id: string # eBay Order ID
|
||||||
|
status: pending|processing|shipped|completed|failed
|
||||||
|
sale_price: decimal sale_date: datetime
|
||||||
|
tracking_number: string? carrier: string?
|
||||||
|
shipped_at: datetime? tracking_pushed_to_ebay_at: datetime?
|
||||||
|
→ Invoice?
|
||||||
|
|
||||||
|
Invoice
|
||||||
|
id: UUID order_id frappe_invoice_id: string
|
||||||
|
storage_path_id filename: string # PDF
|
||||||
|
created_at: datetime emailed_at: datetime?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 AI-Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
AIPipelineJob
|
||||||
|
id: UUID type: photo|pxe|text_gen
|
||||||
|
article_id: UUID? # gesetzt nach Draft-Anlage
|
||||||
|
status: queued|processing|completed|failed|needs_review
|
||||||
|
attempt_count: int # max. 3
|
||||||
|
input_data: jsonb # Foto-Pfad oder PXE-Dump
|
||||||
|
output_data: jsonb # extrahierte Attribute
|
||||||
|
missing_fields: text[] # für Retry-Prompt-Kontext (PostgreSQL text[])
|
||||||
|
error_message: string?
|
||||||
|
created_at: datetime completed_at: datetime?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Auth
|
||||||
|
|
||||||
|
```
|
||||||
|
User
|
||||||
|
id: UUID email password_hash
|
||||||
|
totp_secret: string? # 2FA (scheb/two-factor-bundle)
|
||||||
|
permissions: jsonb # article:view, order:edit, log:delete …
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
ApiKey
|
||||||
|
id: UUID user_id label: string
|
||||||
|
key_hash: string # bcrypt — nie Klartext in DB
|
||||||
|
permissions: jsonb # eigenes, unabhängiges Permission-Set
|
||||||
|
is_active: bool
|
||||||
|
last_used_at: datetime? expires_at: datetime?
|
||||||
|
```
|
||||||
|
|
||||||
|
**PermissionVoter:** Eine einzige Voter-Klasse prüft User- und ApiKey-Permissions einheitlich. API-Keys sind für Browser-Login ohne 2FA, da der Key selbst ein starkes Credential ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Artikel-Erfassung: AI-Pipelines
|
||||||
|
|
||||||
|
### 4.1 Pipeline A — Foto (Typenschild)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline)
|
||||||
|
2. OllamaVisionAgent — LLaVA liest Typenschild
|
||||||
|
Output: Modellbezeichnung + Seriennummer (nur was sichtbar)
|
||||||
|
3. SpecsResearchAgent — Web-Suche mit Modellbezeichnung → vollständige Specs (Freitext)
|
||||||
|
Web-Suche ist Pflicht (kein reines LLM-Wissen — zu unzuverlässig für Hardware)
|
||||||
|
4. JsonCodingAgent — strukturierter Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema
|
||||||
|
5. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder)
|
||||||
|
✓ → Schritt 6
|
||||||
|
✗ → Retry ab Schritt 4 mit missing_fields im Prompt (max. 3×)
|
||||||
|
→ needs_review nach 3 Fehlversuchen
|
||||||
|
6. DraftArticleCreator — Article anlegen (status: draft), Inventurnummer vergeben
|
||||||
|
7. EbayTextAgent — Titel + Beschreibung aus Attributen generieren, am Artikel speichern
|
||||||
|
8. → Freigabe-Queue (manuell)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Pipeline B — PXE-Inventur
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Gerät bootet per PXE → sendet lshw/dmidecode-Dump an API
|
||||||
|
2. API vergibt Inventurnummer → PxeInventoryMessage in Queue
|
||||||
|
3. JsonCodingAgent — PXE-Dump enthält vollständige Rohdaten → direkt JSON-Mapping
|
||||||
|
(SpecsResearchAgent entfällt)
|
||||||
|
4. ValidationGate — identisch zu Pipeline A
|
||||||
|
5. DraftArticleCreator — Inventurnummer bereits bekannt
|
||||||
|
6. EbayTextAgent — identisch
|
||||||
|
7. → Freigabe-Queue (manuell)
|
||||||
|
Mitarbeiter klebt Inventurnummer-Aufkleber auf Gerät während Pipeline läuft
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Freigabe
|
||||||
|
|
||||||
|
Mitarbeiter öffnet Draft im Admin-Panel → prüft Attribute, Fotos, Preis, `condition_notes`, eBay-Texte → gibt frei.
|
||||||
|
Bei Freigabe: `status → active` → `PublishToChannelMessage` → eBay-Listing anlegen + Frappe-ERP-Sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Channel-Adapter
|
||||||
|
|
||||||
|
Jede Plattform implementiert `ChannelAdapterInterface`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface ChannelAdapterInterface {
|
||||||
|
public function publishListing(Article $article): string; // → platform_listing_id
|
||||||
|
public function updateStock(Article $article, int $stock): void;
|
||||||
|
public function deactivateListing(Article $article): void;
|
||||||
|
public function pushTracking(Order $order): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Neue Plattform = neue Implementierung. Domain, Order-Flow und Inventory-Logik bleiben unverändert.
|
||||||
|
|
||||||
|
**eBay-spezifisch:** eBay Taxonomy API wird beim Anlegen einer `ArticleTypePlatformConfig` abgefragt — Pflichtfelder der gewählten Kategorie werden als vorgeschlagene Mappings vorgeladen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Order-Flow
|
||||||
|
|
||||||
|
### 6.1 eBay-Webhook-Registrierung
|
||||||
|
|
||||||
|
eBay Notification API — kein Polling. Registrierte Events:
|
||||||
|
- `FIXED_PRICE_TRANSACTION`
|
||||||
|
- `AUCTION_CHECKOUT_COMPLETE`
|
||||||
|
- `MARKETPLACE_ACCOUNT_DELETION` (eBay-Pflicht)
|
||||||
|
|
||||||
|
### 6.2 Ablauf
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Webhook POST /webhooks/ebay → HMAC-Signatur prüfen → sofort 200 → OrderReceivedMessage
|
||||||
|
2. InventoryLock ATOMAR: UPDATE article SET stock = stock - 1 WHERE id = ? AND stock > 0
|
||||||
|
0 Zeilen → Überverkauf → Order: failed, Alert, manuell klären
|
||||||
|
Erfolg → weiter
|
||||||
|
3. CustomerResolver — Matching-Kaskade (siehe 6.3)
|
||||||
|
4. InvoiceCreator — Sales Invoice in Frappe ERP via REST anlegen
|
||||||
|
(Frappe ERP wird nur hier involviert — rein reaktiv)
|
||||||
|
5. InvoicePdfFetcher — PDF von Frappe abrufen → StorageManager → Invoice-Record
|
||||||
|
6. InvoiceMailer — PDF per SMTP an feste Lieferanten-Adresse → Invoice.emailed_at setzen
|
||||||
|
7. ListingSync — stock > 0: Bestände auf allen Plattformen aktualisieren
|
||||||
|
stock = 0: DeactivateListingsMessage pro Plattform in Queue
|
||||||
|
→ Retry mit Exponential-Backoff bei API-Fehler
|
||||||
|
→ Alert nach 5 Fehlversuchen (kritisch: kein Überverkauf)
|
||||||
|
8. Abschluss — Order: completed, Article: sold (wenn stock = 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tracking (Lieferant meldet zurück):**
|
||||||
|
`PATCH /orders/{id}/tracking` → `tracking_number` + `carrier` → `TrackingPushMessage` → alle Plattformen als versandt markieren → `tracking_pushed_to_ebay_at` setzen.
|
||||||
|
|
||||||
|
### 6.3 Customer-Matching-Kaskade
|
||||||
|
|
||||||
|
**Stufe 1 — Platform-ID (exakt):**
|
||||||
|
`platform_ids->>'ebay' = ?` → Match: direkt verwenden.
|
||||||
|
|
||||||
|
**Stufe 2 — Adresse (exakt, cross-platform):**
|
||||||
|
`lowercase(name + straße + ort + plz)` — 100% exakter Vergleich, keine Fuzzy-Logik.
|
||||||
|
Match → selber Kunde; neue Platform-ID sofort in `platform_ids` eintragen (nächster Sale trifft Stufe 1).
|
||||||
|
Kein Match → neuer Kunde anlegen.
|
||||||
|
|
||||||
|
**Regel:** `lowercase` ist die einzige Toleranz. "Kirchstr 1" ≠ "Kirchstraße 1" → zwei Kunden. Datenmischung ist schlimmer als ein Duplikat. Manuelle Zusammenführung folgt als spätere Admin-Funktion.
|
||||||
|
|
||||||
|
**Neu-Anlage:** Frappe-ERP-Kunde erstellen mit Custom Fields (`superseller_customer_id`, `ebay_user_id`) → `frappe_customer_id` in Middleware zurückschreiben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Logging
|
||||||
|
|
||||||
|
```
|
||||||
|
Schema: logs.log_entry
|
||||||
|
id level channel message context: jsonb
|
||||||
|
message_search: tsvector # PostgreSQL GIN-Index → Fulltext-Suche
|
||||||
|
created_at
|
||||||
|
|
||||||
|
Schema: logs_archive.log_entry # identische Struktur
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monolog Handler:** Custom `DatabaseLogHandler` schreibt alle Levels in `logs.log_entry`.
|
||||||
|
|
||||||
|
**Rotation (täglich per Cron):**
|
||||||
|
1. Einträge > 90 Tage mit `level > DEBUG` → `logs_archive.log_entry` kopieren
|
||||||
|
2. Alle Einträge > 90 Tage aus `logs.log_entry` löschen
|
||||||
|
|
||||||
|
**Admin-Panel (EasyAdmin):** Filter auf Level/Channel/Zeitraum, Fulltext-Suche, ACL-gesteuert (view/delete).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sicherheit & Hardening
|
||||||
|
|
||||||
|
### VPS & Netzwerk
|
||||||
|
- **UFW:** nur Port 80, 443 (Caddy) + SSH öffentlich
|
||||||
|
- **SSH:** Key-only-Auth, Root-Login deaktiviert, Fail2ban
|
||||||
|
- **Docker-Netz:** internes Bridge-Network — PostgreSQL, Redis nie direkt nach außen
|
||||||
|
- **Caddy:** HSTS, X-Content-Type-Options, X-Frame-Options per Default
|
||||||
|
|
||||||
|
### Dienste
|
||||||
|
- **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser)
|
||||||
|
- **Redis:** `requirepass` gesetzt, nur intern erreichbar
|
||||||
|
- **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect)
|
||||||
|
- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth
|
||||||
|
|
||||||
|
### Applikation
|
||||||
|
- **Secrets:** nie in Git — `.env.local` auf Server oder Docker Secrets; `.env` ohne Credentials
|
||||||
|
- **APP_ENV:** `prod`, Debug-Modus aus
|
||||||
|
- **API-Keys:** in DB als bcrypt-Hash, nie Klartext
|
||||||
|
- **eBay-Webhook:** HMAC-Signatur auf jedem Request prüfen vor Queue-Einspeisung
|
||||||
|
- **Rate Limiting:** Caddy oder Symfony RateLimiter auf API-Endpoints + Webhook-Listener
|
||||||
|
- **Frappe-ERP:** API-Token im Secret-Store, HTTPS-only, TLS-Zertifikat validieren
|
||||||
|
- **2FA:** scheb/two-factor-bundle (TOTP) für alle Browser-Logins, Backup-Codes
|
||||||
|
|
||||||
|
### Backups (täglich)
|
||||||
|
- PostgreSQL: verschlüsselter `pg_dump` → externer Storage
|
||||||
|
- Docker-Volumes: Rechnungs-PDFs + Artikel-Fotos
|
||||||
|
- Gitea: `gitea dump` per Cron
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Deployment
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose.yml
|
||||||
|
app # PHP 8.4-FPM + Symfony
|
||||||
|
caddy # Reverse Proxy + Auto-HTTPS
|
||||||
|
postgres # PostgreSQL 17
|
||||||
|
redis # Queue + Cache
|
||||||
|
worker-ai # Symfony Messenger Worker (ai_pipeline)
|
||||||
|
worker-orders # Symfony Messenger Worker (orders)
|
||||||
|
worker-channel # Symfony Messenger Worker (channel_sync)
|
||||||
|
cron # Symfony Console Commands (Log-Rotation, Backups, pg_dump)
|
||||||
|
gitea # Source Control + CI
|
||||||
|
act-runner # Gitea Actions Runner
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gitea Actions CI:** Auf jedem Push — PHPUnit/Pest, PHPStan, PHP CS Fixer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Qualität
|
||||||
|
|
||||||
|
- **TDD:** Tests werden vor oder parallel zur Implementierung geschrieben, kein Code ohne Test
|
||||||
|
- **PHPStan:** Level max, im CI erzwungen
|
||||||
|
- **PHP CS Fixer:** einheitlicher Code-Style, im CI erzwungen
|
||||||
|
- **Doku:** wird laufend gepflegt (OpenAPI für die REST-API, ADRs für Architekturentscheidungen)
|
||||||
|
- **Domain vollständig unit-testbar:** keine Framework-Abhängigkeiten im Domain-Layer
|
||||||
|
- **Adapter isoliert testbar:** jeder Channel-Adapter, jeder AI-Agent hat eigene Tests gegen Mocks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Betrieb & Setup
|
||||||
|
|
||||||
|
### 11.1 Admin-Panel
|
||||||
|
|
||||||
|
URL: `https://ss3k.schaunwama.de/admin`
|
||||||
|
Login: `https://ss3k.schaunwama.de/login` (Formular-Login + TOTP falls aktiviert)
|
||||||
|
Passwort ändern: `/account/password`
|
||||||
|
|
||||||
|
**Ersten User anlegen:**
|
||||||
|
```bash
|
||||||
|
docker compose exec app php bin/console app:users:create --env=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
**Artikel-Typen & Merkmale:**
|
||||||
|
1. Admin → **Attributes** → neues Merkmal anlegen (Name, Typ, Einheit, Optionen)
|
||||||
|
2. Admin → **Article Types** → neuen Typ anlegen → Merkmale per Autocomplete zuweisen
|
||||||
|
|
||||||
|
### 11.2 API-Keys
|
||||||
|
|
||||||
|
API-Keys werden über die Console generiert (nie über die UI — der Klartext-Key wird nur einmal angezeigt):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php bin/console app:api-keys:create --env=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key.
|
||||||
|
Verwendung: `X-Api-Key: <rawKey>` HTTP-Header.
|
||||||
|
|
||||||
|
### 11.3 AI-Backend wechseln
|
||||||
|
|
||||||
|
**Ollama (Standard, lokal):**
|
||||||
|
```yaml
|
||||||
|
# config/services.yaml
|
||||||
|
App\Infrastructure\AI\OllamaClientInterface:
|
||||||
|
alias: App\Infrastructure\AI\OllamaClient
|
||||||
|
```
|
||||||
|
```dotenv
|
||||||
|
# .env
|
||||||
|
AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL}
|
||||||
|
AI_VISION_MODEL=${OLLAMA_VISION_MODEL}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mistral Cloud API:**
|
||||||
|
```yaml
|
||||||
|
App\Infrastructure\AI\OllamaClientInterface:
|
||||||
|
alias: App\Infrastructure\AI\MistralClient
|
||||||
|
```
|
||||||
|
```dotenv
|
||||||
|
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
|
||||||
|
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
|
||||||
|
MISTRAL_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
Vision erfordert Pixtral (`pixtral-12b-2409`). Nach Änderung: `cache:clear` + FPM reload.
|
||||||
|
|
||||||
|
### 11.4 Pipeline starten
|
||||||
|
|
||||||
|
Voraussetzungen: mind. 1 Article Type + mind. 1 API-Key vorhanden, Worker laufen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Photo-Pipeline
|
||||||
|
curl -X POST https://ss3k.schaunwama.de/api/pipeline/photo-upload \
|
||||||
|
-H "X-Api-Key: <key>" \
|
||||||
|
-F "articleTypeId=<uuid>" \
|
||||||
|
-F "photo=@/path/to/photo.jpg"
|
||||||
|
|
||||||
|
# Job-Status abfragen
|
||||||
|
curl https://ss3k.schaunwama.de/api/pipeline/jobs/<jobId> \
|
||||||
|
-H "X-Api-Key: <key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte / Spätere Erweiterungen
|
||||||
|
|
||||||
|
- Eigener Versand (Versandlabel-Generierung, weiterer Workflow-Schritt)
|
||||||
|
- Weitere Verkaufsplattformen (Amazon, Kaufland) — nur neuer Channel-Adapter nötig
|
||||||
|
- SpecsResearchAgent: konkrete Web-Such-Strategie definieren (SerpAPI, direkte Hersteller-Seiten, o.ä.)
|
||||||
|
- Manuelle Kunden-Zusammenführung als Admin-Funktion
|
||||||
|
- eBay-Promotions / Preisanpassungen
|
||||||
|
- `ebay_title` / `ebay_description` sind aktuell eBay-spezifisch auf `Article` — bei weiteren Plattformen zu `platform_texts: jsonb` generalisieren
|
||||||
|
|
@ -35,6 +35,11 @@ class AttributeDefinition
|
||||||
$this->type = $type;
|
$this->type = $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
public function getId(): Uuid
|
public function getId(): Uuid
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ class ApiKey
|
||||||
#[ORM\Column(type: 'string', length: 255)]
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
private string $label;
|
private string $label;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 8)]
|
||||||
|
private string $keyPrefix;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
||||||
private string $keyHash;
|
private string $keyHash;
|
||||||
|
|
||||||
|
|
@ -38,11 +41,12 @@ class ApiKey
|
||||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
private ?\DateTimeImmutable $expiresAt = null;
|
private ?\DateTimeImmutable $expiresAt = null;
|
||||||
|
|
||||||
public function __construct(User $user, string $label, string $keyHash)
|
public function __construct(User $user, string $label, string $keyPrefix, string $keyHash)
|
||||||
{
|
{
|
||||||
$this->id = Uuid::v7();
|
$this->id = Uuid::v7();
|
||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
$this->label = $label;
|
$this->label = $label;
|
||||||
|
$this->keyPrefix = $keyPrefix;
|
||||||
$this->keyHash = $keyHash;
|
$this->keyHash = $keyHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,6 +65,11 @@ class ApiKey
|
||||||
return $this->label;
|
return $this->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getKeyPrefix(): string
|
||||||
|
{
|
||||||
|
return $this->keyPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
public function getKeyHash(): string
|
public function getKeyHash(): string
|
||||||
{
|
{
|
||||||
return $this->keyHash;
|
return $this->keyHash;
|
||||||
|
|
|
||||||
67
src/Infrastructure/Console/CreateApiKeyCommand.php
Normal file
67
src/Infrastructure/Console/CreateApiKeyCommand.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Domain\Auth\ApiKey;
|
||||||
|
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
|
||||||
|
use App\Domain\Auth\Repository\UserRepositoryInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'app:api-keys:create', description: 'Generate a new API key for a user')]
|
||||||
|
final class CreateApiKeyCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepositoryInterface $userRepository,
|
||||||
|
private readonly ApiKeyRepositoryInterface $apiKeyRepository,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$email = $io->ask('User email');
|
||||||
|
if (!\is_string($email) || '' === $email) {
|
||||||
|
$io->error('Email is required.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userRepository->findByEmail($email);
|
||||||
|
if (null === $user) {
|
||||||
|
$io->error(\sprintf('No user found with email "%s".', $email));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $io->ask('Label for this key (e.g. "dev laptop", "pipeline script")');
|
||||||
|
if (!\is_string($label) || '' === $label) {
|
||||||
|
$io->error('Label is required.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawKey = bin2hex(random_bytes(24));
|
||||||
|
$prefix = substr($rawKey, 0, 8);
|
||||||
|
$keyHash = password_hash($rawKey, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
|
$apiKey = new ApiKey($user, $label, $prefix, $keyHash);
|
||||||
|
$this->apiKeyRepository->save($apiKey);
|
||||||
|
|
||||||
|
$io->success('API key created. Store it now — it will not be shown again.');
|
||||||
|
$io->table(['Field', 'Value'], [
|
||||||
|
['Key ID', $apiKey->getId()->toRfc4122()],
|
||||||
|
['Label', $label],
|
||||||
|
['API Key', $rawKey],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Article\ArticleType;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||||
|
|
||||||
|
/** @extends AbstractCrudController<ArticleType> */
|
||||||
|
final class ArticleTypeCrudController extends AbstractCrudController
|
||||||
|
{
|
||||||
|
public static function getEntityFqcn(): string
|
||||||
|
{
|
||||||
|
return ArticleType::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureCrud(Crud $crud): Crud
|
||||||
|
{
|
||||||
|
return $crud->setEntityLabelInSingular('Article Type')->setEntityLabelInPlural('Article Types');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createEntity(string $entityFqcn): ArticleType
|
||||||
|
{
|
||||||
|
return new ArticleType('');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureActions(Actions $actions): Actions
|
||||||
|
{
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureFields(string $pageName): iterable
|
||||||
|
{
|
||||||
|
yield IdField::new('id')->hideOnForm();
|
||||||
|
yield TextField::new('name', 'Name');
|
||||||
|
yield AssociationField::new('attributeDefinitions', 'Attributes')
|
||||||
|
->setFormTypeOptions(['by_reference' => false])
|
||||||
|
->autocomplete()
|
||||||
|
->hideOnIndex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Article\AttributeDefinition;
|
||||||
|
use App\Domain\Article\AttributeType;
|
||||||
|
use App\Infrastructure\Http\Form\StringArrayType;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||||
|
|
||||||
|
/** @extends AbstractCrudController<AttributeDefinition> */
|
||||||
|
final class AttributeDefinitionCrudController extends AbstractCrudController
|
||||||
|
{
|
||||||
|
public static function getEntityFqcn(): string
|
||||||
|
{
|
||||||
|
return AttributeDefinition::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureCrud(Crud $crud): Crud
|
||||||
|
{
|
||||||
|
return $crud
|
||||||
|
->setEntityLabelInSingular('Attribute')
|
||||||
|
->setEntityLabelInPlural('Attributes');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createEntity(string $entityFqcn): AttributeDefinition
|
||||||
|
{
|
||||||
|
return new AttributeDefinition('', AttributeType::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureFields(string $pageName): iterable
|
||||||
|
{
|
||||||
|
yield IdField::new('id')->hideOnForm();
|
||||||
|
yield TextField::new('name', 'Name');
|
||||||
|
|
||||||
|
// choice.html.twig (used by ChoiceField) only renders formattedValue — safe for enums.
|
||||||
|
// text.html.twig also renders field.value in the title attr, which chokes on enum objects.
|
||||||
|
if (\in_array($pageName, [Crud::PAGE_NEW, Crud::PAGE_EDIT], true)) {
|
||||||
|
yield Field::new('type', 'Type')
|
||||||
|
->setFormType(EnumType::class)
|
||||||
|
->setFormTypeOptions(['class' => AttributeType::class]);
|
||||||
|
} else {
|
||||||
|
yield ChoiceField::new('type', 'Type')
|
||||||
|
->setChoices([])
|
||||||
|
->formatValue(static fn (mixed $v): string => $v instanceof AttributeType ? $v->value : (string) $v);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield TextField::new('unit', 'Unit')->setRequired(false)->hideOnIndex();
|
||||||
|
yield Field::new('options', 'Options (one per line)')
|
||||||
|
->setFormType(StringArrayType::class)
|
||||||
|
->setRequired(false)
|
||||||
|
->hideOnIndex()
|
||||||
|
->setHelp('Only relevant for type select / multi_select.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,19 +4,19 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Infrastructure\Http\Controller\Admin;
|
namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
|
|
||||||
|
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
|
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\UserMenu;
|
use EasyCorp\Bundle\EasyAdminBundle\Config\UserMenu;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[AdminDashboard(routePath: '/admin', routeName: 'easyadmin')]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
final class DashboardController extends AbstractDashboardController
|
final class DashboardController extends AbstractDashboardController
|
||||||
{
|
{
|
||||||
#[Route('/admin', name: 'easyadmin')]
|
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return $this->render('admin/dashboard.html.twig');
|
return $this->render('admin/dashboard.html.twig');
|
||||||
|
|
@ -26,13 +26,15 @@ final class DashboardController extends AbstractDashboardController
|
||||||
{
|
{
|
||||||
return parent::configureUserMenu($user)
|
return parent::configureUserMenu($user)
|
||||||
->addMenuItems([
|
->addMenuItems([
|
||||||
MenuItem::linkToRoute('Passwort ändern', 'fa fa-key', 'app_change_password'),
|
MenuItem::linkToRoute('Change Password', 'fa fa-key', 'app_change_password'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureDashboard(): Dashboard
|
public function configureDashboard(): Dashboard
|
||||||
{
|
{
|
||||||
return Dashboard::new()->setTitle('SuperSeller3000');
|
return Dashboard::new()
|
||||||
|
->setTitle('SuperSeller3000')
|
||||||
|
->setFaviconPath('favicon.ico');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureMenuItems(): iterable
|
public function configureMenuItems(): iterable
|
||||||
|
|
@ -40,12 +42,13 @@ final class DashboardController extends AbstractDashboardController
|
||||||
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
|
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
|
||||||
yield MenuItem::linkTo(ArticleCrudController::class, 'Articles', 'fa fa-box');
|
yield MenuItem::linkTo(ArticleCrudController::class, 'Articles', 'fa fa-box');
|
||||||
yield MenuItem::linkTo(ArticleTypeCrudController::class, 'Article Types', 'fa fa-tags');
|
yield MenuItem::linkTo(ArticleTypeCrudController::class, 'Article Types', 'fa fa-tags');
|
||||||
|
yield MenuItem::linkTo(AttributeDefinitionCrudController::class, 'Attributes', 'fa fa-list-check');
|
||||||
yield MenuItem::linkTo(AIPipelineJobCrudController::class, 'AI Pipeline Jobs', 'fa fa-robot');
|
yield MenuItem::linkTo(AIPipelineJobCrudController::class, 'AI Pipeline Jobs', 'fa fa-robot');
|
||||||
yield MenuItem::linkTo(UserCrudController::class, 'Users', 'fa fa-users');
|
yield MenuItem::linkTo(UserCrudController::class, 'Users', 'fa fa-users');
|
||||||
yield MenuItem::linkTo(LogEntryCrudController::class, 'Logs', 'fa fa-list');
|
yield MenuItem::linkTo(LogEntryCrudController::class, 'Logs', 'fa fa-list');
|
||||||
yield MenuItem::section('Verkauf');
|
yield MenuItem::section('Sales');
|
||||||
yield MenuItem::linkTo(OrderCrudController::class, 'Bestellungen', 'fa fa-shopping-cart');
|
yield MenuItem::linkTo(OrderCrudController::class, 'Orders', 'fa fa-shopping-cart');
|
||||||
yield MenuItem::linkTo(CustomerCrudController::class, 'Kunden', 'fa fa-users');
|
yield MenuItem::linkTo(CustomerCrudController::class, 'Customers', 'fa fa-users');
|
||||||
yield MenuItem::linkTo(InvoiceCrudController::class, 'Rechnungen', 'fa fa-file-invoice');
|
yield MenuItem::linkTo(InvoiceCrudController::class, 'Invoices', 'fa fa-file-invoice');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,23 +33,23 @@ final class ChangePasswordController extends AbstractController
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST')) {
|
||||||
$token = $request->request->getString('_csrf_token');
|
$token = $request->request->getString('_csrf_token');
|
||||||
if (!$this->isCsrfTokenValid('change_password', $token)) {
|
if (!$this->isCsrfTokenValid('change_password', $token)) {
|
||||||
$error = 'Ungültiges Formular-Token. Bitte erneut versuchen.';
|
$error = 'Invalid form token. Please try again.';
|
||||||
} else {
|
} else {
|
||||||
$current = $request->request->getString('current_password');
|
$current = $request->request->getString('current_password');
|
||||||
$new = $request->request->getString('new_password');
|
$new = $request->request->getString('new_password');
|
||||||
$confirm = $request->request->getString('confirm_password');
|
$confirm = $request->request->getString('confirm_password');
|
||||||
|
|
||||||
if (!$this->hasher->isPasswordValid($user, $current)) {
|
if (!$this->hasher->isPasswordValid($user, $current)) {
|
||||||
$error = 'Das aktuelle Passwort ist falsch.';
|
$error = 'Current password is incorrect.';
|
||||||
} elseif (mb_strlen($new) < 8) {
|
} elseif (mb_strlen($new) < 8) {
|
||||||
$error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.';
|
$error = 'New password must be at least 8 characters.';
|
||||||
} elseif ($new !== $confirm) {
|
} elseif ($new !== $confirm) {
|
||||||
$error = 'Die neuen Passwörter stimmen nicht überein.';
|
$error = 'Passwords do not match.';
|
||||||
} else {
|
} else {
|
||||||
$user->setPasswordHash($this->hasher->hashPassword($user, $new));
|
$user->setPasswordHash($this->hasher->hashPassword($user, $new));
|
||||||
$this->users->save($user);
|
$this->users->save($user);
|
||||||
|
|
||||||
$this->addFlash('success', 'Passwort erfolgreich geändert.');
|
$this->addFlash('success', 'Password changed successfully.');
|
||||||
|
|
||||||
return $this->redirectToRoute('app_change_password');
|
return $this->redirectToRoute('app_change_password');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/Infrastructure/Http/Form/StringArrayType.php
Normal file
37
src/Infrastructure/Http/Form/StringArrayType.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\CallbackTransformer;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
final class StringArrayType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->addModelTransformer(new CallbackTransformer(
|
||||||
|
fn (?array $v) => implode("\n", $v ?? []),
|
||||||
|
fn (mixed $v) => '' === ($v ?? '') ? null : array_values(
|
||||||
|
array_filter(array_map('trim', explode("\n", (string) $v)))
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
parent::configureOptions($resolver);
|
||||||
|
// EasyAdmin injects CollectionType options when it detects a json column;
|
||||||
|
// declare them as defined so they are accepted without error.
|
||||||
|
$resolver->setDefined(['allow_add', 'allow_delete', 'delete_empty', 'entry_options', 'entry_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParent(): string
|
||||||
|
{
|
||||||
|
return TextareaType::class;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
symfony.lock
129
symfony.lock
|
|
@ -35,6 +35,70 @@
|
||||||
"migrations/.gitignore"
|
"migrations/.gitignore"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"easycorp/easyadmin-bundle": {
|
||||||
|
"version": "5.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "4.14",
|
||||||
|
"ref": "13b3e524d1038a6861ad6c7fc64e31b644ba0c54"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/routes/easyadmin.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"endroid/qr-code-bundle": {
|
||||||
|
"version": "7.0.0"
|
||||||
|
},
|
||||||
|
"friendsofphp/php-cs-fixer": {
|
||||||
|
"version": "3.95",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.39",
|
||||||
|
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".php-cs-fixer.dist.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phpstan/phpstan": {
|
||||||
|
"version": "2.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes-contrib",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"phpunit/phpunit": {
|
||||||
|
"version": "11.5",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "11.1",
|
||||||
|
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env.test",
|
||||||
|
"phpunit.dist.xml",
|
||||||
|
"tests/bootstrap.php",
|
||||||
|
"bin/phpunit"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scheb/2fa-bundle": {
|
||||||
|
"version": "8.5",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.0",
|
||||||
|
"ref": "1e6f68089146853a790b5da9946fc5974f6fcd49"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/scheb_2fa.yaml",
|
||||||
|
"config/routes/scheb_2fa.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/console": {
|
"symfony/console": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
@ -60,6 +124,18 @@
|
||||||
".env.dev"
|
".env.dev"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfony/form": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.2",
|
||||||
|
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/csrf.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/framework-bundle": {
|
"symfony/framework-bundle": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
@ -104,6 +180,18 @@
|
||||||
"config/packages/messenger.yaml"
|
"config/packages/messenger.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfony/monolog-bundle": {
|
||||||
|
"version": "4.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.7",
|
||||||
|
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/monolog.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/property-info": {
|
"symfony/property-info": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
@ -142,6 +230,32 @@
|
||||||
"config/routes/security.yaml"
|
"config/routes/security.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfony/translation": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.3",
|
||||||
|
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/translation.yaml",
|
||||||
|
"translations/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/twig-bundle": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.4",
|
||||||
|
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/twig.yaml",
|
||||||
|
"templates/base.html.twig"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/uid": {
|
"symfony/uid": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
@ -151,6 +265,18 @@
|
||||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"symfony/ux-twig-component": {
|
||||||
|
"version": "3.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "2.13",
|
||||||
|
"ref": "f367ae2a1faf01c503de2171f1ec22567febeead"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/twig_component.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/validator": {
|
"symfony/validator": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
@ -162,5 +288,8 @@
|
||||||
"files": [
|
"files": [
|
||||||
"config/packages/validator.yaml"
|
"config/packages/validator.yaml"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"twig/extra-bundle": {
|
||||||
|
"version": "v3.24.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
{% block title %}Passwort ändern{% endblock %}
|
{% block title %}Change Password{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div style="max-width:400px;margin:4rem auto;font-family:sans-serif">
|
<div style="max-width:400px;margin:4rem auto;font-family:sans-serif">
|
||||||
<h1>Passwort ändern</h1>
|
<h1>Change Password</h1>
|
||||||
|
|
||||||
{% for message in app.flashes('success') %}
|
{% for message in app.flashes('success') %}
|
||||||
<div style="color:green;margin-bottom:1rem">{{ message }}</div>
|
<div style="color:green;margin-bottom:1rem">{{ message }}</div>
|
||||||
|
|
@ -16,21 +16,21 @@
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label for="current_password">Aktuelles Passwort</label><br>
|
<label for="current_password">Current Password</label><br>
|
||||||
<input id="current_password" type="password" name="current_password" required autofocus style="width:100%;padding:.5rem">
|
<input id="current_password" type="password" name="current_password" required autofocus style="width:100%;padding:.5rem">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label for="new_password">Neues Passwort</label><br>
|
<label for="new_password">New Password</label><br>
|
||||||
<input id="new_password" type="password" name="new_password" required minlength="8" style="width:100%;padding:.5rem">
|
<input id="new_password" type="password" name="new_password" required minlength="8" style="width:100%;padding:.5rem">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label for="confirm_password">Neues Passwort bestätigen</label><br>
|
<label for="confirm_password">Confirm New Password</label><br>
|
||||||
<input id="confirm_password" type="password" name="confirm_password" required minlength="8" style="width:100%;padding:.5rem">
|
<input id="confirm_password" type="password" name="confirm_password" required minlength="8" style="width:100%;padding:.5rem">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('change_password') }}">
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('change_password') }}">
|
||||||
<div style="display:flex;gap:1rem;align-items:center">
|
<div style="display:flex;gap:1rem;align-items:center">
|
||||||
<button type="submit" style="padding:.5rem 1rem">Passwort ändern</button>
|
<button type="submit" style="padding:.5rem 1rem">Change Password</button>
|
||||||
<a href="{{ path('easyadmin') }}" style="color:#666">Zurück zum Dashboard</a>
|
<a href="{{ path('easyadmin') }}" style="color:#666">Back to Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label for="password">Password</label><br>
|
<label for="password">Password</label><br>
|
||||||
<input id="password" type="password" name="password" required style="width:100%;padding:.5rem">
|
<input id="password" type="password" name="_password" required style="width:100%;padding:.5rem">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||||
<button type="submit" style="padding:.5rem 1rem">Login</button>
|
<button type="submit" style="padding:.5rem 1rem">Login</button>
|
||||||
|
|
|
||||||
98
tests/Unit/Domain/Auth/ApiKeyTest.php
Normal file
98
tests/Unit/Domain/Auth/ApiKeyTest.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Domain\Auth;
|
||||||
|
|
||||||
|
use App\Domain\Auth\ApiKey;
|
||||||
|
use App\Domain\Auth\User;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ApiKeyTest extends TestCase
|
||||||
|
{
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->user = new User('test@example.com', 'hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructorSetsFields(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'dev laptop', 'abcd1234', 'hashed-key');
|
||||||
|
|
||||||
|
self::assertSame('dev laptop', $key->getLabel());
|
||||||
|
self::assertSame('abcd1234', $key->getKeyPrefix());
|
||||||
|
self::assertSame('hashed-key', $key->getKeyHash());
|
||||||
|
self::assertSame($this->user, $key->getUser());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsActiveByDefault(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'label', 'abcd1234', 'hash');
|
||||||
|
|
||||||
|
self::assertTrue($key->isActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanBeDeactivated(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'label', 'abcd1234', 'hash');
|
||||||
|
$key->setIsActive(false);
|
||||||
|
|
||||||
|
self::assertFalse($key->isActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsNotExpiredWithNoExpiry(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'label', 'abcd1234', 'hash');
|
||||||
|
|
||||||
|
self::assertFalse($key->isExpired());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsExpiredWhenExpiryIsPast(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'label', 'abcd1234', 'hash');
|
||||||
|
$key->setExpiresAt(new \DateTimeImmutable('-1 hour'));
|
||||||
|
|
||||||
|
self::assertTrue($key->isExpired());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsNotExpiredWhenExpiryIsFuture(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'label', 'abcd1234', 'hash');
|
||||||
|
$key->setExpiresAt(new \DateTimeImmutable('+1 hour'));
|
||||||
|
|
||||||
|
self::assertFalse($key->isExpired());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarkUsedSetsLastUsedAt(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'label', 'abcd1234', 'hash');
|
||||||
|
self::assertNull($key->getLastUsedAt());
|
||||||
|
|
||||||
|
$key->markUsed();
|
||||||
|
|
||||||
|
self::assertNotNull($key->getLastUsedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGrantAndCheckPermission(): void
|
||||||
|
{
|
||||||
|
$key = new ApiKey($this->user, 'label', 'abcd1234', 'hash');
|
||||||
|
$key->grantPermission('articles.write');
|
||||||
|
|
||||||
|
self::assertTrue($key->hasPermission('articles.write'));
|
||||||
|
self::assertFalse($key->hasPermission('orders.delete'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRawKeyVerifiesAgainstStoredHash(): void
|
||||||
|
{
|
||||||
|
$rawKey = bin2hex(random_bytes(24));
|
||||||
|
$hash = password_hash($rawKey, PASSWORD_BCRYPT);
|
||||||
|
$prefix = substr($rawKey, 0, 8);
|
||||||
|
|
||||||
|
$key = new ApiKey($this->user, 'label', $prefix, $hash);
|
||||||
|
|
||||||
|
self::assertTrue(password_verify($rawKey, $key->getKeyHash()));
|
||||||
|
self::assertSame($prefix, $key->getKeyPrefix());
|
||||||
|
}
|
||||||
|
}
|
||||||
123
tests/Unit/Infrastructure/AI/MistralClientTest.php
Normal file
123
tests/Unit/Infrastructure/AI/MistralClientTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Infrastructure\AI;
|
||||||
|
|
||||||
|
use App\Infrastructure\AI\MistralClient;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
final class MistralClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private function makeClient(MockResponse $response): MistralClient
|
||||||
|
{
|
||||||
|
return new MistralClient(
|
||||||
|
new MockHttpClient($response),
|
||||||
|
'test-api-key',
|
||||||
|
'https://api.mistral.ai',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateReturnsParsedContent(): void
|
||||||
|
{
|
||||||
|
$body = json_encode([
|
||||||
|
'choices' => [['message' => ['content' => 'Dell Latitude 5520']]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client = $this->makeClient(new MockResponse((string) $body));
|
||||||
|
|
||||||
|
self::assertSame('Dell Latitude 5520', $client->generate('mistral-large-latest', 'What is this?'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateSendsAuthHeader(): void
|
||||||
|
{
|
||||||
|
$body = json_encode([
|
||||||
|
'choices' => [['message' => ['content' => 'ok']]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = new MockResponse((string) $body);
|
||||||
|
$client = $this->makeClient($response);
|
||||||
|
$client->generate('mistral-large-latest', 'test');
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'Bearer test-api-key',
|
||||||
|
implode(', ', $response->getRequestOptions()['headers'] ?? []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateWithImageReturnsParsedContent(): void
|
||||||
|
{
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.jpg';
|
||||||
|
file_put_contents($tmpFile, 'fake-image-data');
|
||||||
|
|
||||||
|
$body = json_encode([
|
||||||
|
'choices' => [['message' => ['content' => 'MODEL: ThinkPad X1']]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client = $this->makeClient(new MockResponse((string) $body));
|
||||||
|
$result = $client->generateWithImage('pixtral-12b-2409', 'Read the nameplate', $tmpFile);
|
||||||
|
|
||||||
|
unlink($tmpFile);
|
||||||
|
|
||||||
|
self::assertSame('MODEL: ThinkPad X1', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateWithImageEncodesImageAsBase64(): void
|
||||||
|
{
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.png';
|
||||||
|
$imageContent = 'fake-png-bytes';
|
||||||
|
file_put_contents($tmpFile, $imageContent);
|
||||||
|
|
||||||
|
$body = json_encode([
|
||||||
|
'choices' => [['message' => ['content' => 'ok']]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = new MockResponse((string) $body);
|
||||||
|
$client = $this->makeClient($response);
|
||||||
|
$client->generateWithImage('pixtral-12b-2409', 'describe', $tmpFile);
|
||||||
|
|
||||||
|
unlink($tmpFile);
|
||||||
|
|
||||||
|
$requestBody = json_decode($response->getRequestOptions()['body'], true);
|
||||||
|
$imageUrl = $requestBody['messages'][0]['content'][1]['image_url']['url'];
|
||||||
|
|
||||||
|
self::assertStringStartsWith('data:image/png;base64,', $imageUrl);
|
||||||
|
self::assertStringContainsString(base64_encode($imageContent), $imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider mimeTypeProvider
|
||||||
|
*/
|
||||||
|
public function testGuessMimeTypeFromExtension(string $filename, string $expectedMime): void
|
||||||
|
{
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.' . pathinfo($filename, PATHINFO_EXTENSION);
|
||||||
|
file_put_contents($tmpFile, 'x');
|
||||||
|
|
||||||
|
$body = json_encode(['choices' => [['message' => ['content' => 'ok']]]]);
|
||||||
|
$response = new MockResponse((string) $body);
|
||||||
|
$client = $this->makeClient($response);
|
||||||
|
$client->generateWithImage('pixtral-12b-2409', 'x', $tmpFile);
|
||||||
|
|
||||||
|
unlink($tmpFile);
|
||||||
|
|
||||||
|
$requestBody = json_decode($response->getRequestOptions()['body'], true);
|
||||||
|
$imageUrl = $requestBody['messages'][0]['content'][1]['image_url']['url'];
|
||||||
|
|
||||||
|
self::assertStringStartsWith('data:' . $expectedMime . ';base64,', $imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, string}> */
|
||||||
|
public static function mimeTypeProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'jpeg' => ['photo.jpg', 'image/jpeg'],
|
||||||
|
'jpeg uppercase' => ['photo.JPG', 'image/jpeg'],
|
||||||
|
'jpeg ext' => ['photo.jpeg', 'image/jpeg'],
|
||||||
|
'png' => ['photo.png', 'image/png'],
|
||||||
|
'webp' => ['photo.webp', 'image/webp'],
|
||||||
|
'unknown falls back to jpeg' => ['photo.bmp', 'image/jpeg'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Infrastructure\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Article\ArticleType;
|
||||||
|
use App\Infrastructure\Http\Controller\Admin\ArticleTypeCrudController;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ArticleTypeCrudControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private ArticleTypeCrudController $controller;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->controller = new ArticleTypeCrudController();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetEntityFqcn(): void
|
||||||
|
{
|
||||||
|
self::assertSame(ArticleType::class, ArticleTypeCrudController::getEntityFqcn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateEntityReturnsArticleTypeWithEmptyName(): void
|
||||||
|
{
|
||||||
|
$entity = $this->controller->createEntity(ArticleType::class);
|
||||||
|
|
||||||
|
self::assertInstanceOf(ArticleType::class, $entity);
|
||||||
|
self::assertSame('', $entity->getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfigureFieldsContainsNameAndAttributes(): void
|
||||||
|
{
|
||||||
|
$fields = iterator_to_array($this->controller->configureFields('new'));
|
||||||
|
$names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields);
|
||||||
|
|
||||||
|
self::assertContains('name', $names);
|
||||||
|
self::assertContains('attributeDefinitions', $names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Infrastructure\Admin;
|
||||||
|
|
||||||
|
use App\Domain\Article\AttributeDefinition;
|
||||||
|
use App\Domain\Article\AttributeType;
|
||||||
|
use App\Infrastructure\Http\Controller\Admin\AttributeDefinitionCrudController;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||||
|
|
||||||
|
final class AttributeDefinitionCrudControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private AttributeDefinitionCrudController $controller;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->controller = new AttributeDefinitionCrudController();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetEntityFqcn(): void
|
||||||
|
{
|
||||||
|
self::assertSame(AttributeDefinition::class, AttributeDefinitionCrudController::getEntityFqcn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateEntityReturnsAttributeDefinitionWithDefaults(): void
|
||||||
|
{
|
||||||
|
$entity = $this->controller->createEntity(AttributeDefinition::class);
|
||||||
|
|
||||||
|
self::assertInstanceOf(AttributeDefinition::class, $entity);
|
||||||
|
self::assertSame('', $entity->getName());
|
||||||
|
self::assertSame(AttributeType::String, $entity->getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfigureFieldsYieldsExpectedFieldNames(): void
|
||||||
|
{
|
||||||
|
$fields = iterator_to_array($this->controller->configureFields('new'));
|
||||||
|
$names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields);
|
||||||
|
|
||||||
|
self::assertContains('name', $names);
|
||||||
|
self::assertContains('type', $names);
|
||||||
|
self::assertContains('unit', $names);
|
||||||
|
self::assertContains('options', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTypeFieldOnIndexUsesChoiceFieldWithFormatValue(): void
|
||||||
|
{
|
||||||
|
$fields = iterator_to_array($this->controller->configureFields('index'));
|
||||||
|
$typeField = null;
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if ('type' === $field->getAsDto()->getProperty()) {
|
||||||
|
$typeField = $field;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertNotNull($typeField);
|
||||||
|
|
||||||
|
// Must have formatValue so choice.html.twig can render it safely
|
||||||
|
$formatValue = $typeField->getAsDto()->getFormatValueCallable();
|
||||||
|
self::assertNotNull($formatValue, 'formatValue must be set so index page can render the enum');
|
||||||
|
self::assertSame('select', $formatValue(AttributeType::Select, null));
|
||||||
|
self::assertSame('string', $formatValue(AttributeType::String, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTypeFieldOnFormUsesEnumType(): void
|
||||||
|
{
|
||||||
|
$fields = iterator_to_array($this->controller->configureFields('new'));
|
||||||
|
$typeField = null;
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if ('type' === $field->getAsDto()->getProperty()) {
|
||||||
|
$typeField = $field;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertNotNull($typeField);
|
||||||
|
self::assertSame(EnumType::class, $typeField->getAsDto()->getFormType());
|
||||||
|
}
|
||||||
|
}
|
||||||
115
tests/Unit/Infrastructure/Console/CreateApiKeyCommandTest.php
Normal file
115
tests/Unit/Infrastructure/Console/CreateApiKeyCommandTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Domain\Auth\ApiKey;
|
||||||
|
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
|
||||||
|
use App\Domain\Auth\Repository\UserRepositoryInterface;
|
||||||
|
use App\Domain\Auth\User;
|
||||||
|
use App\Infrastructure\Console\CreateApiKeyCommand;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
final class CreateApiKeyCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private UserRepositoryInterface&MockObject $users;
|
||||||
|
private ApiKeyRepositoryInterface&MockObject $apiKeys;
|
||||||
|
private CommandTester $tester;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->users = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
$this->apiKeys = $this->createMock(ApiKeyRepositoryInterface::class);
|
||||||
|
|
||||||
|
$this->tester = new CommandTester(new CreateApiKeyCommand($this->users, $this->apiKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailsWhenEmailIsEmpty(): void
|
||||||
|
{
|
||||||
|
$this->tester->setInputs(['', '']);
|
||||||
|
|
||||||
|
$this->tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(1, $this->tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('Email is required', $this->tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailsWhenUserNotFound(): void
|
||||||
|
{
|
||||||
|
$this->users->method('findByEmail')->willReturn(null);
|
||||||
|
|
||||||
|
$this->tester->setInputs(['unknown@example.com', '']);
|
||||||
|
|
||||||
|
$this->tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(1, $this->tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('No user found', $this->tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailsWhenLabelIsEmpty(): void
|
||||||
|
{
|
||||||
|
$this->users->method('findByEmail')->willReturn(new User('test@example.com', 'hash'));
|
||||||
|
|
||||||
|
$this->tester->setInputs(['test@example.com', '']);
|
||||||
|
|
||||||
|
$this->tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(1, $this->tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('Label is required', $this->tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreatesKeyAndPrintsIt(): void
|
||||||
|
{
|
||||||
|
$user = new User('test@example.com', 'hash');
|
||||||
|
$this->users->method('findByEmail')->willReturn($user);
|
||||||
|
|
||||||
|
$savedKey = null;
|
||||||
|
$this->apiKeys->expects($this->once())
|
||||||
|
->method('save')
|
||||||
|
->willReturnCallback(function (ApiKey $key) use (&$savedKey): void {
|
||||||
|
$savedKey = $key;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->tester->setInputs(['test@example.com', 'dev laptop']);
|
||||||
|
$this->tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(0, $this->tester->getStatusCode());
|
||||||
|
|
||||||
|
$display = $this->tester->getDisplay();
|
||||||
|
self::assertStringContainsString('API key created', $display);
|
||||||
|
self::assertStringContainsString('dev laptop', $display);
|
||||||
|
|
||||||
|
self::assertNotNull($savedKey);
|
||||||
|
self::assertSame('dev laptop', $savedKey->getLabel());
|
||||||
|
self::assertSame(8, strlen($savedKey->getKeyPrefix()));
|
||||||
|
self::assertStringContainsString($savedKey->getKeyPrefix(), $display);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStoredHashVerifiesAgainstPrintedKey(): void
|
||||||
|
{
|
||||||
|
$user = new User('test@example.com', 'hash');
|
||||||
|
$this->users->method('findByEmail')->willReturn($user);
|
||||||
|
|
||||||
|
$savedKey = null;
|
||||||
|
$this->apiKeys->method('save')
|
||||||
|
->willReturnCallback(function (ApiKey $key) use (&$savedKey): void {
|
||||||
|
$savedKey = $key;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->tester->setInputs(['test@example.com', 'ci-runner']);
|
||||||
|
$this->tester->execute([]);
|
||||||
|
|
||||||
|
self::assertNotNull($savedKey);
|
||||||
|
|
||||||
|
// SymfonyStyle table uses spaces, not pipes: " API Key <hex48> "
|
||||||
|
preg_match('/API Key\s+([a-f0-9]{48})/', $this->tester->getDisplay(), $matches);
|
||||||
|
self::assertNotEmpty($matches, 'Raw key not found in output');
|
||||||
|
|
||||||
|
$rawKey = $matches[1];
|
||||||
|
self::assertSame(substr($rawKey, 0, 8), $savedKey->getKeyPrefix());
|
||||||
|
self::assertTrue(password_verify($rawKey, $savedKey->getKeyHash()));
|
||||||
|
}
|
||||||
|
}
|
||||||
51
tests/Unit/Infrastructure/Form/StringArrayTypeTest.php
Normal file
51
tests/Unit/Infrastructure/Form/StringArrayTypeTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Infrastructure\Form;
|
||||||
|
|
||||||
|
use App\Infrastructure\Http\Form\StringArrayType;
|
||||||
|
use Symfony\Component\Form\Test\TypeTestCase;
|
||||||
|
|
||||||
|
final class StringArrayTypeTest extends TypeTestCase
|
||||||
|
{
|
||||||
|
public function testNullInputReturnsNull(): void
|
||||||
|
{
|
||||||
|
$form = $this->factory->create(StringArrayType::class);
|
||||||
|
$form->setData(null);
|
||||||
|
|
||||||
|
self::assertSame('', $form->getViewData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testArrayIsJoinedWithNewlines(): void
|
||||||
|
{
|
||||||
|
$form = $this->factory->create(StringArrayType::class);
|
||||||
|
$form->setData(['4GB', '8GB', '16GB']);
|
||||||
|
|
||||||
|
self::assertSame("4GB\n8GB\n16GB", $form->getViewData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubmittedNewlineStringIsTransformedToArray(): void
|
||||||
|
{
|
||||||
|
$form = $this->factory->create(StringArrayType::class);
|
||||||
|
$form->submit("4GB\n8GB\n16GB");
|
||||||
|
|
||||||
|
self::assertSame(['4GB', '8GB', '16GB'], $form->getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlankLinesAreStripped(): void
|
||||||
|
{
|
||||||
|
$form = $this->factory->create(StringArrayType::class);
|
||||||
|
$form->submit("4GB\n\n8GB\n \n16GB");
|
||||||
|
|
||||||
|
self::assertSame(['4GB', '8GB', '16GB'], $form->getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptySubmitReturnsNull(): void
|
||||||
|
{
|
||||||
|
$form = $this->factory->create(StringArrayType::class);
|
||||||
|
$form->submit('');
|
||||||
|
|
||||||
|
self::assertNull($form->getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue