diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 7cf15ce77..e85a9b167 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -27,7 +27,7 @@ jobs: build: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 steps: - name: Clone yavsc run: | @@ -44,7 +44,7 @@ jobs: - name: Secret scan run: | echo "🔍 Scanning for secrets..." - cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \ + cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \ - name: Build run: | echo "🏗️ Building the solution..." @@ -56,3 +56,11 @@ jobs: --report-xunit \ --report-xunit-html \ --report-xunit-html-filename "test-results.html" + TEST_EXIT_CODE=$? + echo "Test exit code: $TEST_EXIT_CODE" + if [ $TEST_EXIT_CODE -ne 0 ]; then + echo "❌ Tests failed with exit code $TEST_EXIT_CODE" + exit $TEST_EXIT_CODE + else + echo "✅ All tests passed successfully." + fi diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7d0670a17..de61bd43d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -52,7 +52,7 @@ jobs: release: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 steps: - name: Clone du repo au tag demandé env: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..52d500e18 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# Conventions du projet + +- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer. diff --git a/.github/workflows/docker-publish-backend.yml b/.github/workflows/docker-publish-backend.yml index d8466bdb8..2d84b767f 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -8,8 +8,7 @@ on: jobs: production-deploy: - runs-on: ubuntu-latest - + runs-on: ubuntu-26.04 steps: # 1. Récupération du code - name: Check out the repo @@ -25,8 +24,24 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 10.0.x + - name: Workloads restore + run: dotnet workload restore - name: Test - run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" + env: + TEST_FAILED: "" + run: make test || export TEST_FAILED=$? + - uses: actions/upload-artifact@v7 + with: + name: my-artifact + path: test-reports/**/* + - name: Fail if tests failed + if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }} + run: exit $TEST_FAILED + # 4. Build et Push de l'image de production finale - name: Build and push production image uses: docker/build-push-action@v7 diff --git a/.gitignore b/.gitignore index 056cf69d2..57f13079f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ DataDir/ *.log +# gitleaks scan output (local secret-scan report, not for the repo) +/gitleaks-report.json + +# Exclude test reports (local test report, not for the repo) +/test-reports +/doc/**/.*.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..168ff1424 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.2 + hooks: + - id: gitleaks + diff --git a/.vscode/launch.json b/.vscode/launch.json index d5088f01d..87cf40dbb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,11 +22,9 @@ }, { "name": "Android Debug", - "type": "mono", - "preLaunchTask": "run-debug-android", - "request": "attach", - "address": "localhost", - "port": 55555 + "type": "maui", + "request": "launch", + "project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj" }, { "name": "Android Attach - Debug", @@ -43,9 +41,15 @@ }, { "name": "Yavsc Org", - "type": "dotnet", + "type": "coreclr", "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", + "program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "cwd": "${workspaceFolder}/src/Yavsc.Org", + "preLaunchTask": "build-web-yavsc-org" + }, { "name": "Yavsc Blogs", diff --git a/.vscode/settings.json b/.vscode/settings.json index 83a17ae38..e6f2aee1d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,5 +45,10 @@ ] } }, - "dotnet.defaultSolution": "yavsc.sln" + "dotnet.defaultSolution": "yavsc.sln", + "axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln", + "chat.tools.terminal.autoApprove": { + "dotnet build": true, + "dotnet test": true + } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index cbd3a744d..0b0bb63a5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,9 +4,13 @@ "tasks": [ { - "label": "run-debug-android", + "label": "build-debug-android", "command": "dotnet", + "detail": "Build the PostIt Android project in debug mode", "type": "shell", + "runOptions": { + + }, "problemMatcher": ["$msCompile"], "options": { "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", @@ -89,6 +93,34 @@ }, "isBackground": true }, + { + "label": "build-web-yavsc-org", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Org" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, + { + "label": "build-web-yavsc-blogs", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Blogs" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, { "label": "dotnet: build-postit-desktop", "type": "process", diff --git a/CHANGELOG.md b/CHANGELOG.md index 85139db1b..3320ee74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,388 @@ # Changelog +## [1.0.8-rc25] - unstable + +### Added + +* [Yavsc.Api] Notification du performer à la création d'un rendez-vous + depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org. +* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature + client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt + et transmise par e-mail et SignalR. +* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me` + pour exposer les notifications publiques et ciblées à PostIt sur la + Home page ; les notifications d'administration restent filtrées. +* [PostIt] Affichage des notifications sur la page d'accueil, avec + actualisation, indicateur de chargement et message d'erreur. Les + notifications publiques sont accessibles sans connexion et la liste + est rechargée lors d'un changement de session. + +### Changed + +* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ; + les liens provisoires des nouveaux billets sont résolus au premier + enregistrement. Save conserve l'édition ouverte et est mis en valeur + lorsque le titre, le corps ou les pièces jointes sont modifiés. + +* [Projet] Instructions Copilot persistantes : mise à jour du changelog + par défaut à chaque commit, avec justification explicite des exceptions. + +### Fixed + +* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau + réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché. + Les fenêtres sont fermées après chaque test et les quatre boutons + de navigation sont vérifiés. + +* [PostIt] Les liens Markdown des pièces jointes utilisent désormais + le même filtre de nom que l'upload serveur, avant l'encodage URL. + Le libellé conserve le nom original, notamment ses accents. + +* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés + dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour + éviter les fragments d'URL et permettre le téléchargement via les + fichiers statiques, notamment pour les noms accentués. + +* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont + désormais enregistrés côté serveur pour les requêtes authentifiées : + le middleware de télémétrie est maintenant branché après + l'authentification, ce qui permet de comptabiliser `ApiCalls` et + `BandwidthMb` au lieu d'enregistrer des zéros. + +## [1.0.8-rc24] - unstable + +### Added + +* [PostIt] Reconnection automatique aux salons de discussion au reconnect + +### Changed + +* [PostIt] La liste des billets et l'éditeur Markdown se partagent + l'espace disponible et défilent séparément ; un séparateur permet + de redimensionner les deux zones. + +### Fixed + +* [PostIt] Défilement des articles longs en édition et en aperçu : + suppression de la limite de hauteur qui masquait le contenu Markdown + et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless + couvrent les barres automatiques, le redimensionnement et le + défilement indépendant de la liste. + +## [1.0.8-rc23] - unstable + +### Added + +* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la + liste, mis à jour lors de la publication ou du retour en brouillon. +* [PostIt] Gestion native du compte inspirée de `/Manage` : modification + du nom d'utilisateur, du nom complet et de l'adresse, téléversement + d'avatar, confirmation e-mail, préférence de courriel mensuel, + création ou changement de mot de passe et activation/désactivation + de l'authentification à deux facteurs. +* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des + comptes bancaires, dissociation des connexions externes et suppression + du compte avec confirmation du nom d'utilisateur. +* [PostIt] Configuration du profil professionnel et des activités, + navigation vers les cercles et les publications depuis le compte. +* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche + dans les métadonnées du profil et consultation de la consommation + de ressources. +* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de + ressources, API de synthèse et migration `ResourceUsageRecords`. +* [Tests] Couverture des parcours profil et professionnel, de l'API de + consommation de ressources et du renouvellement des jetons : + rotation à usage unique, appels simultanés, réponses 401 concurrentes + et rejet d'un jeton de renouvellement révoqué. + +### Changed + +* [PostIt] Réorganisation de la page de compte en sections de profil, + sécurité et préférences ; actions de modification désactivées tant + que le profil n'est pas chargé ou qu'une opération est en cours. +* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark` + dans les pages de profil et de configuration professionnelle. +* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par + PostIt, avec l'état de sécurité, les connexions externes, les comptes + bancaires et les compteurs du compte. + +### Fixed + +* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores + EF nécessaires à `UserManager` : leur absence + empêchait l'activation du contrôleur de compte et provoquait une 500. +* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton + ne contient pas d'identifiant utilisateur ou que le compte n'existe + plus, au lieu d'une exception serveur. +* [PostIt] Synchronisation de tous les chemins de renouvellement des + jetons, y compris après une 401 et au démarrage : les appels + concurrents ne réutilisent plus le même `refresh_token`. Un rejet + `invalid_grant` efface la session et invite à se reconnecter. +* [PostIt] Effacement des informations obsolètes après un échec de + rechargement du profil et normalisation des valeurs de calendrier + lors de la sauvegarde. +* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel + lors de son enregistrement. +* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir + d'un résumé agrégé : opérations dédiées avec contrôle de propriété + et validation de longueur des champs. + +### Known limitations + +* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des + crédits ne sont pas encore disponibles dans l'interface native. + +## [1.0.8-rc22] - unstable + +### Fixed + +User experience login into PostIt + +## [1.0.8-rc21] - unstable + +### Fixed + +User experience editing the blog article. + +## [1.0.8-rc20] - unstable + +Chat SignalR fonctionnel de bout en bout (connexion, salons multiples, +envoi/réception de messages), enregistreur audio et localisation dans +PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx +apprêté pour WebSocket. + +### Added + +* [PostIt] Interface de chat : page `ChatPage` (connexion au hub, + saisie du nom de salon, liste des messages et des membres présents), + entrée depuis la page d'accueil. +* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage` + (Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec + envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en + focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`). +* [PostIt] Modèle multi-salles côté client : `JoinedRooms`, + `SelectedRoom` (salle en focus), messages et utilisateurs indexés par + salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la + salle active ; un `Join` sur une salle déjà jointe ne fait que + changer le focus. +* [PostIt] Enregistreur audio (`AudioRecorder`, interface + `IPCMProcessor`) et ressources de localisation + (`Resources.{resx,en-EN,pt-PT}`). +* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à + chaud ; encore KO). +* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade + WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et + `proxy_buffering off` — sans quoi la connexion SignalR tombait au + premier silence ou l'upgrade échouait derrière nginx. + +### Changed + +* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et + enrichi d'une commande d'action optionnelle (`CheckCommand` + + `CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut + propose désormais l'action contextuelle (Connecter / Déconnecter / + Reconnecter / Vérifier la connexion). +* [PostIt] `ChatViewModel` suit le cycle de vie complet de la + connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de + `WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un + `IsConnected` optimiste. +* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` : + `ChatRoomInfo` n'expose que des champs publics, que System.Text.Json + ne sérialise pas par défaut — le client recevait des objets vides + (`Name` null, `Users` vide) et la liste des salons joints n'était + jamais alimentée. +* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName` + en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le + relire depuis un dictionnaire jamais alimenté. +* [PostIt.Android] `network_security_config.xml` déplacé sous + `Resources/xml/` (chemin canonique Android). + +### Fixed + +* [Yavsc.Server] `KeyNotFoundException` dans + `HubConnectionManager.OnConnected` à chaque connexion SignalR : + `ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique + d'un `SetUserName` disparu lors d'une refactorisation. L'exception + dans `OnConnectedAsync` faisait fermer la connexion par le hub, et + PostIt affichait « Déconnecté (The server closed the connection…) » + immédiatement après la connexion. +* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de + `ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à + la racine du service, pas sous le préfixe versionné). +* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"` + sur une grille à 3 rangées (rangée hors limites, bandeau invisible). + +## [1.0.8-rc19] - unstable + +[PostIt] Blogs UX + +### Fixed + +La saisie du billet est maintenant plutôt possible, +en petit écran. + +## [1.0.8-rc18] - unstable + +* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo + +* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout +(arbre de stockage indexé par le login, pas par le `sub`), boutons +d'action des listes réparés (binding `$parent`), revendication `name` +enfin présente dans les access tokens (branchement du `ProfileService`), +et négociation HTTP/3 côté client PostIt. + + +### Added + +* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc : + `DefaultRequestVersion = 3.0`, `DefaultVersionPolicy = + RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`, + avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/ + desktop sans libmsquic). +* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via + `NavigationPage` + `Window.Show()` et vérifie que chaque bouton de + ligne (Télécharger / Supprimer) a sa `Command` liée, est armé + (`CanExecute`) et déclenche l'appel API au clic. +* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` : + mint un vrai JWT via le token endpoint IdentityServer (password grant, + `openid profile`) et asserte que l'access token porte `name`=login et + que `GetUserName()` résout vers ce login. +* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` : + découple `sub` et login, téléverse sous le login, et asserte que + `GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après + le `sub` n'existe pas). + +### Changed + +* [Yavsc.Org] Branchement du `ProfileService` custom + (`AddProfileService()` après + `AddAspNetIdentity()`). La classe était du code mort : + IdentityServer8 retombait sur son profile service par défaut, qui + n'émet que les claims listés dans les `UserClaims` des API resources. + `name` n'en faisait jamais partie, donc tout access token était sans + `name` et `GetUserName()` résolvait à `null` sur les hosts ressources + (Blogs/Api) — cause du 500 sur l'upload MyFiles et du + `User.Identity.Name` injoignable. Le service émet désormais + `name`=`user.UserName`, `email` et les rôles pour tout client dont + les `AllowedScopes` contiennent `profile`, indépendamment du caller. +* [Yavsc.Server] `ServiceExtensions` : `NameClaimType = + JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms + courts de claims tels qu'émis). +* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise + `GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour + la racine de stockage par utilisateur. +* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ; + 1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client + de test ; réensemencement des identity resources openid/profile dans + `ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store + in-memory partagé et les laissait absentes → `invalid_scope`). +* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel + (défaut = subject) pour refléter la forme production où + `sub` (GUID) ≠ `name` (login). +* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour + sérialiser avec le build d'hôte qui mute les registres statiques + `BillingService`/`Config` (race parallèle pré-existante). + +### Fixed + +* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous + `{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de + stockage perso est indexé par le login partout ailleurs + (`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`, + le endpoint de téléchargement et les avatars utilisent + `user.UserName`). Une fois l'upload corrigé, les fichiers + atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait + sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt + « Mes fichiers » qui ne listait rien. `GetDir` résout désormais + l'utilisateur en base et liste sous `user.UserName`. + `UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds` + interpole toujours par `uid`. +* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au + clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach + sur les listes de pièces jointes de `BillingQueryDetailsPage` et + `EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd` + cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel, + pas un contrôle — donc résolvait à `null` et le `Button` restait + désactivé (grisé) et inerte au clic. Corrigé en + `$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe + déjà en vigueur dans `CirclesPage` / `EstimateListPage`). +* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient + pas sélectionnables/copiables : le texte était un `TextBlock` (non + sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`. +* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`) + renvoyait un 40x alors que le fichier était bien supprimé du disque. + `DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient + `FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length` + lève `FileNotFoundException` une fois le fichier disparu — le `catch` + du contrôleur transformait cette exception en 400, d'où le message + perçu « le fichier n'existe pas sur le serveur » tandis que la + suppression avait réussi. La taille est désormais capturée avant la + suppression. +* PostIt default settings + +## [1.0.8-rc17] - unstable + +Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt + +API), système de génération `contrib` (vhosts nginx HTTP/3 edge + +appsettings jq + QUIC logiciel Kestrel), et correction du bug de +production où `IsPublished` disparaissait du payload de liste du blog API. + +### Added + +* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion + des fichiers de l'utilisateur, entrée depuis la page d'accueil. +* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis + le détail de demande et la page d'édition de devis. +* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis + (`EstimateApiController`) et les demandes front-office + (`FrontOfficeApiController`). +* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes + d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`. +* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`, + `BlogAttachedFile`, `AttachFileRequest` ; migration EF Core + `UserFileAttachments`. +* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge + (`template.vhost`, `QUIC_OPTS`), appsettings générés via jq + (`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des + secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et + `README.nginx.md`. +* [Tests] Projet `Yavsc.Server.Tests` : tests du service + `BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:` + par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`). +* [Tests] `IBlogPostWireShapeTests` (forme du wire blog), + `EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`, + `FileSystemApiTests` (pièces jointes et stockage perso). +* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`). + +### Changed + +* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en + production sur le vhost Org. La preprod et la prod partageant la même + IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) — + serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser + la config par nginx. +* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL + provient de la variable d'environnement + `YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en + source. + +### Fixed + +* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload + de la liste du blog API. Le endpoint `GetBlogspot` retourne + `IEnumerable` sérialisé par System.Text.Json d'après le type + déclaré (l'interface), qui omet les propriétés du type concret — + `IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur + `IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet, + y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13). + Corrigé en déclarant `IsPublished` sur `IBlogPost`. +* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de + `CommentsController` (Index/Details/…) atterrissaient sur la même route + `POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une + `SwaggerGeneratorException` (conflit method/path) au chargement de + `/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur + les actions scaffoldées. + ## [1.0.8-rc16] - unstable Flux de signature et de génération de devis : un devis peut désormais être @@ -446,6 +829,8 @@ the ACL now comes along with the article, [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16 +[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17 +[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18 [1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 diff --git a/Dockerfile b/Dockerfile index 795b70ab3..8ebbb73f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,41 +24,24 @@ # ===================================================================== # syntax=docker/dockerfile:1.7 -ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1 +ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2 FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env WORKDIR /src -# (1) Manifest + fichiers de projet pour le cache de restore -COPY *.sln ./ -COPY Directory.*.props ./ -COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/ -COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/ -COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/ -COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/ -COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/ -COPY src/cli/*.csproj ./src/cli/ -COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/ -COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/ -COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/ -COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/ -COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ - -# (2) Tout le code source COPY . . -# (4) Restore RUN dotnet restore -# (5) Build des trois services web principaux +# Build des trois services web principaux RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly -# (6) Build APK Android (utilisé par le workflow docker-publish-android). +# Build APK Android (utilisé par le workflow docker-publish-android). ARG ANDROID_TARGET_RID=android-arm64 RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID} -# (7) Publish des trois services web (les artifacts sont consommés par +# Publish des trois services web (les artifacts sont consommés par # les stages publish-org / publish-api / publish-blogs ci-dessous). RUN mkdir -p /app/publish RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org diff --git a/Dockerfile.backend b/Dockerfile.backend index a4e9a54a9..46c9a982e 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,36 +1,12 @@ # Utilisation de l'image globale pour le build et l'exécution -FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env +FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env WORKDIR /src - # 1. Copie de la solution -COPY *.sln ./ -COPY Directory.*.props ./ - -# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration -COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/ -COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/ -COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/ -COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/ -COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/ -COPY src/cli/*.csproj ./src/cli/ -COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/ - -# Projets associés à PostIt -COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/ -COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/ -COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/ -COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ - -# 4. Copie de l'intégralité du code source COPY . . - +RUN dotnet workload restore # 4. Restauration des dépendances pour tous les projets RUN dotnet restore - -# 5. Copie de la totalité du code source (filtrée par votre .dockerignore) -COPY . . - # 6. Compilation du serveur Web principal RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly diff --git a/Makefile b/Makefile index e883088c2..04efba0be 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,6 @@ DESTDIR=/tmp/yavsc APP_PATH=srv/www/yavsc APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) -include .env - all: dotnet build --nologo @@ -21,7 +19,29 @@ src/Yavsc/bin/output/wwwroot: dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish test: - ASPNETCORE_ENVIRONMENT=Development dotnet test + ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \ + --report-xunit-html \ + --results-directory "test-reports" + +test-postit: + ASPNETCORE_ENVIRONMENT=Development dotnet test \ + --report-xunit \ + --report-xunit-html \ + --results-directory "test-reports" \ + --report-xunit-html-filename "postit-test-results.html" \ + --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj + +backend-tests: Blogs-backend-test Org-backend-test Api-backend-test +Api-backend-test: +Blogs-backend-test: +Org-backend-test: +%-backend-test: + ASPNETCORE_ENVIRONMENT=Development dotnet test \ + --report-xunit \ + --report-xunit-html \ + --results-directory "test-reports" \ + --report-xunit-html-filename "$*-backend-test-results.html" \ + --project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj watch: dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj diff --git a/README.md b/README.md index fefbdd48d..79e711258 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,12 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d * ~/Views/Home/Index.cshtml * ~/Views/Home/About.cshtml +# 👥 Crédits & Co-auteurs + +Ce projet a été développé de manière hybride : +- **Conception, architecture et revue de code :** Paul Schneider +- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf)) + # Remerciements Photo de Dewang Guptasur Unsplash diff --git a/contrib/Makefile b/contrib/Makefile index 4ba6059df..5ccf773a1 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -14,7 +14,7 @@ reinstall: copy-binaries @echo "💯 backend started" reinstall-prod: - make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production + make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production generated/$(APP_INSTALL_PREFIX)yavscOrg.service: generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: @@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN @cat template.service | APP_NAME="$*" \ DESTDIR="$(DESTDIR)" \ HTTP_HOST="$(HTTP_HOST)" \ - HTTP_PORT="$*_$(HTTP_PORT)" \ + HTTP_PORT="$($*_PORT)" \ APPDIR="$(APPDIR)" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ POSTGRES_HOST="$(POSTGRES_HOST)" \ @@ -45,6 +45,76 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service +# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) --- +# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr) +# +# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx +# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement +# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré). +NGINX_TEMPLATE := template.vhost +NGINX_OUT_DIR := generated/nginx +NGINX_HOSTS := Org Blogs Api +DOMAIN_ROOT ?= pschneider.fr +# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's +# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env). +BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http) +# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs +nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1))) +# domaine complet : . +# prod Org -> yavsc.pschneider.fr +# preprod Org -> preprod-yavsc.pschneider.fr +nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT) + +# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port +# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne +# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est +# vide sur tous les vhosts (sinon nginx refuse la config : duplicate +# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org). +$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env + @cat $(NGINX_TEMPLATE) \ + | DOMAIN="$(call nginx_domain,$*)" \ + BACKEND_SCHEME="$(BACKEND_SCHEME)" \ + HTTP_HOST="$(HTTP_HOST)" \ + BACKEND_PORT="$($*_PORT)" \ + QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \ + envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@ + @echo Created nginx vhost: $@ + +generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS)) + +generate-nginx-prod: + make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= + +$(NGINX_OUT_DIR): + @mkdir -p $@ + +# --- appsettings (générés depuis template.appsettings-*.json) --- +# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json +# +# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les +# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password. +# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un +# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel +# contient un \). jq lit le secret depuis l'environnement (export) -> +# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd. +# Les templates actuels sont issus de la preprod (préfixe preprod-, ports +# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe). +export ASPNETCORE_Smtp__Password +APPSETTINGS_OUT_DIR := generated/appsettings +APPSETTINGS_HOSTS := org api blogs + +$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env + @jq --arg domain "$(DOMAIN_ROOT)" \ + 'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \ + template.appsettings-$*.json > $@ + @echo Created appsettings: $@ + +generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS))) + +$(APPSETTINGS_OUT_DIR): + @mkdir -p $@ + install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service sudo systemctl daemon-reload sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* diff --git a/contrib/README.nginx.md b/contrib/README.nginx.md new file mode 100644 index 000000000..9564d4ffe --- /dev/null +++ b/contrib/README.nginx.md @@ -0,0 +1,134 @@ +# Vhosts nginx — génération par `make generate-nginx` + +Les vhosts nginx sont **générés** à l'image des unités de service systemd +(`template.service` + `envsubst` → `generated/*.service`) : un template +unique `contrib/template.vhost` est substitué par `envsubst` vers +`contrib/generated/nginx/`, qui est **gitignoré**. + +## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`) + +nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec +h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`). +Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les +`template.appsettings-*.json`) est une capacité complémentaire — non +joignable par les clients derrière nginx (nginx OSS = upstream h1.1 +uniquement), mais disponible si Kestrel est un jour exposé en direct. + +Directives générées (par vhost) : +```nginx +listen 443 ssl; listen [::]:443 ssl; http2 on; +listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on; +add_header Alt-Svc 'h3=":443"; ma=86400' always; +ssl_protocols TLSv1.3; +``` +`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle +`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts +confondus). + +## Prérequis côté host + +1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;` + exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`, + `listen ... quic` suffit à activer h3) : + ```bash + nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide + ``` + Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon, + backports ou `nginx-full`. +2. **UDP 443 ouvert** : + ```bash + sudo ufw allow 443/udp + sudo ufw status | grep '443/udp' # → ALLOW + ``` +3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le + host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t` + échoue sur le bind IPv6). +4. Certbot / certs Let's Encrypt déjà en place (chemins + `/etc/letsencrypt/live//{fullchain,privkey}.pem` référencés). +5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` — + sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux). + +## Génération + +```bash +cd contrib + +# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +make generate-nginx + +# Production -> generated/nginx/yavsc{Org,Blogs,Api} +make generate-nginx-prod +# ou manuellement : +make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= +``` + +Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env` +(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend +`https` en prod, `http` en preprod. + +| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod | +|-------|--------------|-------------|-------------------------|------------------------| +| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` | +| Api | api | non | `https://localhost:87` | `http://localhost:93` | +| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` | + +Domaines : `.pschneider.fr` +(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`). + +## Déploiement sur le host + +```bash +sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg +sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg +# (idem yavscApi, yavscBlogs) +sudo nginx -t && sudo systemctl reload nginx +``` + +## Vérification (sur le host) + +```bash +# 1. Module h3 présent +nginx -V 2>&1 | grep -o 'http_v3_module' + +# 2. UDP 443 ouvert +sudo ufw status | grep '443/udp' + +# 3. En-tête Alt-Svc annoncé +curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443" + +# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3) +curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3' + +# 5. Fallback h2 et h1 toujours fonctionnels +curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +``` +Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et +les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools → +Network → colonne « Protocole » → `h3` après mise à niveau. + +## Comment ça marche (template + envsubst) + +`contrib/template.vhost` contient les placeholders `$DOMAIN`, +`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle +`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue +via `envsubst` avec une **allowlist** : + +```makefile +envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS' +``` + +Cette allowlist limite la substitution aux 5 variables citées, de sorte +que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`, +`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans +allowlist, `envsubst` détruirait ces variables nginx. + +## Hors périmètre + +- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal + systemd + nginx). +- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que + HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`. +- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2 + côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP, + relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3). \ No newline at end of file diff --git a/contrib/template.appsettings-api.json b/contrib/template.appsettings-api.json new file mode 100644 index 000000000..b364eeef1 --- /dev/null +++ b/contrib/template.appsettings-api.json @@ -0,0 +1,33 @@ +{ + "Site": { + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["api"], + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN" + ], + "Bills": "bills", + "TempDir": "temp" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:92" + }, + "Https": { + "Url": "https://localhost:93", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.appsettings-blogs.json b/contrib/template.appsettings-blogs.json new file mode 100644 index 000000000..6f8fa02bc --- /dev/null +++ b/contrib/template.appsettings-blogs.json @@ -0,0 +1,40 @@ +{ + "Site": { + "Title": "Yavsc Blogs", + "Description": "A collection of blogs about software development, technology, and programming.", + "Keywords": "software development, technology, programming, coding, blogs", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["blogs"], + "CorsAllowedOrigins": [ + "https://preprod-api.$DOMAIN" + ], + "UserFilesRoot": "files" + }, + "ConnectionStrings": { + "YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Authentication": "Debug" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:90" + }, + "Https": { + "Url": "https://localhost:91", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.appsettings-org.json b/contrib/template.appsettings-org.json new file mode 100644 index 000000000..c330f5f32 --- /dev/null +++ b/contrib/template.appsettings-org.json @@ -0,0 +1,74 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "AllowedHosts": "*", + "Site": { + "Title": "yavsc", + "Slogan": "Yavsc!", + "StyleSheet": "/css/default.css", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": [ + "blogs", + "api" + ], + "ExternalUrl": "https://preprod-yavsc.$DOMAIN", + "ApiUrl": "https://preprod-api.$DOMAIN", + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN", + "https://preprod-api.$DOMAIN" + ], + "Banner": "/images/banner-dewang.jpg", + "Owner": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Admin": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Quota": 200000000, + "UserFilesRoot": "files", + "Avatars": "avatars", + "Bills": "bills", + "TempDir": "temp", + "DataDir": "data", + "GitRepository": "git" + }, + "Smtp": { + "Host": "mercure.pschneider.fr", + "Port": 465, + "SenderName": "Paul Schneider", + "SenderEmail": "paul@pschneider.fr", + "UserName": "paul", + "Password": "$SMTP_PASSWORD" + }, + "DataProtection": { + "Keys": { + "Dir": "DataProtection" + }, + "RSAParamFile": "RSA-Params.json", + "ExpiresInHours": 168 + }, + "Moderation": { + "AutoApproveThreshold": 0.7, + "AutoRejectThreshold": 0.9 + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:88" + }, + "Https": { + "Url": "https://localhost:89", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.vhost b/contrib/template.vhost new file mode 100644 index 000000000..b4c9c139e --- /dev/null +++ b/contrib/template.vhost @@ -0,0 +1,81 @@ +# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER. +# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT +# +# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un +# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443 +# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel, +# cf. template.appsettings-*.json — capacité, non joignable derrière nginx). +# +# Variables substituées par envsubst (allowlist) : +# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS +# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production +# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport +# une seule fois par addr:port (443/udp) — or preprod et prod partagent la +# même IP, donc seule la prod (Org) l'active. +# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont +# préservées (hors allowlist). + +# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges +# ACME ; ne pas servir l'app en HTTP clair.) +server { + listen 80; + listen [::]:80; + server_name $DOMAIN; + return 301 https://$host$request_uri; +} + +server { + # 1. TCP : HTTPS classique + HTTP/2 + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + # 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS). + listen 443 quic$QUIC_OPTS; + listen [::]:443 quic$QUIC_OPTS; + http3 on; + + server_name $DOMAIN; + + # 3. Certificats Let's Encrypt (Certbot). + ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot + # QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP : + # `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.) + ssl_protocols TLSv1.3; + + # 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur. + add_header Alt-Svc 'h3=":443"; ma=86400' always; + + location / { + proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 100s; + proxy_buffering off; + } + + # SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout + # long, sinon la connexion tombe au premier silence ou l'upgrade échoue. + # Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui + # exigerait une directive map {} globale hors de ce vhost) : SignalR + # négocie toujours un upgrade sur ce chemin, et la négociation initiale + # (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet + # en-tête côté Kestrel. + location /chathub { + proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_buffering off; + } + include server-status.conf; +} diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 70f3f2fdc..b1bf83cf7 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -126,6 +126,20 @@ le DI est construit. Ordre, dans cet ordre : > à cette table, *ne pas* ajouter de registration pour > `Settings` ailleurs que la ligne `AddSingleton(settings)`. +## Défilement de la page des billets + +La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête +et les commandes restent visibles, tandis que la liste des billets et +l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet +de modifier cette répartition. La liste et l'article défilent séparément. + +Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ; +l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter +la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir +dépasser le viewport pour que les barres automatiques s'activent. +Ne pas placer cette grille dans un `StackPanel` vertical ou un +`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée. + ## Navigation Le host de navigation est un `NavigationPage x:Name="NavRoot"` diff --git a/docker-compose.yaml b/docker-compose.yaml index f478dcc9f..ea08e9009 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,7 +26,7 @@ services: dockerfile: Dockerfile target: web-runtime args: - BUILD_ENV_TAG: debian12-dotnet10-android36-v1 + BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -53,9 +53,7 @@ services: depends_on: db: condition: service_healthy - networks: - - yavsc-internal - - yavsc-public + network_mode: host # volumes: # # Indispensable pour que Yavsc.Org (IdentityServer8) démarre. # # Sans ce montage, 'docker compose up' échoue côté web avec @@ -73,7 +71,7 @@ services: dockerfile: Dockerfile target: api-runtime args: - BUILD_ENV_TAG: debian12-dotnet10-android36-v1 + BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -90,9 +88,7 @@ services: depends_on: db: condition: service_healthy - networks: - - yavsc-internal - - yavsc-public + network_mode: host # volumes: # - /etc/letsencrypt:/etc/letsencrypt:ro @@ -105,7 +101,7 @@ services: dockerfile: Dockerfile target: blogs-runtime args: - BUILD_ENV_TAG: debian12-dotnet10-android36-v1 + BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -122,9 +118,7 @@ services: depends_on: db: condition: service_healthy - networks: - - yavsc-internal - - yavsc-public + network_mode: host # volumes: # - /etc/letsencrypt:/etc/letsencrypt:ro diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 42d09cd55..a353e4b3a 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -4,7 +4,7 @@ true - 12.1.2 + 12.1.3 @@ -13,16 +13,21 @@ - + + + - - + + + + + @@ -31,4 +36,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 549100804..f8fd5a0fd 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -2,6 +2,7 @@ using Android.App; using Android.Content; using Android.Content.PM; +using Android.Views; using AndroidX.Core.Provider; using AndroidX.Emoji2.Text; using Avalonia.Android; @@ -15,7 +16,9 @@ namespace PostIt.Android; Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, - ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] + WindowSoftInputMode = SoftInput.AdjustResize, + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode) + ] public class MainActivity : AvaloniaMainActivity { /// diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 46702b3a8..18bfec459 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,7 +11,7 @@ false 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 @@ -27,4 +27,14 @@ - \ No newline at end of file + + + + + + + diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 8793aae8a..256555df1 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,6 +1,10 @@  - + diff --git a/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml new file mode 100644 index 000000000..6d2c97559 --- /dev/null +++ b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + 10.0.2.2 + + diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index bf967849b..f2b2a2bbf 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,7 +6,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 12e9a8143..46df5025f 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,7 +7,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index a2b8cd66f..1c5aa0c27 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -1,6 +1,7 @@ using System; using Avalonia; using PostIt.Services; +using PostIt.CLIOptions; namespace PostIt.Desktop; diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 112c11752..741b3863c 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) { _recorder.Calls.Add((method, path, contentFactory)); + if (typeof(T) == typeof(BlogPostDto)) + return Task.FromResult((T)(object)new BlogPostDto + { + Id = 42, + Title = "Mon premier billet", + AuthorId = "tester", + Article = "Contenu du billet de test.", + }); return Task.FromResult(default(T)!); } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 946d3d28e..c9d7d3aae 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,8 +20,13 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [Fact] - public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] + [InlineData("rapport final.pdf", "rapport%20final.pdf")] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority( + string originalName, string urlName) { const string authority = "https://idp.example.test"; @@ -53,27 +58,59 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - "note.txt", + originalName, System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); + var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; + Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle); + Assert.Empty(recorder.Calls); + var draftBeforeSave = vm.DraftArticle; + await vm.SaveAsync(); - // The update branch issues two PUTs: the multipart one - // carrying the file, then — because TryAppendAttachmentLinks - // appended a link — a JSON one carrying the final article. - var linkPut = recorder.Calls.LastOrDefault( - c => c.method == HttpMethod.Put); + var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); Assert.NotEqual(default, linkPut); var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; - var sentStringContent = multipartContent.First() as StringContent ; + var sentStringContent = Assert.IsType(multipartContent.First()); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + using var document = System.Text.Json.JsonDocument.Parse(sentString); + var article = document.RootElement.GetProperty("article").GetString(); + Assert.Contains($"- [{originalName}]({expectedUrl})", article); // The regression shape: a relative, authority-less link. - Assert.DoesNotContain("](/files/", sentString); + Assert.DoesNotContain("](/files/", article); + Assert.Equal(draftBeforeSave, vm.DraftArticle); + Assert.NotNull(vm.SelectedPost); + Assert.False(vm.IsDraftModified); + } + + [Fact] + public async Task New_post_resolves_only_the_provisional_link_at_first_save() + { + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"), + new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } }); + vm.DraftTitle = "Nouveau billet"; + vm.DraftArticle = "Texte conservé."; + vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 })); + + Assert.Contains("postit-attachment:", vm.DraftArticle); + Assert.Empty(recorder.Calls); + await vm.SaveAsync(); + + Assert.Equal("Nouveau billet", vm.DraftTitle); + Assert.Contains("Texte conservé.", vm.DraftArticle); + Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)", + vm.DraftArticle); + Assert.DoesNotContain("postit-attachment:", vm.DraftArticle); + Assert.Equal(42, vm.SelectedPost?.Id); + Assert.Empty(vm.DraftAttachments); + Assert.False(vm.IsDraftModified); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); } } diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index db70e25fa..31c82387d 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -1,4 +1,5 @@ using System.Net.Http; +using PostIt.Models; using PostIt.ViewModels; using Yavsc; using Yavsc.Api.Client; @@ -22,7 +23,8 @@ public class EstimateEditionPageViewModelTests private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) { var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - return new EstimateEditionPageViewModel(query ?? SampleQuery(), client); + var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); + return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient); } [Fact] @@ -143,10 +145,8 @@ public class EstimateEditionPageViewModelTests await vm.SendCommand.ExecuteAsync(null); - Assert.Equal("https://business.example/api/v1/estimate", api.LastPath); - Assert.Equal(HttpMethod.Post, api.LastMethod); - - var payload = Assert.IsType(api.LastBody); + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); Assert.Equal(42, payload.CommandId); Assert.Equal("cli-1", payload.ClientId); Assert.Equal("Brush", payload.CommandType); @@ -216,7 +216,8 @@ public class EstimateEditionPageViewModelTests await vm.SendCommand.ExecuteAsync(null); - var payload = Assert.IsType(api.LastBody); + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); Assert.Equal(2, payload.Bill.Count); Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.True(vm.HasSent); @@ -230,11 +231,20 @@ public class EstimateEditionPageViewModelTests public object? LastBody { get; private set; } public Exception? Failure { get; init; } - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + /// Historique complet des appels (le plus récent en dernier). + public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new(); + + private void Record(HttpMethod method, string path, object? body) { LastMethod = method; LastPath = path; LastBody = body; + Calls.Add((method, path, body)); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Record(method, path, body); if (Failure is not null) { @@ -253,9 +263,7 @@ public class EstimateEditionPageViewModelTests public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - LastMethod = method; - LastPath = path; - LastBody = body; + Record(method, path, body); return Task.CompletedTask; } @@ -265,6 +273,12 @@ public class EstimateEditionPageViewModelTests public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) => CallAsync(method, path, (object?)null, ct); + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + Record(method, path, null); + return Task.FromResult(Array.Empty()); + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs new file mode 100644 index 000000000..667ea599a --- /dev/null +++ b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs @@ -0,0 +1,199 @@ +using System.Net.Http; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +using Yavsc.Abstract.Models.Messaging; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class HomePageNotificationsTests +{ + [Fact] + public async Task Refresh_loads_public_notifications_from_the_business_host() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + transport.Response = Task.FromResult(new List { Note(1, "Information") }); + + await vm.RefreshNotificationsAsync(); + + Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath); + Assert.True(transport.WasAnonymous); + Assert.Equal("Information", Assert.Single(vm.Notifications).title); + Assert.False(vm.IsLoadingNotifications); + Assert.False(vm.HasNoNotifications); + Assert.Null(vm.NotificationsError); + } + + [Fact] + public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + vm.Notifications.Add(Note(1, "Old")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.True(vm.HasNoNotifications); + + transport.Response = Task.FromException>(new HttpRequestException("Server unavailable")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.False(vm.HasNoNotifications); + Assert.Contains("Server unavailable", vm.NotificationsError); + Assert.False(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Recovered") }); + await vm.RefreshNotificationsAsync(); + Assert.Null(vm.NotificationsError); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + + [Fact] + public async Task Refresh_ignores_an_older_response_after_a_newer_refresh() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + var oldResponse = new TaskCompletionSource>(); + transport.Response = oldResponse.Task; + var oldLoad = vm.RefreshNotificationsAsync(); + Assert.True(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Current") }); + await vm.RefreshNotificationsAsync(); + oldResponse.SetResult(new List { Note(1, "Stale") }); + await oldLoad; + + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.False(vm.IsLoadingNotifications); + } + + [AvaloniaFact] + public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data() + { + var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json"); + var settings = new Settings(); + var store = new TokenStore(tokenPath); + store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", + DateTimeOffset.UtcNow.AddHours(1), null)); + await using var api = new YavscApiClient(settings, store); + try + { + var session = new SessionStatusViewModel { Api = api }; + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport, session); + session.Refresh(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + var privateResponse = new TaskCompletionSource>(); + transport.Response = privateResponse.Task; + var privateLoad = vm.RefreshNotificationsAsync(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + + vm.Notifications.Add(Note(9, "Private")); + transport.Response = Task.FromResult(new List { Note(2, "Public") }); + session.SetError("Disconnected"); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.True(transport.WasAnonymous); + + privateResponse.SetResult(new List { Note(1, "Late private data") }); + await privateLoad; + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + finally + { + File.Delete(tokenPath); + } + } + + [AvaloniaFact] + public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies() + { + var transport = new NotificationTransport + { + Response = Task.FromResult(new List { Note(1, "Notification title") }) + }; + var vm = CreateViewModel(transport); + var page = new HomePage { DataContext = vm }; + var nav = new NavigationPage(); + var window = new Window { Content = nav, Width = 320, Height = 450 }; + try + { + window.Show(); + await nav.PushAsync(page); + window.UpdateLayout(); + + Assert.Single(vm.Notifications); + var texts = page.GetVisualDescendants().OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "Notification title" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + Assert.Contains(texts, t => t.Text == "Notification body" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + var refresh = Assert.Single(page.GetVisualDescendants().OfType