diff --git a/.dockerignore b/.dockerignore index 45b7c22b..444995d0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ **/bin/ **/obj/ **/.playwright/ +.git/ .vs/ .github/ # Exclure uniquement les dossiers de sortie de compilation @@ -13,4 +14,5 @@ test/*/obj/ # Exclure les caches lourds **/.playwright/ +.git/ .vs/ diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index a0f3a375..cda246cc 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -21,28 +21,33 @@ on: push: branches: [ "main" ] pull_request: - branches: [ "main", "release/*" ] + branches: [ "main" ] jobs: + log-the-inputs: + runs-on: debian-latest + steps: + - run: | + echo "Log level: $LEVEL" + echo "Tags: $TAGS" + echo "Environment: $ENVIRONMENT" + env: + LEVEL: ${{ inputs.logLevel }} + TAGS: ${{ inputs.tags }} + build: - runs-on: docker + runs-on: debian-latest steps: - - name: Clone yavsc - run: | - cd /src - git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src - cd _src - if [ -n "${GITHUB_REF:-}" ]; then - git fetch origin "$GITHUB_REF" - git checkout FETCH_HEAD - fi - git submodule update --init --recursive - echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" + - uses: actions/checkout@v6 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x - name: Restore dependencies - run: cd /src/_src && dotnet restore + run: dotnet restore - name: Build - run: cd /src/_src && dotnet build --no-restore + run: dotnet build --no-restore - name: Test - run: cd /src/_src && dotnet test --no-build --verbosity normal + run: dotnet test --no-build --verbosity normal diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index 3d4fc0ac..00000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,330 +0,0 @@ -# Build and publish a release on the Forgejo source-of-truth instance -# with the PostIt Android APK as an attached asset. -# -# Triggered by a push of a git tag. Validates the tag/changelog pair, -# builds the APK using the existing Dockerfile (--target build-env), then -# publishes a Forgejo release via the Forgejo REST API and uploads the -# APK as an asset. -# -# Authentication uses ${{ secrets.GITHUB_TOKEN }} (auto-provided by the -# Forgejo runner, scoped to contents: write for the current repo). A -# dedicated PAT (${{ secrets.RELEASE_TOKEN }}) was the preferred option -# for least-privilege, but creating repo-level secrets is currently -# broken on this Forgejo instance (InsertEncryptedSecret fails with a -# UTF-8 byte-sequence error, probably a text-vs-bytea column type on -# the secret table). Bumping to Forgejo v16 should fix it; until then, -# the runner-provided token keeps the workflow operational. -# -# Why bash + jq + curl, no third-party actions: the runner's docker -# label points at pazof/yavsc-build-env, a Debian image with jq but -# without Node.js or python3. Any action like actions/checkout, -# rasterstate/forgejo-release-action, etc. fails with "executable -# file not found in $PATH". jq is shipped in the image from -# debian12-dotnet10-android36-v2 onward; earlier tags fell back to -# hand-rolled JSON building via sed, which was fragile (cf. PR #30: -# sed greedy + head -3 still matched author.id instead of the -# release id on the minified JSON this instance returns, PATCH -# /releases/1 → 404). Same constraint as -# .forgejo/workflows/buildAndTest.yml. -# -# This workflow complements .github/workflows/docker-publish-android.yml -# which targets the GitHub mirror; the validate-release logic mirrors -# the GitHub-side job so the two channels stay consistent. -name: Forgejo Release - -on: - push: - tags: - - '*' - workflow_dispatch: - inputs: - tag: - description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).' - required: true - type: string - -permissions: - contents: write - -jobs: - # Job unique : validation tag/CHANGELOG + build APK + publication - # via l'API REST Forgejo (pas d'actions tierces Node). - release: - runs-on: docker - steps: - - name: Clone du repo au tag demandé - env: - # En push tag : github.ref_name est le tag. - # En workflow_dispatch : on lit l'input 'tag'. - TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} - run: | - if [[ -z "$TAG" ]]; then - echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." - exit 1 - fi - - # WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile). - cd /src - - # Clone unshallow pour que GitVersion.MsBuild ait l'historique - # et les tags (sinon MSB3073 sur la cible Android cf. PR #21). - if [[ ! -d _src/.git ]]; then - git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src - fi - - cd _src - git fetch --tags --force --prune origin - git checkout "$TAG" - - echo "Checked out at $(git rev-parse HEAD) on $(git describe --tags --always 2>/dev/null || echo unknown)" - - - name: Valider le tag et la section CHANGELOG - run: | - cd /src/_src - TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || git rev-parse --short HEAD)" - echo "Validating tag $TAG" - - # Parse semver : MAJOR.MINOR.PATCH[-SUFFIX] - if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then - echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." - exit 1 - fi - - MAJOR="${BASH_REMATCH[1]}" - MINOR="${BASH_REMATCH[2]}" - PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" - - # Classification du canal par parité du patch. - # Patch pair + pas de suffixe -> stable. - # Patch impair + pas de suffixe -> preview. - # Suffixe présent -> instable. - if [[ -n "$SUFFIX" ]]; then - CHANNEL="unstable" - elif (( PATCH % 2 == 0 )); then - CHANNEL="stable" - else - CHANNEL="preview" - fi - - echo "Tag $TAG classifié comme channel=$CHANNEL" - - # Seuls les suffixes explicitement autorisés déclenchent un - # release : -rcN et -betaN. Les autres suffixes (-alpha*, - # -dev*, -preview*, etc.) restent refusés — ils sont - # utilisables localement pour itérer, mais ne doivent pas - # être publiés comme release publique. - if [[ "$CHANNEL" == "unstable" ]]; then - if [[ ! "$SUFFIX" =~ ^-(rc|beta)([0-9]+)?$ ]]; then - echo "::error::Tag '$TAG' has suffix '$SUFFIX' which is not in the allowed release suffixes (-rcN, -betaN). Refusing to publish." - exit 1 - fi - fi - - # Lecture du CHANGELOG.md (doit exister à la racine du repo). - if [[ ! -f CHANGELOG.md ]]; then - echo "::error::CHANGELOG.md not found at repo root." - exit 1 - fi - - # Extraction de la section [TAG]. On cherche la première ligne - # commençant par '## [' qui contient '[TAG]' (entre '## [' et - # la prochaine ligne '## [' ou fin de fichier). awk en mode - # paragraphe suffit et reste POSIX. On garde aussi le titre - # (ligne `## [TAG] - channel`) pour la vérification du canal. - BODY=$(awk -v tag="[$TAG]" ' - /^## \[/ { - if (in_section) exit - if (index($0, tag) > 0) { - in_section=1 - print - next - } - } - in_section { print } - ' CHANGELOG.md) - - if [[ -z "$BODY" ]]; then - echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md." - echo "Add a '## [$TAG] - $CHANNEL' section before tagging." - exit 1 - fi - - # Vérification cohérence du canal déclaré dans le suffixe. - # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - # On lit la première ligne du body qui contient le titre. - TITLE=$(echo "$BODY" | head -1) - if [[ "$TITLE" != *" - $CHANNEL"* ]]; then - echo "::error::Section title '$TITLE' must declare suffix '- $CHANNEL' to match tag parity." - exit 1 - fi - - # Body pour la release : retire la première ligne (titre). - BODY=$(echo "$BODY" | tail -n +2) - - echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" - - # Expose channel + body pour les étapes suivantes via $GITHUB_ENV. - echo "RELEASE_CHANNEL=$CHANNEL" >> "$GITHUB_ENV" - echo "RELEASE_BODY<> "$GITHUB_ENV" - echo "$BODY" >> "$GITHUB_ENV" - echo "EOF" >> "$GITHUB_ENV" - echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - - - name: Build des projets .NET (sans docker) - # L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le - # workload Android, mais PAS le binaire `docker` ni de daemon - # Docker. On exécute donc les commandes dotnet directement - # au lieu de passer par `docker build`. - # Equivalent des stages build-env du Dockerfile (lignes - # restore + build Yavsc.Org + build Yavsc.Api + build - # Yavsc.Blogs + build PostIt.Android -r android-arm64). - run: | - cd /src/_src - dotnet restore - dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ - -c Release --no-restore -clp:ErrorsOnly -r android-arm64 - - - name: Copier l'APK signé vers un emplacement connu - # Le build Android avec -r android-arm64 produit l'APK dans - # bin/Release/net10.0-android/android-arm64/. On le copie à - # la racine du checkout pour que l'étape d'upload le trouve. - run: | - cd /src/_src - APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk - if [[ ! -f "$APK" ]]; then - echo "::error::APK not found at $APK" - ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true - exit 1 - fi - cp "$APK" /src/_src/PostIt.Android.apk - ls -la /src/_src/PostIt.Android.apk - - - name: Publier la release Forgejo via l'API REST - # Pas d'action tierce (pas de Node dans l'image runner). - # On parle à l'API Forgejo directement via curl. - # Docs : https://forgejo.pschneider.fr/api/swagger#/repository/release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_REPOSITORY: ${{ github.repository }} - TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} - RELEASE_BODY: ${{ env.RELEASE_BODY }} - IS_PRERELEASE: ${{ env.IS_PRERELEASE }} - run: | - if [[ -z "$TAG" ]]; then - echo "::error::No tag resolved for the API call." - exit 1 - fi - - # Le runner Forgejo expose l'API sur github.api_url (par - # défaut http://…/api/v1). On retire le suffixe /api/v1 s'il - # est présent pour dériver la base du serveur, puis on - # reconstruit l'URL de l'API proprement. - API_BASE="${GITHUB_API_URL%/}" - API_BASE="${API_BASE%/api/v1}" - - # Construction des bodies JSON et extraction de champs via - # jq. L'image runner pazof/yavsc-build-env installe jq - # (>= 1.7) depuis debian12-dotnet10-android36-v2. La - # chaîne de construction --arg/--argjson garantit un - # escaping correct (backslashes, guillemets, newlines, - # caractères de contrôle Unicode) sans avoir à le - # reproduire à la main. - # - # json_escape et json_field à base de sed ont vécu : le - # sed greedy matche la dernière occurrence d'un champ - # dans la ligne, et l'API renvoie sur cette instance un - # JSON minifié d'une seule ligne où l'id de l'auteur - # (1, premier user du repo) suit l'id de la release - # (10706). PATCH /releases/ tombait - # alors en 404 "The target couldn't be found". jq - # résout les deux problèmes en une fois. - - # 1. Vérifier si la release existe déjà pour ce tag. - echo "::group::Check existing release for tag $TAG" - HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Accept: application/json" \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG") - echo "GET releases/tags/$TAG -> HTTP $HTTP" - EXISTING_ID="" - if [[ "$HTTP" == "200" ]]; then - EXISTING_ID=$(jq -r '.id // empty' /tmp/existing.json) - echo "Existing release id: ${EXISTING_ID:-none}" - fi - echo "::endgroup::" - - # 2. Créer ou mettre à jour la release. - if [[ -n "$EXISTING_ID" ]]; then - echo "::group::Update release id=$EXISTING_ID" - jq -n \ - --arg body "$RELEASE_BODY" \ - --argjson prerelease "$IS_PRERELEASE" \ - '{body: $body, prerelease: $prerelease}' \ - > /tmp/patch.json - HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ - -X PATCH \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - --data-binary @/tmp/patch.json \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$EXISTING_ID") - echo "PATCH release -> HTTP $HTTP" - echo "::endgroup::" - else - echo "::group::Create release" - jq -n \ - --arg tag "$TAG" \ - --arg name "$TAG" \ - --arg body "$RELEASE_BODY" \ - --argjson prerelease "$IS_PRERELEASE" \ - '{tag_name: $tag, name: $name, body: $body, prerelease: $prerelease}' \ - > /tmp/post.json - HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - --data-binary @/tmp/post.json \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases") - echo "POST release -> HTTP $HTTP" - echo "::endgroup::" - fi - - if [[ "$HTTP" != "200" && "$HTTP" != "201" ]]; then - echo "::error::Release creation/update failed (HTTP $HTTP):" - cat /tmp/release.json - exit 1 - fi - - RELEASE_ID=$(jq -r '.id' /tmp/release.json) - echo "Release id=$RELEASE_ID" - - # 3. Upload l'APK en asset. - # Le nom du fichier passe en query string (?name=...), pas - # en argument positionnel entre --data-binary et l'URL : - # sinon curl l'interprète comme un second fichier d'input - # (un fichier nommé '?name=PostIt.Android.apk') et l'API - # Forgejo renvoie 400 "Missing 'name' parameter". - echo "::group::Upload APK asset" - HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - -H "Accept: application/json" \ - --data-binary "@/src/_src/PostIt.Android.apk" \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk") - echo "POST asset -> HTTP $HTTP" - echo "::endgroup::" - - if [[ "$HTTP" != "201" ]]; then - echo "::error::Asset upload failed (HTTP $HTTP):" - cat /tmp/asset.json - exit 1 - fi - - echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 48d3b4d1..ae9780df 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -59,7 +59,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v4 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml index b9ee364c..25c3aa2d 100644 --- a/.github/workflows/docker-publish-android.yml +++ b/.github/workflows/docker-publish-android.yml @@ -4,20 +4,7 @@ on: push: branches: - main - tags: - - '*' workflow_dispatch: - inputs: - force_unstable: - description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.' - required: false - type: boolean - default: false - -# softprops/action-gh-release a besoin de contents: write -# pour publier une release + uploader un asset. -permissions: - contents: write jobs: apk-deploy: @@ -25,9 +12,6 @@ jobs: steps: - name: Checkout du code uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true # 1. Votre étape de build actuelle (on nomme l'image "postit-android") # --target build-env : on ne veut que le stage de build (qui @@ -50,134 +34,3 @@ jobs: path: ./PostIt.Android.apk retention-days: 7 - # Job de validation : parse le tag, vérifie le format, applique la règle - # de parité du patch (pair=stable / impair=preview / suffixe=instable), - # et s'assure que CHANGELOG.md contient une section cohérente. - # Sans ce job, le job publish-release peut être bypassé (un attaquant - # qui contrôle un tag ne peut pas publier de release sans une section - # changelog cohérente). - validate-release: - if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Valider le tag et la section CHANGELOG - env: - FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }} - run: | - TAG="${GITHUB_REF_NAME}" - - # Parse semver : MAJOR.MINOR.PATCH[-SUFFIX] - if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then - echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." - exit 1 - fi - - MAJOR="${BASH_REMATCH[1]}" - MINOR="${BASH_REMATCH[2]}" - PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" - - # Classification du canal par parité du patch. - # Patch pair + pas de suffixe -> stable. - # Patch impair + pas de suffixe -> preview. - # Suffixe présent -> instable. - if [[ -n "$SUFFIX" ]]; then - CHANNEL="unstable" - elif (( PATCH % 2 == 0 )); then - CHANNEL="stable" - else - CHANNEL="preview" - fi - - echo "Tag $TAG classifié comme channel=$CHANNEL" - - # Fail-fast sur instable sauf opt-in explicite via workflow_dispatch. - if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then - echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish." - echo "Set force_unstable=true via workflow_dispatch to override." - exit 1 - fi - - # Lecture du CHANGELOG.md (doit exister à la racine du repo). - if [[ ! -f CHANGELOG.md ]]; then - echo "::error::CHANGELOG.md not found at repo root." - exit 1 - fi - - # Extraction de la section [TAG]. On cherche la première ligne - # commençant par '## [' qui contient '[TAG]' (entre '## [' et - # la prochaine ligne '## [' ou fin de fichier). awk en mode - # paragraphe suffit et reste POSIX. - BODY=$(awk -v tag="[$TAG]" ' - /^## \[/ { - if (in_section) exit - if (index($0, tag) > 0) in_section=1 - next - } - in_section { print } - ' CHANGELOG.md) - - if [[ -z "$BODY" ]]; then - echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md." - echo "Add a '## [$TAG] - $CHANNEL' section before tagging." - exit 1 - fi - - # Vérification cohérence du canal déclaré dans le titre de section. - # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md) - if [[ "$HEADER" != *" - $CHANNEL"* ]]; then - echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." - echo "Current section header: $HEADER" - exit 1 - fi - - echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" - - # Exposition aux étapes suivantes via $GITHUB_ENV. - # heredoc <> "$GITHUB_ENV" - - publish-release: - # Déclenché uniquement par un push de tag. Le job apk-deploy produit - # l'artefact ; validate-release garantit la cohérence du tag et du - # changelog avant publication. - if: startsWith(github.ref, 'refs/tags/') - needs: [apk-deploy, validate-release] - runs-on: ubuntu-latest - steps: - - name: Récupérer l'APK depuis l'artefact - uses: actions/download-artifact@v7 - with: - name: application-apk-release - path: ./ - - - name: Publier la release GitHub et uploader l'APK - uses: softprops/action-gh-release@v2 - with: - # Le nom de fichier final dans la release. C'est ce qui - # apparaîtra dans l'asset et donc dans le permalink : - # https://github.com///releases/latest/download/PostIt.Android.apk - files: ./PostIt.Android.apk - # Le body est extrait de la section CHANGELOG.md correspondant - # au tag, exposée par validate-release via $GITHUB_ENV. - body: ${{ env.RELEASE_BODY }} - # stable -> false (marque comme Latest). - # preview / unstable -> true (visible mais pas Latest). - prerelease: ${{ env.IS_PRERELEASE }} diff --git a/.gitignore b/.gitignore index a94475e3..fb843f96 100644 --- a/.gitignore +++ b/.gitignore @@ -24,15 +24,6 @@ data/ appsettings.*.json appsettings-*.*.json -# Exception: the Testing-environment override for Yavsc.Org is a tracked -# configuration source, not a secrets file. TestWebApplicationFactory -# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so -# AddConfiguration("org") in Program.Main loads this file as the -# last in the chain (it is optional). It overrides the connection -# string and SMTP section for the in-memory test host and contains -# no production secrets. -!src/Yavsc.Org/appsettings-org.Testing.json - generated/ *.tmp DataDir/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index eadf3c7f..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "external/dotnet-android-build-image"] - path = external/dotnet-android-build-image - url = https://forgejo.pschneider.fr/notazof/dotnet-android-build-image.git diff --git a/.vscode/launch.json b/.vscode/launch.json index 76dc08d5..c374bc6b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,7 @@ "name": "Yavsc.Org", "type": "dotnet", "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", + "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj" }, { "name": "Yavsc.Blogs", diff --git a/.vscode/settings.json b/.vscode/settings.json index 16bbe483..0a4785b9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,10 +12,8 @@ "DOTNET", "ecdsa", "envsubst", - "Hsts", "Newtonsoft", "Npgsql", - "PKCE", "postit", "pschneider", "SLNDIR", @@ -28,17 +26,5 @@ "cSpell.language": "fr,en", "makefile.configureOnOpen": false, "search.useGlobalIgnoreFiles": true, - "search.useParentIgnoreFiles": true, - "chat.mcp.serverSampling": { - "yavsc/.vscode/mcp.json: openclaw": { - "allowedModels": [ - "copilot/auto", - "copilotcli/claude-haiku-4.5", - "copilotcli/gpt-4.1", - "copilotcli/gpt-5-mini", - "copilotcli/mai-code-1-flash-picker", - "copilotcli/gpt-5.3-codex" - ] - } - } + "search.useParentIgnoreFiles": true } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e45a9921..c384ec79 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,17 +13,16 @@ "isBackground": true }, { - "label": "test blogs backend", + "label": "build-web", "type": "process", "problemMatcher": ["$msCompile"], "command": "dotnet", - "args": ["test"], + "args": ["build"], "options": { - "cwd": "src/Yavsc.Blogs.Tests" + "cwd": "src/Yavsc.Org" }, "group": { - "kind": "test", - "isDefault": false + "kind": "build" } }, { @@ -41,23 +40,17 @@ "isBackground": true }, { - "label": "test blogs", + "label": "build-web", "type": "process", "problemMatcher": ["$msCompile"], "command": "dotnet", - "args": ["test"], - "runOptions": { - "instanceLimit": 1 - }, + "args": ["build"], + "runOptions": {}, "options": { - "cwd": "src/Yavsc.Blogs", - "env": { - "DOTNET_CLI_UI_LANGUAGE": "en-US", - "ASPNETCORE_ENVIRONMENT": "Development" - } + "cwd": "src/Yavsc.Web" }, "group": { - "kind": "test" + "kind": "build" }, "isBackground": true, "presentation": { @@ -89,7 +82,7 @@ ], "problemMatcher": "$msCompile", "runOptions": { - + } } diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e2a446a0..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,209 +0,0 @@ -# Changelog - -Toutes les modifications notables de PostIt et de la plateforme Yavsc -sont documentées dans ce fichier. - -Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), -et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -À noter : la **parité du numéro de patch** porte une signification de canal : - -- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** -- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** -- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** - -Cette convention est partagée avec le dépôt -[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) -pour la production des paquets `.deb`. - -## [1.0.8-rc1] - unstable - -### Added -- `BlogAclApiTests.PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test` - : test de non-régression qui épingle la forme exacte du payload - que PostIt envoie à `POST /api/v1/blogacl` (un objet - `PostAccessControlRulePayload` avec `CircleId` et `BlogPostId`). - C'est le verrou côté test du fix applicatif PostIt + serveur. -- `BlogAclApiTests.PostCircleAuthorization_never_returns_500` : une - `[Theory]` couvrant quatre shapes de payload (`{ circleId }`, - corps vide, `{ blogPostId }` seul, `{ circleId, blogPostId: 0 }`) - qui doivent tous retourner un statut différent de 500. Toute - réintroduction d'un chemin 500 dans le futur fera rougir ce test. -- `BlogAclApiTests.PostCircleAuthorization_dosent_return_500` et - `..._dosent_return_500_on_success` : entry points `[Fact]` qui - appellent la `[Theory]` ci-dessus avec un payload spécifique - chacun, pour pouvoir filtrer en isolation depuis la ligne de - commande ou le CI. -- Règle « Pas de `object` dans le code source applicatif » ajoutée - à `CONTRIBUTING.md` : types de retour, paramètres, champs, - propriétés, variables locales doivent être typés statiquement. - `dynamic` est interdit pour les mêmes raisons. - -### Changed -- `BlogAclApiController.CheckOwner` devient `CheckOwnerAsync` et - utilise `FirstOrDefaultAsync` au lieu de `First`, supprimant - l'appel LINQ synchrone sur le fil de la requête et retournant - `false` sur cercle manquant (le contrôleur mappe déjà cela vers - `ChallengeResult`). -- `BlogsWebServerFixture` seed `alice`, son `Circle` et son - `BlogPost` une seule fois au démarrage du host, sur la - `SqliteConnection` partagée (`Cache=Shared`). Le précédent - `EnsureDeleted` au début de chaque test fermait la connexion - statique et détruisait le store `:memory:` pour tous les autres - `DbContext` ; il est retiré au profit d'un `EnsureCreated` - idempotent. - -### Fixed -- `POST /api/v1/blogacl` ne retourne plus 500 sur les payloads - dont `BlogPostId` est absent ou à zéro. Le contrôleur rejette - `BlogPostId <= 0` avec `400 BadRequest` avant que la requête - n'atteigne `SaveChangesAsync`. L'incident de prod du 2026-08-21 - sur mercure (PostIt envoyant seulement `circleId`, le serveur - voyant `BlogPostId = default(long) = 0` et EF Core levant - `InvalidOperationException` sur l'INSERT) n'est plus atteignable. -- PostIt `PostAclDialogViewModel.AddAsync` envoie désormais le - payload explicite `PostAccessControlRulePayload { CircleId, - BlogPostId }` au lieu de l'ancien `CircleAuthorization { - CircleId }`. Le DTO serveur `PostAccessControlRulePayload` est - introduit dans `Yavsc.Abstract` pour porter le contrat. - -## [1.0.7] - preview - -### Added -- Per-post ACL in PostIt: a new “Manage ACL” page, opened from the ACL - button on a selected post, lets the post author grant or revoke - grants for individuals or circles. The server scopes each grant - operation to `caller == post.AuthorId` and returns `404` (not `403`) - for posts the caller does not own, so the existence of another - user's post is not leaked. -- Circle membership API + UI: three new REST endpoints under - `/api/circle/{id}/members` (`GET` list, `POST` add, `DELETE` - remove) and a new “Members” column on the *My Circles* page with an - “Add a member” button that opens a search modal. The search modal - reuses `IUserDirectory` (introduced by the `IContactService` split - in this same release) — exactly the use case the abstraction was - carved out for. -- Publish toggle for blog posts: a new `PUT /api/BlogApi/{id}/publish` - endpoint, and a `Published` checkbox in the post toolbar that - toggles a `BlogSpotPublication` row for the post. The publish - signal flows through the pre-existing `PermissionHandler.IsPublic` - path, so no new column was needed and the server-side authorisation - logic is unchanged. -- `UserSearchApiController` in `Yavsc.Blogs`: - `GET /api/user-search?q=...&e=...&take=...`. Any-authenticated- - caller endpoint that exposes the user's email under a closed- - community assumption (documented in the controller's XML doc). - Wired to the PostIt Desktop address book so the user search modal - picks it up. -- `IYavscApiClient` abstraction in `Yavsc.Api.Client`. The transport - for the blog/circle/blog-acl/user-search clients is now accessed - through this interface, so `PostIt.Tests` can stub the HTTP layer - without spinning up a real WebAPI host. -- Forgejo Actions release workflow: a `.forgejo/workflows/release.yml` - pipeline that builds and publishes a release with the PostIt APK - on tag push. Written in pure bash (the runner image has no Node), - uses `jq` for JSON body construction and response parsing, uses the - runner-provided `GITHUB_TOKEN` (no repo-level secret needed), - validates the CHANGELOG section heading before allowing the tag - to ship. -- `make release V=` target: creates a `release/` branch - from `main`, bumps the `` property in every `.csproj` via - `dotnet-gitversion /updateprojectfiles`, commits the bump on the - release branch, and pushes to `origin`. Fails fast if the working - tree is dirty or if `HEAD` is not on `main`. -- Forgejo status badges in the README. - -### Changed -- The new Publish toggle replaces the “Visibility enum” approach - originally drafted in this branch: the existing `BlogSpotPublication` - table already carried enough information to expose a publish - switch, so no schema change was needed. The original `feat(blog): - add Visibility { Private, Public }` commit and its EF migration - were reverted in favour of the endpoint-only toggle. -- `BlogPost` DTO and `IBlogPost` moved from `PostIt.Models` to - `Yavsc.Abstract.Blogspot`, the shared assembly where the server-side - entity and the wire DTO both live. Renamed `Yavsc.Blogspot.BlogPost` - to `BlogPostDto` to make the wire/entity distinction explicit. -- `BlogAclApiController` and `CircleApiController` moved from - `Yavsc.Api` (not yet enabled in production) to `Yavsc.Blogs`, where - they belong next to the `BlogSpotService` they depend on. -- `IContactService` split from `IUserDirectory`: the two interfaces - previously conflated the local address-book access (mobile-only, - via `Contacts.Default`) and the Yavsc user-search access - (Desktop-only, via `/api/user-search`) behind a single facade. The - split restores the `ContactDto.Emails` multi-value shape that was - being silently flattened to a single string before. -- CI: the Forgejo Actions build now compiles `.csproj` projects - directly inside the runner container (which ships the .NET SDK + - Android workload), instead of relying on a separate Docker build - step. Node-based third-party actions were replaced with bash + curl - + `jq`. The validate-release job parses the CHANGELOG section - heading to derive the channel (`stable` / `preview` / `unstable`) - rather than the patch-version parity alone. - -### Fixed -- `CircleApiController` used to read the caller's user id via - `FindFirstValue(ClaimTypes.NameIdentifier)`, which does not match - when JWT Bearer middleware has `MapInboundClaims = false`. Switched - to `User.GetUserId()` (tries `sub` first, then - `ClaimTypes.NameIdentifier`, then `nameid`). This was a latent - bug visible in tests but easy to ship to production if a host - ever disabled the remap. -- `CircleApiController` and `BlogAclApiController` reads and writes - were not always scoped to the caller's own data. Tightened the - authorisation checks: cross-user reads now return `404`, not the - raw record. -- `validate-release` CHANGELOG channel check used to parse the - patch-version parity only, which disagreed with the channel - suffix in the section heading (e.g. `## [1.0.7] - preview` - would be flagged as `stable` from the parity alone). The job now - inspects the heading line and trusts the suffix when present. -- `.forgejo/workflows/release.yml`: the asset-upload URL now carries - the asset name as a query-string parameter instead of a `curl` - positional argument. The previous shape triggered Forgejo's - “Missing `name` parameter” 400 in some cases. - -### Removed -- The `## [Unreleased]` block has been moved into this section. -- The abandoned `Visibility { Private, Public }` enum and its EF - migration, reverted in this release. The publish toggle covers - the same user-visible switch without a schema change. - -[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD -[1.0.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1 -[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7 -[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 - -## [1.0.6] - stable - -### Added -- Self-hosted Forgejo Actions runner now drives the CI build for the - yavsc repository, using the - `pazof/yavsc-build-env:debian12-dotnet10-android36-v2` image pulled - from Docker Hub. Workflow runs end-to-end: clone, restore, build, - test, with NuGet.config picking up the `isn.pschneider.fr` feed. -- The build-env image now ships `jq` (Debian package, ≥ 1.7), so the - release workflow can build JSON bodies and parse API responses - without a hand-rolled `sed`-based extractor that was matching the - wrong `id` field on minified responses. - -### Changed -- CI workflow `.forgejo/workflows/buildAndTest.yml` no longer relies on - `actions/checkout` (the runner image has no Node); clones yavsc via - `git`, fetches the ref under test, and initializes submodules over - HTTPS. - -### Fixed -- `Dockerfile` and `Dockerfile.backend` no longer carry a redundant - `dotnet nuget add source` step that conflicted with the GitHub - Actions APK build (`--allow-insecure-connections` on an HTTPS - endpoint, exit 1). `NuGet.config` at the repo root supplies the - `isn.pschneider.fr` feed for every restore, including inside Docker. -- `.forgejo/workflows/release.yml`: PATCH on `/releases/{id}` no longer - 404s on existing releases. The previous `sed`-based `json_field` - matched the last `id` on the line (the author's), so it tried to - PATCH `/releases/1` (the first user of the instance) instead of the - actual release id. Switched to `jq` for both body construction and - field extraction. - -[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e528b7a4..4730e18c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,57 +49,6 @@ Les tests sont répartis en : item « Tests d'intégration smoke par BC ». - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. -## Navigation (PostIt) - -La navigation est centralisée dans -`App.PushPageAsync(ViewModelBase vm)` (`src/PostIt/PostIt/App.axaml.cs`). -Pour ouvrir un écran, un ViewModel (généralement dans une -commande `[RelayCommand]`) appelle -`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`. -`PushPageAsync` résout la `Control` correspondante via le -`ViewLocator` (un `IDataTemplate` enregistré dans -`Application.DataTemplates` au boot), l'identifie comme -`Page`, lui assigne le VM comme `DataContext`, et appelle -`NavRoot.PushAsync(page)`. Une garde anti-empilement -compare par référence la nouvelle page au sommet courant -de la stack pour éviter un push doublon. - -Pour qu'une nouvelle page soit navigable, il faut *deux* -enregistrements : la page dans le DI (`AddTransient` -ou `AddSingleton`) **et** une case dans le `switch` -de `ViewLocator.Build`. Si l'un manque, l'app affiche -"No view for X" sans crash. - -Règles : - -- On n'instancie jamais une `View` à la main depuis un - ViewModel, on ne récupère jamais une `View` depuis la DI - directement dans un ViewModel. -- Le ViewModel qui déclenche la nav ne pousse pas lui-même - la page ; il appelle `App.PushPageAsync(vm)` et laisse - `App` orchestrer le `PushAsync` physique. -- Le ViewModel qui déclenche la nav ne capture pas de - référence à `MainWindow` ou `NavigationPage`. Il passe - par `App.Current` (l'app Avalonia est un singleton). - -Exemple canonique (depuis `MainPageViewModel`) : - -```csharp -[RelayCommand] -internal async Task OpenSettings() -{ - var settingsVm = ((App)App.Current!).ServiceProvider - .GetRequiredService(); - await ((App)App.Current!).PushPageAsync(settingsVm) - .ConfigureAwait(true); -} -``` - -Cf. [doc/architecture/postit.md](./doc/architecture/postit.md) -pour la topologie complète (host de navigation, -`SessionStatusViewModel`, signaux de cycle de vie vs nav -utilisateur). - ## Conventions de code Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en @@ -115,13 +64,6 @@ Quelques règles non capturées par `.editorconfig` : - Préférer les types BCL (`int`, `string`) aux types framework (`Int32`, `String`). - Préférer les expressions de pattern matching aux casts explicites. -- **Pas de `object` dans le code source applicatif.** Types de retour, - paramètres, champs, propriétés, variables locales : tout doit être - typé statiquement. `dynamic` est interdit pour les mêmes raisons. - Un cast en `object` est presque toujours le symptôme d'un contrat - qu'on a laissé s'effriter (DTO, payload, handler) — refactore - le contrat (record typé, DTO dédié, méthode dédiée) au lieu de - shimer avec un cast. ## Branches & commits diff --git a/Directory.Build.props b/Directory.Build.props index aec8c990..051dba7a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,16 +1,5 @@ Yavsc - - true - NU1701, NU1901, NU1902 diff --git a/Directory.Packages.props b/Directory.Packages.props index 84380e44..d3664121 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,30 +2,31 @@ true + - - - + + - - - - - - + - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 795b70ab..e534f8ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,10 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ # (2) Tout le code source COPY . . +# (3) Source NuGet interne (Letsencrypt, certificat auto-signé côté +# serveur, justifié par build privé). +RUN dotnet nuget add source https://isn.pschneider.fr/v3/index.json --allow-insecure-connections + # (4) Restore RUN dotnet restore diff --git a/Dockerfile.backend b/Dockerfile.backend index a4e9a54a..86df9ccd 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -25,6 +25,9 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ # 4. Copie de l'intégralité du code source COPY . . +# 3. Restauration des dépendances avec vos workloads actifs +RUN dotnet nuget add source https://isn.pschneider.fr/v3/index.json --allow-insecure-connections + # 4. Restauration des dépendances pour tous les projets RUN dotnet restore diff --git a/Makefile b/Makefile index fa9d4ecf..a4922a3d 100644 --- a/Makefile +++ b/Makefile @@ -10,8 +10,8 @@ include .env all: dotnet build --nologo -clean: - dotnet clean -c $(CONFIG) +clean: + dotnet clean src/Yavsc/bin/output/wwwroot: dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish @@ -31,7 +31,7 @@ src/Yavsc.Server/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.Server.dll: src/Yavsc/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.dll: dotnet build -p:Configuration=$(CONFIG) --project src/Yavsc.Org/Yavsc.Org.csproj -$(DESTDIR): +$(DESTDIR): mkdir $(DESTDIR) install: $(DESTDIR) @@ -48,77 +48,5 @@ docker-build: docker-run: docker run -d -p 5000:5000 --name yavsc yavsc -# Crée une branche release/ depuis main, met à jour les -# `` des .csproj via dotnet-gitversion, et la -# pousse sur origin. -# -# Usage : make release V=1.0.7-rc1 -# -# Pré-requis : être sur main, working tree clean. La cible -# vérifie les deux et refuse sinon — elle ne fait JAMAIS -# de checkout automatique, c'est à l'opérateur de s'être -# positionné sur la bonne branche au préalable (sinon le -# bump pourrait partir sur une branche tierce par accident). -# -# Notes : -# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1 -# donne release/1.0.7-rc1). C'est une étiquette d'intention, -# pas la version assembly. -# - La version dans les .csproj vient de GitVersion qui la -# calcule depuis l'historique git (tag le plus proche + -# nombre de commits). C'est la version assembly réelle. -# - L'ordre (fetch → branche → bump → push) garantit qu'on -# part d'un main synchro et qu'on ne pollue pas main avec -# le bump (qui vit sur la branche release). -# - Fail-fast si la branche existe déjà en local ou sur origin. -release: - @if [ -z "$(V)" ]; then \ - echo "Usage: make release V="; \ - echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \ - exit 1; \ - fi - @CURRENT=$$(git branch --show-current); \ - if [ "$$CURRENT" != "main" ]; then \ - echo "Refus : la cible doit être lancée depuis main."; \ - echo " Branche courante : $$CURRENT"; \ - echo " Fais : git checkout main && git pull --ff-only origin main"; \ - exit 1; \ - fi - @if [ -n "$$(git status --porcelain)" ]; then \ - echo "Working tree sale, refus de créer une branche release."; \ - git status --short; \ - exit 1; \ - fi - @BRANCH="release/$(V)"; \ - if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \ - echo "La branche $$BRANCH existe déjà en local."; \ - echo " Pour la supprimer : git branch -D $$BRANCH"; \ - exit 1; \ - fi; \ - if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \ - echo "La branche $$BRANCH existe déjà sur origin."; \ - exit 1; \ - fi; \ - echo "==> Fetch + vérification synchro main"; \ - git fetch origin main; \ - if ! git merge-base --is-ancestor origin/main HEAD; then \ - echo "main a avancé plus loin que HEAD. Fais :"; \ - echo " git pull --ff-only origin main"; \ - exit 1; \ - fi; \ - echo "==> Création de $$BRANCH depuis main"; \ - git checkout -b "$$BRANCH"; \ - echo "==> dotnet-gitversion /updateprojectfiles"; \ - dotnet-gitversion /updateprojectfiles; \ - echo "==> Commit du bump"; \ - git add .; \ - if git diff --cached --quiet; then \ - echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \ - else \ - git commit -m "chore(release): bump version via gitversion for $(V)"; \ - fi; \ - echo "==> Push de $$BRANCH sur origin"; \ - git push -u origin "$$BRANCH"; \ - echo "==> Terminé. Branche $$BRANCH live sur origin." -.PHONY: test release +.PHONY: test diff --git a/NuGet.config b/NuGet.config deleted file mode 100644 index c601d09b..00000000 --- a/NuGet.config +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - diff --git a/README.md b/README.md index 8e630612..1f1e7ce5 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,9 @@ # Yavsc - [![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest) C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé. -# Statut actuel des actions Forgejo - - -* [![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml) - -* [![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)]( -https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml -) - # Statut actuel des actions GitHub * [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) @@ -162,13 +152,6 @@ d'abord `appsettings-org.json` du serveur ; sinon, laisse-le en place. (utilisateur, mot de passe, hôte, base). Privilégier `dotnet user-secrets` ou des variables d'environnement `ASPNETCORE_*` plutôt qu'un mot de passe en clair dans le fichier. -- Au démarrage, Yavsc.Org applique automatiquement ses migrations EF - Core. Sur cette base de code, EF Core 10 peut encore lever un - `PendingModelChangesWarning` malgré des migrations et snapshots déjà - alignés ; ce faux positif est ignoré sur les contextes PostgreSQL pour - éviter un démarrage inutilement en mode dégradé. Si une erreur de - migration apparaît encore en production, elle doit être traitée comme - une vraie divergence de schéma ou de connexion. - `Smtp.*` — hôte, port, identifiants SMTP pour l'envoi d'e-mails transactionnels. - `Authentication.PayPal.*` et `Authentication.Google.*` — clés d'API diff --git a/contrib/Makefile b/contrib/Makefile index 151045db..62e1e22d 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -1,4 +1,4 @@ -APP_PROJECT_NAMES=Org Blogs +APP_PROJECT_NAMES=Api Org Blogs SLNDIR=.. include $(SLNDIR)/.env @@ -7,6 +7,7 @@ include .env generated/: @mkdir -p $@ +generated/yavscApi.service: generated/yavscOrg.service: generated/yavscBlogs.service: @@ -33,11 +34,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @echo Created service file: $@ -copy-services: copy-service-Org copy-service-Blogs +copy-services: copy-service-Org copy-service-Api copy-service-Blogs copy-service-Org: /etc/systemd/system/yavscOrg.service +copy-service-Api: /etc/systemd/system/yavscApi.service copy-service-Blogs: /etc/systemd/system/yavscBlogs.service -copy-binaries: build_publish_Org build_publish_Blogs stop-services +copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-services @for project in $(APP_PROJECT_NAMES); \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ echo "$${project} -> $${LCAPI}" ; \ @@ -53,7 +55,7 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services done @sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR) -/etc/systemd/system/yavsc%.service: generated/yavsc%.service +/etc/systemd/system/yavsc%.service: generated/yavsc%.service sudo cp $^ $@ sudo chown root:root $@ @@ -63,14 +65,14 @@ build_publish_%: clean_publish_dir_% clean_publish_dir_%: @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish -install: build_publish copy-binaries copy-services +install: build_publish copy-binaries copy-services @sudo systemctl daemon-reload @for project in $(APP_PROJECT_NAMES); \ do \ sudo systemctl enable yavsc$${project} ; \ sudo systemctl start yavsc$${project} ; \ done - + reinstall: copy-binaries @sync @for project in $(APP_PROJECT_NAMES); do \ @@ -84,12 +86,13 @@ stop-services: $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish +$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish -showConfig: +showConfig: @echo CONFIGURATION: $(CONFIGURATION) @echo BASEAPPDIR: $(BASEAPPDIR) clean: @rm -rf generated -.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean +.PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean diff --git a/contrib/bruno/Get Posts.bru b/contrib/bruno/Get Posts.bru deleted file mode 100644 index bafe95b1..00000000 --- a/contrib/bruno/Get Posts.bru +++ /dev/null @@ -1,16 +0,0 @@ -info: - name: Get Posts - type: http - seq: 1 - -http: - method: GET - url: https://jsonplaceholder.typicode.com/users - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 - -docs: This request retrieves a list of users from the JSONPlaceholder API. diff --git a/contrib/bruno/Untitled.bru b/contrib/bruno/Untitled.bru deleted file mode 100644 index 168811b2..00000000 --- a/contrib/bruno/Untitled.bru +++ /dev/null @@ -1,15 +0,0 @@ -info: - name: Untitled - type: http - seq: 1 - -http: - method: GET - url: "" - auth: inherit - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/contrib/bruno/blog post.yml b/contrib/bruno/blog post.yml deleted file mode 100644 index 94e2745f..00000000 --- a/contrib/bruno/blog post.yml +++ /dev/null @@ -1,22 +0,0 @@ -info: - name: blog post - type: http - seq: 2 - -http: - method: POST - url: "{{Blogs}}/api/v1/blog" - body: - type: json - data: |- - { - "Title": "lkijlk", - "Article": "test" - } - auth: inherit - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/contrib/bruno/blogs.yml b/contrib/bruno/blogs.yml deleted file mode 100644 index 2767b01d..00000000 --- a/contrib/bruno/blogs.yml +++ /dev/null @@ -1,15 +0,0 @@ -info: - name: blogs - type: http - seq: 1 - -http: - method: GET - url: "{{Blogs}}/api/v1/blog" - auth: inherit - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/contrib/bruno/environments/Development.yml b/contrib/bruno/environments/Development.yml deleted file mode 100644 index 0fd5430e..00000000 --- a/contrib/bruno/environments/Development.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: Development -variables: - - name: Blogs - value: https://localhost:5003 - - name: Authority - value: https://localhost:5001 diff --git a/contrib/bruno/environments/Production.yml b/contrib/bruno/environments/Production.yml deleted file mode 100644 index fda7173b..00000000 --- a/contrib/bruno/environments/Production.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: Production -variables: - - name: Authority - value: https://yavsc.pschneider.fr - - name: Blogs - value: https://blogs.pschneider.fr diff --git a/contrib/bruno/opencollection.yml b/contrib/bruno/opencollection.yml deleted file mode 100644 index 374cd0e1..00000000 --- a/contrib/bruno/opencollection.yml +++ /dev/null @@ -1,43 +0,0 @@ -opencollection: 1.0.0 - -info: - name: blogs -config: - proxy: - inherit: true - config: - protocol: http - hostname: "" - port: "" - auth: - username: "" - password: "" - bypassProxy: "" - -request: - auth: - type: oauth2 - flow: authorization_code - authorizationUrl: "{{Authority}}/connect/authorize" - accessTokenUrl: "{{Authority}}/connect/token" - refreshTokenUrl: https://yavsc.pschneider.fr/connect/token - callbackUrl: "{{Authority}}" - credentials: - clientId: postit - placement: basic_auth_header - scope: openid blogs profile - pkce: {} - tokenConfig: - id: credentials - placement: - header: Bearer - source: access_token - settings: - autoFetchToken: true - autoRefreshToken: true -bundled: false -extensions: - bruno: - ignore: - - node_modules - - .git diff --git a/doc/README.md b/doc/README.md index 912a2e56..4afdf585 100644 --- a/doc/README.md +++ b/doc/README.md @@ -15,9 +15,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md). | [architecture/dictionnaires-metier.md](architecture/dictionnaires-metier.md) | Dictionnaires métier, héritage en arbre, cycle de vie d'un terme | | [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter | | [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh | -| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | -| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé | ## Roadmap & design exploration diff --git a/doc/architecture/decoupage-organisation.md b/doc/architecture/decoupage-organisation.md index 52e6ea97..9d4a0e4a 100644 --- a/doc/architecture/decoupage-organisation.md +++ b/doc/architecture/decoupage-organisation.md @@ -31,19 +31,7 @@ └────────────────┘ Clients externes : - - PostIt (Avalonia, code-base unique multi-cible) : - · PostIt — lib partagée (pages, VM, services) - · PostIt.Desktop — front-end Linux/Windows - · PostIt.Android — front-end APK - · PostIt.Browser — front-end WASM - Cf. postit.md et postit-oidc.md. - -Outils et tests : - - cli — outillage CLI - - Yavsc.Tests.Shared — helpers de tests partagés - - Yavsc.Org.Tests — tests du front web - - Yavsc.Blogs.Tests — tests du backend blogs - - PostIt.Tests — tests du client PostIt + - PostIt : client desktop Avalonia (cf. postit-oidc.md). ``` ## Par projet @@ -56,14 +44,6 @@ Outils et tests : | `Yavsc.Api` | ASP.NET Web | API REST JSON principale consommée par les clients externes (PostIt, …). JwtBearer auth. | | `Yavsc.Blogs` | ASP.NET Web | **Backend API headless** dédié aux blogs (uniquement `*ApiController` + services + modèles — aucune vue Razor). Destiné à être déployé sur un sous-domaine en production, séparé du front web hébergé par `Yavsc.Org`. | | `Yavsc.Org.Tests` | Test (xUnit) | Tests d'isolation du front web (`Yavsc.Org`) — fakes, controller tests. | -| `Yavsc.Blogs.Tests`| Test (xUnit) | Tests d'isolation du backend blogs (`Yavsc.Blogs`). | -| `Yavsc.Tests.Shared` | Library | Helpers de tests partagés (fixtures, fakes, builders) entre les projets de tests. | -| `PostIt` | Library | Code-base partagée du client PostIt (Avalonia) : pages, ViewModels, services, `ViewLocator` custo. Multi-cible — produit PostIt.Desktop / PostIt.Android / PostIt.Browser. | -| `PostIt.Desktop` | Avalonia.Desktop | Front-end Desktop Linux/Windows : `Program.Main`, `Platform.CreateBrowser` (CustomSchemeBrowser), custom URI scheme `postit://`. | -| `PostIt.Android` | Avalonia.Android | Front-end Android : `MainActivity` SingleTask, Chrome Custom Tabs, scheme `android://postit-signin`. | -| `PostIt.Browser` | Avalonia.Browser | Front-end WASM : pas de process distinct, IBrowser N/A. | -| `PostIt.Tests` | Test (xUnit) | Tests du client PostIt : settings, scopes Bearer, OIDC stub (`OidcStubAuthority`). | -| `cli` | exe / tool | Outillage CLI (build, packaging, génération de clés). | ## Pourquoi ce découpage diff --git a/doc/architecture/postit-oidc.md b/doc/architecture/postit-oidc.md index ddedbfde..ee003b41 100644 --- a/doc/architecture/postit-oidc.md +++ b/doc/architecture/postit-oidc.md @@ -56,7 +56,6 @@ pas vers un serveur HTTP. |---------------------------------|-------------------------------------------------------------------| | `Services/OidcLoginPhase` | Enum des étapes du flow : `Idle / Discovering / OpeningBrowser / AwaitingCallback / ExchangingCode / Success / Error` | | `Services/YavscApiClient` | Client HTTP de l'API Yavsc. Porte `LoginInteractiveAsync(IProgress)` et `TrySilentLoginAsync`. Refresh silencieux sur 401 et sur access-token bientôt expiré. | -| `Services/BlogApiClient` | Mapper DTO↔path pour la sous-API blog. **Note** : `pathPrefix` est *relatif* à `/api/v1/` (que porte déjà `BaseAddress`) — ex. `"blog"` pour matcher `[Route(APIPrefix + "/blog")]`. Ne pas ré-inclure `api/`. | | `Services/SingleInstance` | Named-pipe helper. `TryHandOffAsync` côté 2ᵉ instance, `StartServerAsync` côté instance vivante. | | `Services/CustomSchemeBrowser` | `IBrowser` OidcClient qui ouvre le système + attend le pipe. | | `Services/SchemeUrlDetector` | Détection pure, testable, du `postit://callback` dans argv. | diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md deleted file mode 100644 index 70f3f2fd..00000000 --- a/doc/architecture/postit.md +++ /dev/null @@ -1,282 +0,0 @@ -# PostIt — Topologie, navigation, DI - -> **Récapitulatif** : PostIt est le client Avalonia du projet -> Yavsc. C'est un code-base unique (`src/PostIt/PostIt/PostIt.csproj`) -> **multi-cible** vers trois front-ends distincts -> (`PostIt.Desktop`, `Postit.Android`, `PostIt.Browser`). Cette -> fiche couvre la topologie des projets, le DI, le `ViewLocator` -> custo et la navigation — c'est-à-dire tout ce que la fiche -> [postit-oidc.md](postit-oidc.md) ne détaille pas déjà (l'OIDC, -> le flow d'auth, la persistance des tokens). Détail dans cette -> page, racine de l'architecture : [Architecture.md](../Architecture.md). - -## Surface : un code-base, trois front-ends - -``` - ┌────────────────────────┐ - │ PostIt (lib) │ - │ src/PostIt/PostIt/ │ - │ Pages, ViewModels, │ - │ Services, ViewLocator │ - │ (aucun rendu natif) │ - └──────┬───┬─────┬───────┘ - │ │ │ - ┌───────────────┘ │ └────────────────┐ - │ │ │ - ┌──────────▼────────┐ ┌────────▼─────────┐ ┌──────────▼────────┐ - │ PostIt.Desktop │ │ PostIt.Android │ │ PostIt.Browser │ - │ Avalonia.Desktop │ │ Avalonia.Android │ │ Avalonia.Browser │ - │ Linux/Windows │ │ APK │ │ WASM │ - │ + custom scheme │ │ + Chrome Custom │ │ (no native proc) │ - │ postit:// │ │ Tabs │ │ │ - │ + IBrowser custo │ │ + IBrowser custo │ │ │ - └───────────────────┘ └──────────────────┘ └───────────────────┘ -``` - -Le code partagé vit dans `PostIt/`. Chaque front-end est un -**projet Satellite SDK** Avalonia qui ne contient que le -`Program.Main`, le `Platform.CreateBrowser`, et les manifestes -spécifiques (IntentFilter Android, `app.manifest` Desktop). -Toute la logique (VM, services, navigation, settings, OIDC) est -dans le code-base partagé. - -## ViewLocator custo - -Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un -`IDataTemplate` Avalonia **explicitement câblé sur le -`IServiceProvider`** : - -```csharp -public Control Build(object? data) => data switch -{ - MainPageViewModel => _services.GetRequiredService(), - Settings => _services.GetRequiredService(), - HomePageViewModel => _services.GetRequiredService(), - SignaturePageViewModel => _services.GetRequiredService(), - null => new TextBlock { Text = "No view for " }, - _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } -}; -public bool Match(object? data) => data is ViewModelBase; -``` - -**Pourquoi un custo, et pas le `ViewLocatorBase` par défaut -d'Avalonia.Mvvm ?** Pour deux raisons : - -1. **Sortie du `Activator.CreateInstance`** — les pages - PostIt sont enregistrées dans le DI et peuvent avoir des - dépendances (par construction, aujourd'hui aucune, mais - l'extension future est ouverte). Le `ViewLocatorBase` - historique fait `new View()`, ce qui rend impossible - l'injection et complique les tests. -2. **Filtrage par `ViewModelBase`** — `Match` n'accepte que les - types dérivés de `ViewModelBase`. Toute tentative d'afficher - un objet métier (par ex. un DTO de l'API Yavsc) tombe sur le - `TextBlock` "No view for X", pas sur un crash Avalonia. - -Le `ViewLocator` est ajouté aux `DataTemplates` de l'app dans -`App.OnFrameworkInitializationCompleted` : - -```csharp -DataTemplates.Clear(); -DataTemplates.Add(new ViewLocator(provider)); -``` - -**Conséquence pratique** : pour qu'une nouvelle page soit -affichée par un `ContentControl` qui binde un ViewModel, il -faut *deux* enregistrements : la page en `AddTransient` (ou -`AddSingleton`) dans le DI, **et** une case dans le `switch` -de `ViewLocator.Build`. Si l'un manque, l'app affiche -"No view for X" sans crash. - -## Composition root (`App.axaml.cs`) - -`App.OnFrameworkInitializationCompleted` est le seul endroit où -le DI est construit. Ordre, dans cet ordre : - -1. `new Settings()` + `settings.Load()` — lit - `~/.config/PostIt/postit-settings.json` (ou le fallback - embarqué dans `PostIt.dll`). -2. `new TokenStore(...)` + `new YavscApiClient(settings, tokenStore)`. -3. `new ServiceCollection()` + enregistrements en bloc. -4. `services.BuildServiceProvider()`. -5. `Settings.BindToServiceProvider(provider)` — pose le - singleton statique pour les helpers hors-DI - (`Settings.GetCurrent()`, `Settings.RequireCurrent()`). -6. `DataTemplates.Add(new ViewLocator(provider))`. -7. Branche `IClassicDesktopStyleApplicationLifetime` / - `ISingleViewApplicationLifetime` (Browser/Android). - -### Enregistrements DI - -| Service | Lifetime | Pourquoi | -|-------------------------------|------------|-------------------------------------------------------------------------------------------| -| `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. | -| `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. | -| `BlogApiClient` | Singleton | Mapper stateless, partagé. | -| `SettingsPage` | **Singleton** | Une seule instance pour la vie de l'app : le `DataContext` est câblé une fois au boot, le push est idempotent (cf. section *Garde anti-empilement* ci-dessous). | -| `MainPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. | -| `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. | -| `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). | - -> **Invariant** : `Settings` est **uniquement** un singleton. Un -> `AddTransient()` supplémentaire (qui réécrase le -> singleton dans le container) ferait que chaque push de -> `SettingsPage` crée une instance vide, casse les bindings -> Authority/ClientId, et perd toute édition. Si tu dois toucher -> à cette table, *ne pas* ajouter de registration pour -> `Settings` ailleurs que la ligne `AddSingleton(settings)`. - -## Navigation - -Le host de navigation est un `NavigationPage x:Name="NavRoot"` -posé sur `MainWindow.axaml`. La pile est gérée par deux -mécanismes distincts : - -1. **Nav utilisateur (VM-first)** : un ViewModel (souvent dans - une commande `[RelayCommand]`) appelle - `await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`. - `App.PushPageAsync` (`src/PostIt/PostIt/App.axaml.cs`) - résout la `Control` correspondante via le `ViewLocator` - enregistré dans `Application.DataTemplates`, l'identifie - comme `Page`, lui assigne le VM comme `DataContext`, et - appelle `NavRoot.PushAsync(page)`. C'est le seul chemin - pour les boutons de la toolbar, les `OpenSettings` / - `OpenCircles` / `ManageAcl` / `OpenSignatureDev`, et - toute autre nav déclenchée par un ViewModel. - -2. **Signaux de cycle de vie** : le `SessionStatusViewModel` - lève des événements consommés dans - `App.OnFrameworkInitializationCompleted` pour orchestrer - la nav de boot : - - | Événement | Effet | - |---------------------|------------------------------------------------------------------| - | `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage` (post-login). | - | `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). | - - Ces events ne sont **pas** un canal de nav utilisateur ; ils - portent une transition d'état applicatif (authentification - établie / perdue) et c'est `App` qui choisit d'en faire une - transition de pile. - -### Garde anti-empilement - -`NavigationPage.PushAsync` n'est pas idempotent : pousser deux -fois la même instance l'empile deux fois, et l'utilisateur doit -taper **Retour** N fois pour sortir. La garde est implémentée -dans `App.PushPageAsync` (et consommée par tous les chemins -de nav utilisateur) : - -```csharp -var stack = window.NavRoot.NavigationStack; -if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) -{ - return Task.CompletedTask; // déjà au sommet, no-op silencieux -} -return window.NavRoot.PushAsync(page); -``` - -La comparaison est par référence, pas par type : on ne veut -empêcher qu'un push de *cette* instance particulière, pas -celui d'une éventuelle autre `SettingsPage` (il n'en existe -qu'une, mais l'invariant est plus clair comme ça). La garde -repose sur le fait que `SettingsPage` est un singleton ; si on -repassait en `Transient`, `ReferenceEquals` resterait correct -mais la pertinence de la garde s'évaporerait (chaque push -apporterait une nouvelle instance et l'anti-empilement -reposerait sur l'invariant « la même est déjà au sommet », -qui ne tiendrait plus). - -## ViewModels et invariants d'état - -- `Settings` est un objet-modèle exposé comme `DataContext` - des pages. Il n'hérite pas de `ViewModelBase` (c'est un - POCO `[ObservableProperty]`-généré par - `CommunityToolkit.Mvvm`). Le fait qu'il soit utilisé comme - DataContext est un raccourci de composition acceptable ici, - pas un pattern à généraliser. - -- `SessionStatusViewModel` est le seul VM avec une durée de vie - **process-entière** (singleton). Il survit à toutes les - navigations, expose `HasValidSession` en continu, et porte - les événements de cycle de vie consommés par `App` pour - orchestrer la nav de boot (`LoginSucceeded`, - `LogoutCompleted`). La nav utilisateur déclenchée par - l'utilisateur passe par `App.PushPageAsync(vm)`, pas par - un événement du `SessionStatusViewModel`. - -- `MainPageViewModel` / `HomePageViewModel` / - `SignaturePageViewModel` sont `Transient` — une nouvelle - instance est créée à chaque push, l'ancienne est libérée - quand la page est dépilée. Pas d'état partagé entre - occurrences ; pour passer une donnée d'une page à l'autre, - on passe par un singleton (souvent `YavscApiClient` ou - `Settings`). - -## Bindings XAML : conventions de nommage - -Pour les `[RelayCommand]` (cf. `CommunityToolkit.Mvvm`), le -binding XAML reprend **le nom exact de la méthode, sans -suffixe** : - -| Méthode C# | Binding XAML | -|-----------------------|-----------------------------| -| `Save()` | `{Binding Save}` | -| `SaveAsync()` | `{Binding SaveAsync}` | -| `LoginCommand()` | `{Binding LoginCommand}` (nom littéral, *pas* de suffixe ajouté) | -| `Clear()` | `{Binding Clear}` | -| `CaptureAsync()` | `{Binding CaptureAsync}` | - -**JAMAIS** `SaveCommand`, `SaveCmd`, `DoSave`, etc. Le source -generator `[RelayCommand]` émet une propriété `ICommand` du -même nom que la méthode. Un binding qui pointe vers une -propriété inexistante casse l'app au moment du câblage (le -bouton ne se câble pas, et selon la version ça peut faire -planter l'init de la page). - -Référence canonique : `AGENTS.md`, section -"Avalonia + CommunityToolkit.Mvvm : conventions de binding -pour `[RelayCommand]`". - -## Pages et leurs rôles - -| Page | DataContext | Rôle | -|----------------------------|--------------------------|-----------------------------------------------------------------------| -| `MainWindow` | `HomePageViewModel` (initial) | Host de la `NavigationPage`. | -| `SessionStatusBanner` | `SessionStatusViewModel` | Bandeau persistant en haut de la fenêtre, visible sur toutes les pages. Boutons Login / Logout / Paramètres. | -| `HomePage` | `HomePageViewModel` | Page d'accueil publique. | -| `MainPage` | `MainPageViewModel` | Éditeur de post de blog (après login). | -| `SignaturePage` | `SignaturePageViewModel` | Capture de signature (estimateur). | -| `SettingsPage` | `Settings` | Édition de Authority / ClientId / Scopes / URLs API / Dark mode. Sauver via `Save` (RelayCommand). | - -## Conséquences pratiques - -- **Ajouter une page** : créer la View + le ViewModel + - enregistrer les deux dans le DI **et** dans le `switch` de - `ViewLocator.Build`. Oublier le `ViewLocator` est silencieux - (juste un TextBlock "No view for X"), pas une exception. -- **Ajouter un événement global de navigation** (par ex. - "Push après payment success") : ne pas capturer `MainWindow` - ni `NavigationPage` depuis le VM. La nav passe par - `App.PushPageAsync(vm)` dans tous les cas : soit le VM - appelle la méthode directement depuis une commande - (`[RelayCommand]`), soit un handler abonné à un événement - d'un singleton (cf. `SessionStatusViewModel`) l'appelle. - Garder les VMs découplés du - `IClassicDesktopStyleApplicationLifetime`. -- **Modifier l'OIDC** : la fiche à lire est - [postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche - ne ré-explique ni le flow, ni le pipe, ni le custom scheme. -- **Modifier les `Settings`** : ne pas casser le singleton - (cf. invariant ci-dessus). Toute propriété présentationnelle - ajoutée (par ex. `ScopeListText`) doit porter `[JsonIgnore]` - pour ne pas polluer le format sur disque. - -## Voir aussi - -- [Architecture.md](../Architecture.md) — racine. -- [postit-oidc.md](postit-oidc.md) — flow OIDC, custom scheme, - silent refresh, persistance des tokens. -- [decoupage-organisation.md](decoupage-organisation.md) — - place de `PostIt` dans le découpage global des projets - .NET du repo. diff --git a/doc/dev-tracking/client-editor-overhaul.md b/doc/dev-tracking/client-editor-overhaul.md new file mode 100644 index 00000000..0231e6aa --- /dev/null +++ b/doc/dev-tracking/client-editor-overhaul.md @@ -0,0 +1,278 @@ +# Client editor overhaul — Yavsc.Org administration + +## Goal + +Bring the OAuth2 client administration UI (`/Client/Edit/{id}` and friends) +in Yavsc.Org to feature parity with the IdentityServer8 `Client` entity +model. Today the editor only exposes a handful of scalar fields and a few +single-line inputs for collections; the bulk of the entity and its +related collections are unreachable from the UI. + +## Inventory — current state + +### Properties exposed by `Views/Client/Edit.cshtml` + +| Field | Type | Notes | +| ------------------------ | ----------- | ---------------------------------- | +| `ClientId` | string | hidden, identifier | +| `Enabled` | bool | checkbox | +| `ClientName` | string | display name | +| `FrontChannelLogoutUri` | string | only front-channel, no back-channel | +| `RedirectUris` | collection | rendered as a single text input | +| `IdentityTokenLifetime` | int | seconds | +| `AbsoluteRefreshTokenLifetime` | int | seconds | +| `ClientSecrets` | collection | rendered as a single text input | +| `AccessTokenType` | enum | dropdown (custom `SetAppTypesInputValues`) | + +### Properties of `IdentityServer8.EntityFramework.Entities.Client` **NOT** in the editor + +Core scalars (16 fields missing): + +- `Description` +- `ClientUri` +- `LogoUri` +- `RequireConsent` +- `RequirePkce` +- `RequireRequestObject` +- `RequireClientSecret` +- `AllowPlainTextPkce` +- `AllowOfflineAccess` +- `AllowRememberConsent` +- `AlwaysIncludeUserClaimsInIdToken` +- `AlwaysSendClientClaims` +- `AuthorizationCodeLifetime` +- `BackChannelLogoutUri` +- `BackChannelLogoutSessionRequired` +- `CibaLifetime` +- `ClientClaimsPrefix` +- `ConsentLifetime` +- `Created` +- `DeviceCodeLifetime` +- `EnableLocalLogin` +- `Enabled` +- `FrontChannelLogoutSessionRequired` +- `IncludeJwtId` +- `LastAccessed` +- `LogoUri` +- `NonEditable` +- `PairwiseSubjectSalt` +- `PollingInterval` +- `ProtocolType` +- `RefreshTokenExpiration` +- `RefreshTokenUsage` +- `SlidingRefreshTokenLifetime` +- `UpdateAccessTokenClaimsOnRefresh` +- `Updated` +- `UserCodeType` +- `UserSsoLifetime` + +Collections (8 missing — currently either not exposed at all, or jammed +into a single-line text input that doesn't work for an IEnumerable): + +- `AllowedGrantTypes` → `ClientGrantType` (GrantType) +- `AllowedScopes` → `ClientScope` (Scope) +- `RedirectUris` → `ClientRedirectUri` (RedirectUri) — exposed but broken +- `PostLogoutRedirectUris` → `ClientPostLogoutRedirectUri` (PostLogoutRedirectUri) +- `AllowedCorsOrigins` → `ClientCorsOrigin` (Origin) +- `IdentityProviderRestrictions` → `ClientIdPRestriction` (Provider) +- `Claims` → `ClientClaim` (Type, Value) +- `Properties` → `ClientProperty` (Key, Value) +- `ClientSecrets` → `ClientSecret` (Type, Value, Description, Created, Expiration) — exposed but broken +- `AllowedSigningAlgorithms` → scalar string collection on Client itself + +## Pages to add + +Pattern: one Razor page per collection under +`Views/Client/Edit{Collection}.cshtml`. Each page lists existing rows, +offers an "Add" form with the relevant fields, and a per-row +remove button. The main `Edit.cshtml` becomes a hub page with links +to each subpage plus the scalar fields it already has. + +| Page | Route | Form fields | +| ------------------------------------- | ------------------------------------------ | ------------------------------------------------- | +| `Edit.cshtml` | `GET /Client/Edit/{id}` (existing) | scalar fields + nav links | +| `EditRedirectUris.cshtml` | `GET /Client/EditRedirectUris/{id}` | `RedirectUri` | +| `EditPostLogoutRedirectUris.cshtml` | `GET /Client/EditPostLogoutRedirectUris/{id}` | `PostLogoutRedirectUri` | +| `EditScopes.cshtml` | `GET /Client/EditScopes/{id}` | `Scope` (with select of known scopes) | +| `EditGrantTypes.cshtml` | `GET /Client/EditGrantTypes/{id}` | `GrantType` (with select of known types) | +| `EditCorsOrigins.cshtml` | `GET /Client/EditCorsOrigins/{id}` | `Origin` | +| `EditIdPRestrictions.cshtml` | `GET /Client/EditIdPRestrictions/{id}` | `Provider` | +| `EditClaims.cshtml` | `GET /Client/EditClaims/{id}` | `Type`, `Value` | +| `EditProperties.cshtml` | `GET /Client/EditProperties/{id}` | `Key`, `Value` | +| `EditSecrets.cshtml` (replacement) | `GET /Client/EditSecrets/{id}` | `Type`, `Value`, `Description`, `Expiration` | + +Partial view `_EditableList.cshtml` factored once and consumed by all +of the above. + +## Controller actions to add + +For each collection `Foo`: + +- `GET EditFoo(int id)` — load the client, render the page +- `POST AddFoo(int id, …)` — append a row, redirect to `EditFoo` +- `POST RemoveFoo(int id, int rowId)` — delete a row, redirect + +## Verification + +- `dotnet build src/Yavsc.Org/Yavsc.Org.csproj` → 0 errors +- No tests in `Yavsc.Org.Tests` exercise the controller today (per + `find … -name "ClientController*" -not -path "*/bin/*"`). Smoke-test + by logging in as admin, hitting `/Client/Edit/1`, then each + `Edit*/1` page, and verifying the add/remove POSTs. +- Existing seed flow (`MigratePostItClientToPublic` in + `HostingExtensions.cs`) must keep working — the editor changes are + additive, not destructive. + +## Out of scope + +- Tests (no MVC test infrastructure currently exists for this controller) +- Migration of existing collection fields (the broken `RedirectUris` + text input will simply be replaced by the new subpage) +- Per-collection authorization policies (the controller is already + `[Authorize("AdministratorOnly")]`) +- Client cloning / templating / JSON import-export + +## Status + +2026-06-21 16:04 — kickoff. Inventory done. Pages not yet started. + +2026-06-21 16:11 — first delivery, **build does not compile by design** +(per Paul: "Tu peux même me laisser un travail qui ne compile +pas"). The structural work is done; the residual errors are easy +fixes Paul will do in a debug session. + +Files added (working tree, not yet committed): + +- `src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs` + — partial class with the per-collection GET / Add / Remove actions. +- `src/Yavsc.Org/Views/Client/EditRedirectUris.cshtml` +- `src/Yavsc.Org/Views/Client/EditPostLogoutRedirectUris.cshtml` +- `src/Yavsc.Org/Views/Client/EditScopes.cshtml` +- `src/Yavsc.Org/Views/Client/EditGrantTypes.cshtml` +- `src/Yavsc.Org/Views/Client/EditCorsOrigins.cshtml` +- `src/Yavsc.Org/Views/Client/EditIdPRestrictions.cshtml` +- `src/Yavsc.Org/Views/Client/EditClaims.cshtml` +- `src/Yavsc.Org/Views/Client/EditProperties.cshtml` +- `src/Yavsc.Org/Views/Client/EditSecrets.cshtml` +- `src/Yavsc.Org/Views/Client/_EditableStringList.cshtml` + — partial consumed by the single-string-field collection pages. + +Files modified: + +- `src/Yavsc.Org/Controllers/Administration/ClientController.cs` + — `class` → `partial class`; the `Edit(int id)` GET now uses + `LoadClientAsync` to load all navigations (so the new Edit.cshtml + can render counts in its nav links). +- `src/Yavsc.Org/Views/Client/Edit.cshtml` + — significantly enriched: nav links to the 9 sub-pages, all the + scalar fields split into fieldsets (Security, Logout, Tokens, + Device / CIBA, Tokens-extra), ClientId / Id hidden. + +### Known residual compile errors (4 errors total) + +Paul is fixing these in a debug session. The structure is sound; the +errors are missing properties on the `Client` entity, a Razor +nullable quirk, and a `Localizer` injection miss. + +1. `Edit.cshtml:249` — `PairwiseSubjectSalt` doesn't exist on + `IdentityServer8.EntityFramework.Entities.Client`. **Fix**: drop + the field from Edit.cshtml; IdentityServer8 likely uses a + different property name (e.g. on a related entity) or doesn't + expose it. +2. `Edit.cshtml:221` — `CibaLifetime` doesn't exist on `Client`. + **Fix**: same as above. CIBA flow may be configured elsewhere + (resource-level) or via a different property. +3. `ClientController.Collections.cs` lines 181, 217, 253, 304 — + `Localizer` is not available in the partial class. **Fix**: inject + `IStringLocalizer` via the constructor, or + inline the strings ("BothTypeAndValueRequired", "KeyRequired", + "ValueRequired", "SecretValueRequired"). +4. `EditSecrets.cshtml:44` — `s.Expiration?.ToString("u")` on a + `DateTime?`. **Fix**: just `s.Expiration?.ToString("u")` works + if you write `s.Expiration.Value.ToString("u")`, or use + `(s.Expiration is null ? "" : s.Expiration.Value.ToString("u"))`, + or `s.Expiration?.ToString("u") ?? string.Empty`. + +### Suggested next session + +Once the 4 compile errors are fixed and the pages render: + +1. Smoke test by logging in as admin, hitting `/Client/Edit/1`, + then each `Edit*/1` page, and verifying add/remove POSTs. +2. Add a confirmation prompt (or 2-step form) for Remove actions — + removing a Redirect URI is destructive and one click is too easy. +3. Wire up some collection-level validation (e.g. redirect URI must + be a valid URL) at the controller level. +4. Add tests — the project doesn't have MVC test infrastructure + today; consider adding a `Yavsc.Org.Tests` project that drives + the controller via `WebApplicationFactory`. + + +## Test bootstrap notes (session of 2026-06-21 17:00+) + +When adding new integration tests against `WebServerFixture`: + +1. **Skip `/Account/Login` roundtrip.** The fixture ships without + `MapRazorPages()` (commented out in `HostingExtensions.ConfigurePipeline`), + so `/Identity/Account/Login` is 404, and the custom + `/Account/Login` route requires a complex antiforgery dance. + Instead, build a `ClaimsPrincipal` for the test user via + `UserManager` + `IUserClaimsPrincipalFactory`, + then call `IAuthenticationService.SignInAsync` on a synthetic + `DefaultHttpContext` and replay the resulting `Set-Cookie` header + into the test `HttpClient`. See + `ClientControllerCollectionTests.IssueIdentityCookie`. + +2. **Create the `Administrator` role before assigning it.** ASP.NET + Identity stores roles in `AspNetRoles`; there is no automatic seed. + The constant name is `YavscConstants.AdminGroupName` = `"Administrator"`. + Use `RoleManager.CreateAsync(new IdentityRole("Administrator"))` + before `AddToRoleAsync`. + +3. **Use `InMemory` connection string to bypass the prod signing-cert + requirement.** `HostingExtensions.AddIdentityServer` requires a + PEM cert unless `builder.Environment.IsDevelopment()` OR + `UsesInMemoryProvider(connectionString)`. The fixture already + uses `InMemory`, so `AddDeveloperSigningCredential()` is called + automatically — but only after we wired this check in (see + commit history). + +4. **Field-name gotchas** (from disassembling HigginsSoft + IdentityServer8.EntityFramework.Entities.Client 8.0.5-preview-net9): + - `PairWiseSubjectSalt` (capital W on "Wise"), not `PairwiseSubjectSalt`. + - `CibaLifetime` and `PollingInterval` do NOT exist on `Client` in + this version. + - `ConsentLifetime` and `UserSsoLifetime` are `int?`. + +5. **`MapStaticAssets()` fails on test projects.** Calling + `MapStaticAssets()` resolves a manifest file + (`.staticwebassets.endpoints.json`) that test projects + don't produce. Skip when `WebRootPath` points at the test + assembly directory. + +6. **Routing 404 on /Client/Edit/{id} via WebServerFixture.** As of + this session, the GET endpoint returns 404 even with admin + header. The route mapping is intact + (`MapDefaultControllerRoute()`), so this is likely an MVC + convention routing issue with the + `Controllers/Administration/` subdirectory. To investigate + next session: log middleware pipeline or hit `/Client` index + first to see if any Client route resolves. + +7. **`MapStaticAssets()` is unconditional in prod, but blocks tests.** + `WebApplication.CreateBuilder` defaults `ContentRootPath` to + `AppContext.BaseDirectory`. In test runs that resolves to + `src/Yavsc.Org.Tests/bin/Debug/net10.0/`, where + `Yavsc.Org.Tests.staticwebassets.endpoints.json` doesn't exist + (it's generated only by projects with the Web SDK). The + `app.MapStaticAssets()` call inside `ConfigurePipeline` then + throws and the fixture fails to start — taking every test in + the `[Collection("Yavsc Server")]` down with it. + + This is a pre-existing fragility of the WebServerFixture that + the new test work surfaced. Fixing it cleanly requires either: + (a) moving the test project to the Web SDK so it produces its + own manifest, (b) copying the manifest at build time via an + MSBuild target, or (c) routing `MapStaticAssets` through an + assembly-resolution fallback. None attempted in this session — + recorded for next session. diff --git a/doc/testing.md b/doc/testing.md deleted file mode 100644 index ef80cda9..00000000 --- a/doc/testing.md +++ /dev/null @@ -1,88 +0,0 @@ -# Stratégie de test - -Yavsc utilise **xUnit** (`xunit.v3`) avec un mix d'unitaire pur -et d'intégration légère. Les projets de tests sont sous -`src/.Tests/` et consomment le scaffold partagé -`src/Yavsc.Tests.Shared/`. - -## Vue d'ensemble - -| Sujet | Document | -|---|---| -| Scaffold partagé (`WebHostFixture`, JWT de test, etc.) | [src/Yavsc.Tests.Shared/README.md](../src/Yavsc.Tests.Shared/README.md) | -| Convention des dossiers de tests | [Conventions](#conventions-des-dossiers-de-tests) | -| Driver EF Core en test | [EF Core en test](#ef-core-en-test) | -| Stubs d'authentification et de permissions | [Auth et permissions](#auth-et-permissions) | - -## Conventions des dossiers de tests - -Sous `src/.Tests/`, on trouve quatre dossiers de premier -niveau qui classifient les tests par intention : - -| Dossier | Usage | -|---|---| -| `NonRegression/` | Régressions : un bug constaté, un test qui le détecte si on le réintroduit | -| `Mandatory/` | Tests bloquants : ils doivent passer avant tout merge | -| `Smoke/` | Smoke tests HTTP rapides, montent un host léger | -| `Controllers/` | Tests unitaires des contrôleurs (mock du service, assertions sur le mapping HTTP) | - -Les `NonRegression` sont la cible par défaut quand on fixe un -bug : ils doivent être **rouges avant le fix, verts après**, et -continuer à **casser** si quelqu'un revert le fix. Pas de test -qui passe à vide. - -## EF Core en test - -Pour les tests qui ont besoin d'un `ApplicationDbContext`, on -utilise **`UseInMemoryDatabase`** avec un `InMemoryDatabaseRoot` -partagé au niveau de la fixture. Pas de SQLite, pas de Docker, -pas de mock du contexte : le service testé s'exécute contre -un vrai `DbContext` sur in-memory. - -```csharp -private static readonly InMemoryDatabaseRoot _dbRoot = new(); - -var opts = new DbContextOptionsBuilder() - .UseInMemoryDatabase("Yavsc.Org.Tests.MyFixture", _dbRoot) - .Options; -``` - -Le `InMemoryDatabaseRoot` partagé est important : sans lui, EF -crée un store indépendant par `DbContext` dans certaines -configurations, et un test qui seed + read sur deux contextes -voit un store vide. Le pattern est documenté dans -`BlogsWebServerFixture` ([src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs](../src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs)). - -> **Limite connue** : le provider in-memory **ignore** les -> `Migration` EF et ne respecte pas les FK **sur les raw -> SQL** (`ExecuteSqlRaw`). Pour tester des contraintes FK, on -> écrit la configuration dans `OnModelCreating` et on s'appuie -> sur le fait qu'EF la respecte à l'`Add`/`SaveChanges`. Pour -> tester des migrations, c'est l'environnement de staging. - -## Auth et permissions - -L'authorization policy provider de prod est swappé contre -`TestAuthPolicyProvider` (dans `Yavsc.Tests.Shared`) par les -fixtures spécialisées. Les tests qui ont besoin qu'un user soit -"Administrator" envoient un header `X-Test-Rôle` ; ceux qui -veulent un user anonyme omettent le header. - -Pour les tests unitaires qui n'ont pas besoin du pipeline -HTTP, on stub `IAuthorizationService` directement (cf. -`BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`) -pour éviter de monter un host complet. - -## Quand ne PAS écrire de test - -Un test qui ne détecte rien n'est pas un test. Si l'invariant -qu'on cherche à protéger est déjà enforced par EF, par le -compilateur, ou par une couche applicative en amont, le test -est du bruit. Mieux vaut : -- Un test qui assert un **comportement observable** (code - retour HTTP, exception typée, valeur de retour) -- Ou pas de test, et une note dans le code - -La non-régression se prouve par un test qui casse si on -réintroduit le bug. Pas par un test qui passe aujourd'hui et -qui continuera à passer après un revert. diff --git a/external/dotnet-android-build-image b/external/dotnet-android-build-image deleted file mode 160000 index 0695a6c1..00000000 --- a/external/dotnet-android-build-image +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d diff --git a/src/PostIt.Tests/AddCircleMemberDialogTests.cs b/src/PostIt.Tests/AddCircleMemberDialogTests.cs deleted file mode 100644 index 289ff727..00000000 --- a/src/PostIt.Tests/AddCircleMemberDialogTests.cs +++ /dev/null @@ -1,156 +0,0 @@ - -using Avalonia; -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Microsoft.Extensions.DependencyInjection; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; -using Yavsc.Api.Client; - -namespace PostIt.Tests; - -/// -/// Headless coverage for the two interactive buttons of the -/// "add a circle member" modal: "Ajouter" and "Fermer". -/// -/// The dialog is pushed on top of -/// via the canonical App.PushPageAsync pipeline (the -/// same path CirclesPageViewModel.OpenAddMemberAsync -/// uses). The test asserts on NavRoot.NavigationStack -/// size before and after each click — the user's bug was "I -/// click and nothing happens", so the failure mode is a stack -/// that doesn't shrink for "Fermer", and a "Confirmer" event -/// that the host doesn't pick up for "Ajouter" (the dialog -/// stays up = stack doesn't shrink either). -/// -/// Pattern follows MainPageButtonsTests: name -/// every interactive control in XAML with x:Name, -/// click via button.Command?.Execute(...) + flush -/// any async command before asserting. -/// -public class AddCircleMemberDialogTests -{ - /// - /// Stand-in that returns an - /// empty list. The dialog's "Rechercher" button is never - /// exercised in these tests — the picker starts empty and - /// the "Ajouter" button's IsEnabled is bound to a null - /// selection, which keeps the click harmless even when - /// its - /// command does fire. - /// - private sealed class StubUserDirectory : IUserDirectory - { - public Task> SearchAsync(string query, CancellationToken ct = default) - => Task.FromResult>(new List()); - } - - private sealed class ThrowingApi : YavscApiClient - { - public ThrowingApi() : base( - new Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://stub.invalid", - ClientId = "stub", - Scopes = new[] { "openid" }, - }, - }, - new TokenStore(System.IO.Path.GetTempFileName())) - { } - } - - private static async Task BuildApp() - { - TestAppContext context = new TestAppContext - { - - - }; - - return context; - } - /// - /// Mount a real , build a minimal - /// DI graph, push then the - /// on top of it. - /// Returns the stack size so the test can pin the delta. - /// The graph exposes IUserDirectory (so the dialog - /// VM resolves its dependency) and AddCircleMemberDialog - /// (so ViewLocator can resolve it from the VM). - /// - private static async Task Mount() - { - TestAppContext context = new TestAppContext(); - - var api = new ThrowingApi(); - var circleClient = new CircleApiClient(api, "http://localhost/"); - - var services = new ServiceCollection(); - services.AddSingleton(new Settings()); - services.AddSingleton(new StubUserDirectory()); - services.AddSingleton(circleClient); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - var sp = services.BuildServiceProvider(); - - context.Window = new MainWindow(); - context.App = (PostIt.App)Application.Current!; - context.App.DataTemplates.Clear(); - context.App.DataTemplates.Add(new ViewLocator(sp)); - context.App.AttachMainWindow(context.Window); - context.Window.Show(); - - context.page = sp.GetRequiredService(); - context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult(); - - // The "Ajouter un membre" command on CirclesPage builds - // the dialog VM directly (it knows the directory from - // the service provider) and pushes it via App.PushPage. - await context.App.PushPageAsync(sp.GetRequiredService()); - - context.dialog = context.Window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog - ?? throw new System.InvalidOperationException("Dialog page not at top of stack."); - - return context; - } - - /// - /// Click the "Fermer" button on the dialog and assert the - /// nav stack shrinks by exactly one. - /// - [AvaloniaFact] - public async Task Close_button_pops_dialog_off_nav_stack() - { - // Arrange: stack starts at 2 (CirclesPage + dialog). - var context = await Mount(); - var window = context.Window!; - - var stackBefore = window.NavRoot.NavigationStack.Count; - Assert.Equal(2, stackBefore); - - // Act - var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog ?? throw new System.InvalidOperationException(); - // The "Fermer" button uses a Click handler (not a - // Command), so RaiseEvent(Button.ClickEvent) is the - // right way to fire it from headless code. Executing - // Command would no-op because no Command is bound. - - // FIXME Assert.NotNull(dialog.CloseButton): - // in order to click it by its def : - - // dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent)); - - // The workaround is to execute the action like it's written : - await context.App!.GoBackAsync(); - - // Assert: stack -1, the top is the CirclesPage again. - Assert.True(window.NavRoot.NavigationStack.Count == stackBefore - 1, - $"Click on 'Fermer' must shrink the nav stack by one. Before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}."); - Assert.IsType(window.NavRoot.NavigationStack[^1]); - } -} diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs deleted file mode 100644 index fbccb606..00000000 --- a/src/PostIt.Tests/BearerScopeTests.cs +++ /dev/null @@ -1,291 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using PostIt.Services; -using PostIt.Services; -using Xunit; - -namespace PostIt.Tests; - -/// -/// Diagnostic coverage for the 401 we're seeing in production when -/// PostIt talks to Yavsc.Blogs. The hypothesis this file -/// isolates: "the access token sent on the wire is missing the -/// blogs scope that Yavsc.Blogs's BlogScope -/// policy requires". The policy lives in -/// Yavsc.Blogs/Program.cs as -/// RequireClaim(JwtClaimTypes.Scope, "blogs"). -/// -/// -/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or -/// any network listener. The test fakes a single -/// that captures the outbound -/// request, deserialises the bearer JWT, and asserts the -/// scope claim contains the segment the policy needs. This -/// pins the client side of the contract so a future regression in -/// or (e.g. a -/// silently dropped scope, a wrong merge order, a scope string -/// that no longer matches the server policy) trips the test before -/// it reaches production. -/// -/// -public class BearerScopeTests -{ - /// - /// Hard-coded blogs scope string. Mirrors the value in - /// Yavsc.Blogs/Program.cs's BlogScope policy; if - /// the server ever moves to "blog.read" or similar this - /// constant should be updated to match. - /// - private const string RequiredScope = "blogs"; - - [Fact] - public async Task GetPostsAsync_sends_bearer_with_blogs_scope_in_jwt() - { - // Build the exact scope list a user would have in - // postit-settings.json. MergeScopes (called inside - // YavscApiClient when issuing the authorize request) would - // have appended "openid profile offline_access", so the - // access token in real life carries all of them. The test - // pins that the scope the *server* needs survived the - // round trip from settings.json to the access_token. - var userScopes = new[] { "openid", "profile", "offline_access", RequiredScope }; - var scopeInAccessToken = string.Join(' ', userScopes); - - // Mint a fake access token whose only payload claim is - // "scope". No signature: the client never verifies, and the - // production server doesn't see this token (we mock the - // HttpMessageHandler, so the message never leaves the - // process). - var accessToken = MintUnsignedJwt(scopeInAccessToken); - - var settings = new PostIt.ViewModels.Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://example.invalid", - ClientId = "postit-tests", - Scopes = userScopes, - RedirectUri = "postit://callback", - }, - BusinessApiUrl = "https://example.invalid/api/v1/", - }; - - var tokensPath = Path.Combine( - Path.GetTempPath(), $"postit-bearer-scope-{Guid.NewGuid():N}.json"); - try - { - // Pre-seed the token store so YavscApiClient believes - // it has a valid session and CallAsync does not refuse - // to send. - var store = new TokenStore(tokensPath); - store.Save(new RefreshTokenRecord( - AccessToken: accessToken, - RefreshToken: "irrelevant-for-this-test", - AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddHours(1), - IdToken: null)); - - // CapturingHttpHandler is the assertion point. It - // records the first request's Authorization header and - // returns 200 with an empty array (BlogApiClient - // deserialises to List). - var captured = new CapturingHttpHandler(); - var client = new YavscApiClient( - settings, - store, - // Bypass OidcClient construction (it would try to - // resolve an Authority we don't have a real IdP - // for). The handler we inject below is what the - // bearer attaches the token to; refresh paths are - // not exercised in this test. - oidc: null!); - - // YavscApiClient builds its own HttpClient around a - // BearerTokenHandler(new HttpClientHandler()) in its - // constructor; the handler is not exposed for - // replacement. The seam we use: CallAsync is virtual, - // so a subclass that talks to a caller-supplied - // HttpMessageHandler lets us assert on the outbound - // request without standing up any server. - var subClient = new TestableYavscApiClient( - settings, store, captured, accessToken); - - // Resolve a BlogApiClient on top. We don't need real - // posts; we just need the outbound HTTP request to be - // the one we capture. - var blog = new BlogApiClient(subClient, "http://localhost/"); - - await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); - - // The test only makes sense if we did capture - // something. If we got here with an empty capture, the - // BlogApiClient chose a non-HTTP path and this whole - // setup is wrong. - Assert.NotNull(captured.Authorization); - Assert.StartsWith("Bearer ", captured.Authorization); - - var jwt = captured.Authorization.Substring("Bearer ".Length).Trim(); - var scopes = ExtractScopes(jwt); - - Assert.Contains(RequiredScope, scopes); - } - finally - { - if (File.Exists(tokensPath)) File.Delete(tokensPath); - } - } - - // --- helpers ------------------------------------------------------- - - /// - /// Build an unsigned JWT carrying a single scope claim. - /// Mirrors the read-only fallback in - /// : base64url-decode - /// the middle segment, parse JSON, read the scope string. - /// The header and signature are placeholders — nobody in the - /// test path verifies the signature. - /// - private static string MintUnsignedJwt(string scope) - { - var header = Base64Url("""{"alg":"none","typ":"JWT"}"""); - var payload = Base64Url(JsonSerializer.Serialize(new - { - sub = "test-user", - iss = "https://example.invalid", - aud = "postit", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), - iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - scope, - })); - return $"{header}.{payload}."; - } - - private static string Base64Url(string s) - { - var bytes = Encoding.UTF8.GetBytes(s); - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - - /// - /// Pull the scope claim out of a (possibly unsigned) JWT - /// and split on whitespace, the canonical encoding per RFC 8693 - /// §4.2 and OpenID Connect Core 1.0 §5.1. - /// - private static IReadOnlyCollection ExtractScopes(string jwt) - { - var parts = jwt.Split('.'); - Assert.True(parts.Length >= 2, "JWT must have a payload segment"); - - var payload = parts[1].Replace('-', '+').Replace('_', '/'); - switch (payload.Length % 4) - { - case 2: payload += "=="; break; - case 3: payload += "="; break; - } - - using var doc = JsonDocument.Parse(Convert.FromBase64String(payload)); - if (!doc.RootElement.TryGetProperty("scope", out var scopeEl)) - { - return Array.Empty(); - } - var raw = scopeEl.GetString() ?? string.Empty; - return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries); - } - - /// - /// Minimal that records the - /// first request's Authorization header and replies 200 - /// with an empty JSON array. Anything beyond the first request - /// is a regression in the test setup, not the production code - /// path under test. - /// - private sealed class CapturingHttpHandler : HttpMessageHandler - { - public string? Authorization { get; private set; } - public Uri? RequestUri { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - Authorization = request.Headers.Authorization?.ToString(); - RequestUri = request.RequestUri; - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("[]", Encoding.UTF8, "application/json"), - }; - return Task.FromResult(response); - } - } - - /// - /// Subclass of that routes HTTP - /// traffic through a caller-supplied - /// . The base ctor wires - /// Http as new HttpClient(BearerTokenHandler(...)); - /// we don't replace that — we override the public call seam - /// - /// (declared virtual) and talk to our own HttpClient - /// from there. The EnsureFreshToken / 401-retry path - /// is intentionally not exercised here — that lives in - /// YavscApiClientTests; isolating the bearer - /// attachment is the whole point of this test. - /// - private sealed class TestableYavscApiClient : YavscApiClient - { - private readonly HttpClient _http; - private readonly string _accessToken; - - public TestableYavscApiClient( - PostIt.ViewModels.Settings settings, - TokenStore store, - HttpMessageHandler handler, - string accessToken) - : base(settings, store, oidc: null!) - { - _http = new HttpClient(handler, disposeHandler: false); - _accessToken = accessToken; - } - - public override Task CallAsync( - HttpMethod method, string path, object? body = null, - CancellationToken ct = default) - { - // Reproduce just enough of the production request - // shape: a real HttpRequestMessage with the bearer - // attached, so the assertion in the test is faithful. - // We skip the EnsureFreshToken/401-retry machinery on - // purpose — that path is already covered by - // YavscApiClientTests, and isolating the bearer - // attachment is exactly what this test exists for. - // - // The base YavscApiClient relies on HttpClient.BaseAddress - // being set by BlogApiClient's ctor; in this test our - // private HttpClient is independent, so we resolve the - // absolute URI ourselves from Settings.BusinessApiUrl — - // the same URL BlogApiClient would have set as BaseAddress. - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); - using var req = new HttpRequestMessage(method, absolute); - req.Headers.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); - using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); - resp.EnsureSuccessStatusCode(); - using var stream = resp.Content.ReadAsStream(); - var dto = JsonSerializer.Deserialize(stream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - return Task.FromResult(dto!); - } - } -} diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs deleted file mode 100644 index 4b541e42..00000000 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Yavsc.Blogspot; -using PostIt.Services; -using PostIt.ViewModels; -using Yavsc.Models; - -namespace PostIt.Tests; - -/// Per-call ledger shared between the test and the -/// recording fake, so the assertion can inspect what the VM -/// actually sent on the wire without coupling to the fake's -/// internals. -internal sealed class CallRecorder -{ - public (HttpMethod method, string path, object? body) FirstCall => - Calls[0]; - public List<(HttpMethod method, string path, object? body)> Calls { get; } = new(); -} - -/// Test fake that records every CallAsync invocation -/// and answers them with a canned sequence: the first call gets -/// a server-issued BlogPostDto (Id=42), the second call gets a -/// single-element list containing that post. Used by the ViewModel -/// tests and the headless UI test to capture exactly what the -/// Save button posts to the server. -internal sealed class RecordingYavscApiClient : YavscApiClient -{ - private readonly CallRecorder _recorder; - public RecordingYavscApiClient(CallRecorder recorder) - : base( - new Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://stub.invalid", - ClientId = "stub", - Scopes = new[] { "openid" }, - }, - }, - new TokenStore(System.IO.Path.GetTempFileName())) - { - _recorder = recorder; - } - - public override Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - _recorder.Calls.Add((method, path, body)); - // BlogPostDto? boxes to BlogPostDto at runtime, so we test the - // non-nullable type — typeof(BlogPostDto?) is a C# error - // (CS8639: "typeof cannot be used on a nullable reference - // type"). - 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.", - }); - if (typeof(T) == typeof(List)) - return Task.FromResult((T)(object)new List - { - new() { Id = 42, Title = "Mon premier billet" } - }); - return Task.FromResult(default(T)!); - } -} diff --git a/src/PostIt.Tests/BlogPostAuthorDtoTests.cs b/src/PostIt.Tests/BlogPostAuthorDtoTests.cs deleted file mode 100644 index 895f220e..00000000 --- a/src/PostIt.Tests/BlogPostAuthorDtoTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Text.Json; -using Yavsc.Blogspot; - -namespace PostIt.Tests; - -/// -/// Round-trip tests for the wire shape of a blog post as -/// serialised by Yavsc.Blogs and consumed by PostIt. -/// -/// -/// Background: in 1.0.7, BlogPostDto.Author was typed as -/// the abstract interface IApplicationUser. System.Text.Json -/// cannot materialise an interface without a polymorphic -/// converter, so the "load posts" call from PostIt crashed when -/// the server returned a post with a populated Author -/// object. The fix replaced IApplicationUser with a thin -/// concrete DTO, BlogPostAuthorDto, embedded directly in -/// BlogPostDto.Author. -/// -/// -/// -/// These tests pin the wire shape: a JSON document with an -/// Author object must deserialise without throwing and -/// must round-trip the three fields PostIt exposes in the UI -/// (Id, UserName, Avatar). They are intentionally placed in -/// PostIt.Tests — the client-side assembly — so the -/// regression is caught at the deserialisation boundary, where -/// it actually manifested in production. -/// -/// -public class BlogPostAuthorDtoTests -{ - private static readonly JsonSerializerOptions CaseInsensitiveJson - = new() { PropertyNameCaseInsensitive = true }; - - [Fact] - public void BlogPostDto_deserialises_with_populated_author() - { - // A representative JSON shape the server would emit for - // GET /api/BlogApi. The Author object is fully populated - // — that's the shape that used to break deserialisation - // when Author was typed as the abstract IApplicationUser - // interface. - var json = """ - { - "id": 42, - "title": "Premier billet", - "article": "Contenu", - "photo": null, - "dateCreated": "2026-08-01T12:00:00Z", - "dateModified": "2026-08-02T12:00:00Z", - "userCreated": "alice", - "userModified": "alice", - "authorId": "u-alice", - "isPublished": true, - "author": { - "id": "u-alice", - "userName": "alice", - "avatar": "/avatars/alice.png" - } - } - """; - - var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); - - Assert.NotNull(post); - Assert.Equal(42, post!.Id); - Assert.Equal("Premier billet", post.Title); - Assert.Equal("u-alice", post.AuthorId); - Assert.True(post.IsPublished); - - // The actual regression coverage: Author must - // materialise as a concrete DTO, not be left null because - // of a JsonException on IApplicationUser. - Assert.NotNull(post.Author); - Assert.Equal("u-alice", post.Author!.Id); - Assert.Equal("alice", post.Author.UserName); - Assert.Equal("/avatars/alice.png", post.Author.Avatar); - } - - [Fact] - public void BlogPostDto_deserialises_when_author_is_null() - { - // The server is allowed to omit Author (the field is - // nullable on the wire — it maps to a navigation - // property that may not have been Included). The client - // must accept that shape without throwing. - var json = """ - { - "id": 7, - "title": "Sans auteur", - "article": null, - "photo": null, - "dateCreated": "2026-08-01T12:00:00Z", - "dateModified": "2026-08-01T12:00:00Z", - "userCreated": "system", - "userModified": "system", - "authorId": "system", - "isPublished": false, - "author": null - } - """; - - var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); - - Assert.NotNull(post); - Assert.Null(post!.Author); - Assert.Equal("system", post.AuthorId); - } - - [Fact] - public void BlogPostDto_deserialises_when_author_field_is_missing() - { - // Forward-compatibility: an older server that doesn't - // emit the Author field at all. Should not throw. - var json = """ - { - "id": 9, - "title": "Ancien format", - "article": "Pas d'auteur dans la charge utile", - "photo": null, - "dateCreated": "2026-07-01T12:00:00Z", - "dateModified": "2026-07-01T12:00:00Z", - "userCreated": "bob", - "userModified": "bob", - "authorId": "u-bob", - "isPublished": true - } - """; - - var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); - - Assert.NotNull(post); - Assert.Null(post!.Author); - } - - [Fact] - public void BlogPostAuthorDto_serialises_back_to_expected_json_shape() - { - // Pin the wire shape on the way out too. The server - // builds BlogPostAuthorDto from an ApplicationUser and - // PostIt receives it as JSON; if the field names - // change (e.g. case) the round-trip on the client side - // is what would silently break. - // - // The server emits camelCase (ASP.NET Core's Web - // defaults — PropertyNamingPolicy = CamelCase). We - // mirror that here so the test reflects what the wire - // actually looks like. PropertyNameCaseInsensitive on - // the client deserialiser means we don't have to - // hardcode the casing for the inbound assertions. - var author = new BlogPostAuthorDto - { - Id = "u-alice", - UserName = "alice", - Avatar = "/avatars/alice.png" - }; - - var json = JsonSerializer.Serialize(author, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.True(root.TryGetProperty("id", out _)); - Assert.True(root.TryGetProperty("userName", out _)); - Assert.True(root.TryGetProperty("avatar", out _)); - } -} diff --git a/src/PostIt.Tests/FakeAuthorizingBrowser.cs b/src/PostIt.Tests/FakeAuthorizingBrowser.cs index 4748425a..10311500 100644 --- a/src/PostIt.Tests/FakeAuthorizingBrowser.cs +++ b/src/PostIt.Tests/FakeAuthorizingBrowser.cs @@ -10,7 +10,7 @@ namespace PostIt.Tests; /// URL emitted by OidcClient, extracts its state, and returns a /// BrowserResult that mimics the OIDC redirect-with-code callback. /// -/// The paired 's token endpoint accepts +/// The paired 's token endpoint accepts /// any authorization code, so we don't need to mint a real one here. /// public sealed class FakeAuthorizingBrowser diff --git a/src/PostIt.Tests/LoginPageViewModelTests.cs b/src/PostIt.Tests/LoginPageViewModelTests.cs new file mode 100644 index 00000000..e469c011 --- /dev/null +++ b/src/PostIt.Tests/LoginPageViewModelTests.cs @@ -0,0 +1,257 @@ +using System; +using System.Threading.Tasks; +using PostIt.ViewModels; +using Xunit; + +namespace PostIt.Tests; + +public class LoginPageViewModelTests +{ + [Fact] + public async Task LoginAsync_acquires_access_token_from_stubbed_yavsc_authority() + { + // Arrange: spin up a stub OIDC authority and a fake browser that + // short-circuits the system browser. The authority signs its + // access_token with RS256; the fake browser captures the redirect + // URI so the authority can complete the token exchange. + using var authority = await OidcStubAuthority.StartAsync(); + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = authority.Issuer, + ClientId = "postit-tests" + }, + RedirectUri = authority.LoopbackRedirectUri, + Scopes = new[] { "openid", "profile", "blog" } + }; + + var vm = new LoginPageViewModel(settings, browser.CreateBrowser); + + // Act + await vm.LoginAsync(); + + // Assert: the ViewModel surfaced a token, not an error. + Assert.True( + !string.IsNullOrEmpty(vm.AccessToken), + $"Login did not produce a token. StatusMessage={vm.StatusMessage ?? ""}"); + Assert.False( + vm.StatusMessage?.StartsWith("Error") == true, + $"Login reported error: {vm.StatusMessage}"); + } + + [Fact] + public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty() + { + // Regression: when no user settings file exists and the embedded + // default somehow fails to load (e.g. resource stripped at publish + // time), the ViewModel must NOT hand a blank Authority to + // OidcClient — IdentityModel would build a bogus authorize URL + // like "http://127.0.0.1:1/" which the browser rejects with a + // confusing error. Surface a clear, actionable message instead. + // + // SettingsLoadOverride is set to a no-op so the test fixture's + // pre-loaded Settings object survives the call to LoginAsync. + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "", + ClientId = "postit-tests", + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" }, + }; + + var browserInvoked = false; + var vm = new LoginPageViewModel(settings, () => + { + browserInvoked = true; + return null; + }) + { + // Skip the disk / embedded read so the Authority stays empty. + SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask, + }; + + await vm.LoginAsync(); + + Assert.False( + browserInvoked, + "Browser factory was invoked even though Authority was empty."); + Assert.NotNull(vm.StatusMessage); + Assert.Contains("Configuration manquante", vm.StatusMessage); + Assert.Contains("postit-settings.json", vm.StatusMessage); + Assert.True(string.IsNullOrEmpty(vm.AccessToken)); + } + + [Fact] + public async Task LoginAsync_works_when_authority_has_trailing_slash() + { + // Regression: with Authority ending in "/" (the production + // postit-settings.json shape for https://yavsc.pschneider.fr/), + // the discovery URL OidcClient computes must NOT contain a + // double slash before /.well-known/openid-configuration. The + // stub advertises itself without the trailing slash; OidcClient + // must bridge. + using var authority = await OidcStubAuthority.StartAsync(); + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = authority.Issuer + "/", + ClientId = "postit-tests" + }, + RedirectUri = authority.LoopbackRedirectUri, + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, browser.CreateBrowser); + + await vm.LoginAsync(); + + Assert.True( + !string.IsNullOrEmpty(vm.AccessToken), + $"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? ""}"); + } + + [Fact] + public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://yavsc.example.com/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings); + + // Trailing slash on Authority is normalised away. + Assert.Equal( + "https://yavsc.example.com/Account/Register", + vm.RegisterUrl); + Assert.Equal( + "https://yavsc.example.com/Account/ForgotPassword", + vm.ForgotPasswordUrl); + Assert.True(vm.HasRegisterUrl); + Assert.True(vm.HasForgotPasswordUrl); + } + + [Fact] + public void RegisterUrl_is_empty_when_authority_is_unset() + { + var vm = new LoginPageViewModel(new PostIt.Settings()); + Assert.Equal(string.Empty, vm.RegisterUrl); + Assert.Equal(string.Empty, vm.ForgotPasswordUrl); + Assert.False(vm.HasRegisterUrl); + Assert.False(vm.HasForgotPasswordUrl); + } + + [Fact] + public void ConfigMissing_is_true_when_authority_is_unset() + { + var vm = new LoginPageViewModel(new PostIt.Settings()); + Assert.True(vm.ConfigMissing); + Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage); + } + + [Fact] + public void ConfigMissing_is_false_when_authority_is_set() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://yavsc.example.com/", + ClientId = "postit-tests" + } + }; + var vm = new LoginPageViewModel(settings); + Assert.False(vm.ConfigMissing); + } + + [Theory] + [InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")] + [InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")] + [InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")] + public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected) + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings { Authority = authority } + }; + var vm = new LoginPageViewModel(settings); + Assert.Equal(expected, vm.DiscoveryUrl); + // ExternalUrl is the slash-normalised form of Authority. + Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl); + } + + [Fact] + public void DiscoveryUrl_is_empty_when_authority_is_unset() + { + var vm = new LoginPageViewModel(new PostIt.Settings()); + Assert.Equal(string.Empty, vm.DiscoveryUrl); + } + + [Fact] + public async Task LoginAsync_failure_message_includes_discovery_url() + { + // Arrange: settings point at an unreachable authority; the test + // browser throws synchronously to guarantee the catch branch runs. + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://does-not-exist.invalid/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom")); + + // Act + await vm.LoginAsync(); + + // Assert: the surfaced error mentions the canonical discovery URL, + // so it can be copy-pasted into a browser to diagnose reachability. + Assert.NotNull(vm.StatusMessage); + Assert.StartsWith("Error:", vm.StatusMessage); + Assert.Contains( + "https://does-not-exist.invalid/.well-known/openid-configuration", + vm.StatusMessage); + } + + [Fact] + public async Task LoginAsync_reports_discovery_url_when_no_browser_available() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://yavsc.example.com/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, () => null); + + await vm.LoginAsync(); + + Assert.Contains( + "https://yavsc.example.com/.well-known/openid-configuration", + vm.StatusMessage); + } +} \ No newline at end of file diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs deleted file mode 100644 index 767f9c2e..00000000 --- a/src/PostIt.Tests/MainPageButtonsTests.cs +++ /dev/null @@ -1,239 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Headless; -using Avalonia.Headless.XUnit; -using Avalonia.Input; -using Avalonia.Interactivity; -using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.DependencyInjection; -using Yavsc.Api.Client; -using Yavsc.Blogspot; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; - -namespace PostIt.Tests; - -/// -/// Regression coverage for the three toolbar buttons on -/// that the user reported as inoperative: -/// "ACL", "Mes cercles", and "[DEV] Signature". -/// -/// Pattern (per the Avalonia headless testing docs — -/// TestableApp.Headless.XUnit/CalculatorTests): name every -/// interactive control in the XAML with x:Name="...", then -/// in the test focus the named control and raise the click via -/// window.KeyPressQwerty(PhysicalKey.Enter, ...). This is -/// the supported path — searching the visual tree via -/// GetVisualDescendants().OfType<Button>() for a -/// button by Content text is brittle and was tried first; it does -/// not work reliably when the page is hosted inside an -/// , which wraps the -/// pushed page in an internal container that the visual-tree walk -/// does not always expose under headless. -/// -/// The assertion is on the post-click top of -/// : -/// the user's bug is "I click and the dialog / page never opens", -/// so the test fails when the click doesn't push anything onto the -/// stack. We pin γ + sniff léger — the new top must be a non-null -/// , but we do not yet assert the concrete type -/// (that would require a fully stubbed App.ServiceProvider, -/// which is the next iteration of this suite). -/// -/// Each test exercises the bit that would silently break if -/// the wiring was reverted: -/// -/// "ACL" — click with a selected post pushes a page onto -/// the stack. -/// "Mes cercles" — click pushes a page onto the stack. -/// "[DEV] Signature" — click pushes a page onto the -/// stack. -/// -/// -public class MainPageButtonsTests -{ - /// - /// Fake that throws on any - /// wire call. These tests never invoke a command that hits - /// the API — only the click → nav side of the pipeline is - /// asserted. - /// - private sealed class ThrowingApi : YavscApiClient - { - public ThrowingApi() : base( - new Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://stub.invalid", - ClientId = "stub", - Scopes = new[] { "openid" }, - }, - }, - new TokenStore(System.IO.Path.GetTempFileName())) - { } - } - - private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) - { - var api = new ThrowingApi(); - var blog = new BlogApiClient(api, "http://localhost/"); - var circle = new CircleApiClient(api, "http://localhost/"); - var acl = new BlogAclApiClient(api, "http://localhost/"); - // Minimal DI graph: only what MainPageViewModel resolves - // when the user clicks a navigation button. Today that's - // SignaturePageViewModel / CirclesPageViewModel / ACL - // dependencies. The graph intentionally stays local to this - // suite to avoid side effects from App.BuildServices() (real - // token-store wiring). - var services = new ServiceCollection(); - services.AddSingleton(new Settings()); - services.AddSingleton(circle); - services.AddSingleton(acl); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); - if (selectedPost is not null) vm.SelectedPost = selectedPost; - return vm; - } - - /// - /// Mount a real (as - /// SessionStatusBannerTests does), push a - /// with the given VM onto - /// NavRoot. PushAsync is awaited (via - /// GetAwaiter().GetResult()) so the page is on the - /// nav stack before the test tries to interact with its - /// named buttons. The window is shown so the visual tree is - /// realised and KeyPressQwerty has a real - /// to dispatch against. - /// - private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) - { - var window = new MainWindow(); - var page = new MainPage { DataContext = vm }; - var app = (PostIt.App)Application.Current!; - if (vm.Services is not null) - { - app.DataTemplates.Clear(); - app.DataTemplates.Add(new ViewLocator(vm.Services)); - } - app.AttachMainWindow(window); - window.Show(); - window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); - return (window, page); - } - - /// - /// Click a button by focusing it and pressing Enter — the - /// supported headless pattern (cf. CalculatorTests in the - /// Avalonia.Samples repo). Returns the nav-stack count - /// before the click so the caller can assert on the delta. - /// KeyPressQwerty is dispatched on the - /// itself — it is the that owns the - /// headless implementation, and routing the key through any - /// descendant TopLevel (e.g. one obtained via - /// TopLevel.GetTopLevel(button)) fails with a - /// NullReferenceException from the headless impl - /// because the descendant does not carry the - /// PlatformHandle the harness expects. - /// - private static int ClickAndCapture(MainWindow window, Button button) - { - var stackBefore = window.NavRoot.NavigationStack.Count; - button.Command?.Execute(button.CommandParameter); - if (button.Command is IAsyncRelayCommand asyncCommand) - { - asyncCommand.ExecutionTask?.GetAwaiter().GetResult(); - } - return stackBefore; - } - - [AvaloniaFact] - public void Acl_button_click_pushes_a_page_onto_nav_stack() - { - // Arrange: a VM whose SelectedPost is non-null so - // CanManageAcl evaluates to true and the button is - // armed. - var post = new BlogPostDto - { - Id = 42, - Title = "An existing post", - AuthorId = "u-alice" - }; - var vm = MakeViewModel(post); - var (window, page) = MountMainPage(vm); - - // Sanity: the button's command is bound and CanExecute - // is true. If this fails, the bug is upstream (XAML - // binding) and the rest of the test is moot. - var aclButton = page.ManageAclButton; - Assert.NotNull(aclButton.Command); - Assert.True(aclButton.Command.CanExecute(null)); - - // Act - var stackBefore = ClickAndCapture(window, aclButton); - - // Assert γ + sniff léger: stack grew, new top is a Page. - Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, - $"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}."); - var pushed = window.NavRoot.NavigationStack.Last(); - Assert.NotNull(pushed); - Assert.IsAssignableFrom(pushed); - } - - [AvaloniaFact] - public void Circles_button_click_pushes_a_page_onto_nav_stack() - { - // Arrange: OpenCircles has no CanExecute guard today — - // any click should fire it and push the page. - var vm = MakeViewModel(); - var (window, page) = MountMainPage(vm); - - var circlesButton = page.OpenCirclesButton; - Assert.NotNull(circlesButton.Command); - - // Act - var stackBefore = ClickAndCapture(window, circlesButton); - - // Assert - Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, - "Click on 'Mes cercles' must push a new page onto the nav stack."); - var pushed = window.NavRoot.NavigationStack.Last(); - Assert.NotNull(pushed); - Assert.IsAssignableFrom(pushed); - } - - [AvaloniaFact] - public void Signature_dev_button_click_pushes_a_page_onto_nav_stack() - { - // Arrange: the "[DEV] Signature" button is bound to the - // MainPageViewModel.OpenSignatureDevCommand [RelayCommand]. - // The click must push SignaturePage on top of NavRoot. - // The ServiceCollection registered in MakeViewModel provides - // SignaturePageViewModel so the command can resolve it via - // DI and call App.PushPage; the ViewLocator - // then maps SignaturePageViewModel -> SignaturePage and - // the binding pushes the page. - var vm = MakeViewModel(); - var (window, page) = MountMainPage(vm); - - var signatureButton = page.OpenSignatureDevButton; - Assert.NotNull(signatureButton.Command); - Assert.True(signatureButton.Command.CanExecute(null)); - - // Act - var stackBefore = ClickAndCapture(window, signatureButton); - - // Assert - Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, - "Click on '[DEV] Signature' must push a new page onto the nav stack."); - var pushed = window.NavRoot.NavigationStack.Last(); - Assert.NotNull(pushed); - Assert.IsAssignableFrom(pushed); - } -} diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs deleted file mode 100644 index b6bf963a..00000000 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Avalonia.VisualTree; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; -namespace PostIt.Tests; - -/// -/// Headless UI tests for the "Save" flow in . -/// The pattern is the one SessionStatusBannerTests -/// established: [AvaloniaFact], a -/// hosting the page (via a because -/// MainPage is a ContentPage), then drive the -/// controls through their public surface and assert on what -/// saw go on the wire. -/// -/// The bug we are pinning: the title TextBox is -/// currently {Binding SelectedPost.Title, Mode=TwoWay}. -/// When SelectedPost is null (i.e. the user has not yet -/// clicked an item in the posts list — which is the only state -/// in which a brand-new post can be created), the binding has -/// no target and the user's keystrokes are silently dropped. -/// Clicking "Save" then routes to the VM branch -/// if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } } -/// which the controller rejects with 400 "The Title field is -/// required." This test fails on that branch today and will -/// pass once the VM owns a dedicated Title/Article -/// buffer that the XAML binds to and the Save command consumes. -/// -public class MainPageSaveTests -{ - [AvaloniaFact] - public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body() - { - // Arrange: VM with a recording API client, mounted in a - // headless window via a Frame (MainPage is a ContentPage, - // not a Control, so it needs a navigation host). - var recorder = new CallRecorder(); - var api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api, "http://localhost/"); - var viewModel = new MainPageViewModel(blog); - - var page = new MainPage { DataContext = viewModel }; - // MainPage is a ContentPage (a Page, not a Control), so it - // must be hosted in a navigation surface. The production - // MainWindow.axaml uses NavigationPage, and the API is the - // same one App.axaml.cs drives at boot (PushAsync, fire- - // and-forget in prod because the page is the top of the - // stack immediately). - var nav = new NavigationPage(); - _ = nav.PushAsync(page); - var window = new Window { Content = nav }; - window.Show(); - - // Act: type a title into the editor's TextBox without - // first selecting a post in the list — the only state in - // which a new post can be created. Then click Save. - var titleBox = window.GetVisualDescendants() - .OfType() - .First(t => t.PlaceholderText == "Title"); - const string typed = "Mon premier billet"; - titleBox.Text = typed; - - var saveButton = window.GetVisualDescendants() - .OfType - \ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Account/ForgotPasswordConfirmation.cshtml b/src/Yavsc.Org/Views/Account/ForgotPasswordConfirmation.cshtml index 4faaf2d0..d1e50615 100644 --- a/src/Yavsc.Org/Views/Account/ForgotPasswordConfirmation.cshtml +++ b/src/Yavsc.Org/Views/Account/ForgotPasswordConfirmation.cshtml @@ -1,7 +1,5 @@ @model string -@{ - ViewBag.Title = Localizer["Account"]; -}

Check your mail box!

+ diff --git a/src/Yavsc.Org/Views/Account/LoggedOut.cshtml b/src/Yavsc.Org/Views/Account/LoggedOut.cshtml index 82c653c7..dc9fbf7a 100644 --- a/src/Yavsc.Org/Views/Account/LoggedOut.cshtml +++ b/src/Yavsc.Org/Views/Account/LoggedOut.cshtml @@ -1,8 +1,4 @@ -@{ - ViewBag.Title = Localizer["Account"]; -} - -@model LoggedOutViewModel +@model LoggedOutViewModel @{ // set this so the layout rendering sees an anonymous user diff --git a/src/Yavsc.Org/Views/Account/Login.cshtml b/src/Yavsc.Org/Views/Account/Login.cshtml index 54df2e3e..b0ee8e88 100644 --- a/src/Yavsc.Org/Views/Account/Login.cshtml +++ b/src/Yavsc.Org/Views/Account/Login.cshtml @@ -1,7 +1,4 @@ @model LoginViewModel -@{ - ViewBag.Title = Localizer["Account"]; -} - \ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Account/Logout.cshtml b/src/Yavsc.Org/Views/Account/Logout.cshtml index 893807f7..b49dee09 100644 --- a/src/Yavsc.Org/Views/Account/Logout.cshtml +++ b/src/Yavsc.Org/Views/Account/Logout.cshtml @@ -1,8 +1,4 @@ -@{ - ViewBag.Title = Localizer["Account"]; -} - -@model LogoutViewModel +@model LogoutViewModel
diff --git a/src/Yavsc.Org/Views/Account/Register.cshtml b/src/Yavsc.Org/Views/Account/Register.cshtml index 92a7f34e..388b2ecd 100644 --- a/src/Yavsc.Org/Views/Account/Register.cshtml +++ b/src/Yavsc.Org/Views/Account/Register.cshtml @@ -1,11 +1,8 @@ @model RegisterModel -@{ - ViewBag.Title = Localizer["Account"]; -}
@Html.EditorForModel() -
\ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Account/ResetPassword.cshtml b/src/Yavsc.Org/Views/Account/ResetPassword.cshtml index 0cdbb2e3..5f64c727 100644 --- a/src/Yavsc.Org/Views/Account/ResetPassword.cshtml +++ b/src/Yavsc.Org/Views/Account/ResetPassword.cshtml @@ -1,7 +1,4 @@ @model ResetPasswordViewModel -@{ - ViewBag.Title = Localizer["Account"]; -}

Your email : @Model.Email

@@ -18,4 +15,4 @@ -
\ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Account/Signin.cshtml b/src/Yavsc.Org/Views/Account/Signin.cshtml index 871c0c74..079882b3 100644 --- a/src/Yavsc.Org/Views/Account/Signin.cshtml +++ b/src/Yavsc.Org/Views/Account/Signin.cshtml @@ -1,7 +1,4 @@ @model SignInModel -@{ - ViewBag.Title = Localizer["Account"]; -} -
\ No newline at end of file +
diff --git a/src/Yavsc.Org/Views/Activity/Create.cshtml b/src/Yavsc.Org/Views/Activity/Create.cshtml index 0fe9938a..cc305557 100644 --- a/src/Yavsc.Org/Views/Activity/Create.cshtml +++ b/src/Yavsc.Org/Views/Activity/Create.cshtml @@ -20,7 +20,7 @@
+ Name"]
@@ -28,7 +28,7 @@
+ Parent"]
@@ -37,7 +37,7 @@
+ Description"]
@@ -45,7 +45,7 @@
@@ -54,7 +54,7 @@
+
diff --git a/src/Yavsc.Org/Views/Activity/Edit.cshtml b/src/Yavsc.Org/Views/Activity/Edit.cshtml index 0f4e591a..ea77d956 100644 --- a/src/Yavsc.Org/Views/Activity/Edit.cshtml +++ b/src/Yavsc.Org/Views/Activity/Edit.cshtml @@ -19,7 +19,7 @@
- +
@@ -54,14 +54,13 @@
- -
- - +
+ +
+ + +
-
diff --git a/src/Yavsc.Org/Views/Administration/Enroll.cshtml b/src/Yavsc.Org/Views/Administration/Enroll.cshtml index 155e0e6b..78125398 100644 --- a/src/Yavsc.Org/Views/Administration/Enroll.cshtml +++ b/src/Yavsc.Org/Views/Administration/Enroll.cshtml @@ -21,7 +21,7 @@
- +
diff --git a/src/Yavsc.Org/Views/Administration/Haircut.cshtml b/src/Yavsc.Org/Views/Administration/Haircut.cshtml index 0165665c..a59ab801 100644 --- a/src/Yavsc.Org/Views/Administration/Haircut.cshtml +++ b/src/Yavsc.Org/Views/Administration/Haircut.cshtml @@ -1,7 +1,4 @@ @model HaircutAdminViewModel -@{ - ViewBag.Title = Localizer["Administration"]; -} Gestion des couleurs diff --git a/src/Yavsc.Org/Views/Administration/Role.cshtml b/src/Yavsc.Org/Views/Administration/Role.cshtml index e541f75a..68a5710f 100644 --- a/src/Yavsc.Org/Views/Administration/Role.cshtml +++ b/src/Yavsc.Org/Views/Administration/Role.cshtml @@ -21,19 +21,7 @@ @foreach (var user in Model.Users) { - @if (user.UserId==User.GetUserId()) { - You - } - @if (SiteSettings.Value.Admin.EMail == user.Email) { - Admin - } - @if (SiteSettings.Value.Owner.EMail == user.Email) { - Owner - } - @if (!String.IsNullOrWhiteSpace(user.Avatar)) - { - avatar - } + avatar @user.UserName <@(user.Email)> diff --git a/src/Yavsc.Org/Views/ApiScope/Create.cshtml b/src/Yavsc.Org/Views/ApiScope/Create.cshtml index 8297251d..5d3b9b8b 100644 --- a/src/Yavsc.Org/Views/ApiScope/Create.cshtml +++ b/src/Yavsc.Org/Views/ApiScope/Create.cshtml @@ -1,9 +1,20 @@ @model IdentityServer8.EntityFramework.Entities.ApiScope + @{ - ViewBag.Title = Localizer["ApiScope"]; + Layout = null; } + + + + + + Create + + +

ApiScope

+
@@ -53,3 +64,6 @@ + + + diff --git a/src/Yavsc.Org/Views/ApiScope/Delete.cshtml b/src/Yavsc.Org/Views/ApiScope/Delete.cshtml index abb3d26b..166e6110 100644 --- a/src/Yavsc.Org/Views/ApiScope/Delete.cshtml +++ b/src/Yavsc.Org/Views/ApiScope/Delete.cshtml @@ -1,8 +1,18 @@ @model Yavsc.Models.YavscApiScope + @{ - ViewBag.Title = Localizer["ApiScope"]; + Layout = null; } + + + + + + Delete + + +

Are you sure you want to delete this?

YavscApiScope

@@ -51,9 +61,12 @@ @Html.DisplayFor(model => model.ShowInDiscoveryDocument) - + | Back to List +
+ + diff --git a/src/Yavsc.Org/Views/ApiScope/Details.cshtml b/src/Yavsc.Org/Views/ApiScope/Details.cshtml index 9275a114..6c904ce3 100644 --- a/src/Yavsc.Org/Views/ApiScope/Details.cshtml +++ b/src/Yavsc.Org/Views/ApiScope/Details.cshtml @@ -1,8 +1,17 @@ @model Yavsc.Models.YavscApiScope + @{ - ViewBag.Title = Localizer["ApiScope"]; + Layout = null; } + + + + + + Details + +

YavscApiScope

@@ -56,3 +65,5 @@ Edit | Back to List
+ + diff --git a/src/Yavsc.Org/Views/ApiScope/Edit.cshtml b/src/Yavsc.Org/Views/ApiScope/Edit.cshtml index 1466435b..34b111d4 100644 --- a/src/Yavsc.Org/Views/ApiScope/Edit.cshtml +++ b/src/Yavsc.Org/Views/ApiScope/Edit.cshtml @@ -1,8 +1,17 @@ @model Yavsc.Models.YavscApiScope + @{ - ViewBag.Title = Localizer["ApiScope"]; + Layout = null; } + + + + + + Edit + +

YavscApiScope


@@ -56,3 +65,6 @@ + + + diff --git a/src/Yavsc.Org/Views/ApiScope/Index.cshtml b/src/Yavsc.Org/Views/ApiScope/Index.cshtml index 2c64b5c1..2d3bd6c7 100644 --- a/src/Yavsc.Org/Views/ApiScope/Index.cshtml +++ b/src/Yavsc.Org/Views/ApiScope/Index.cshtml @@ -1,10 +1,17 @@ @model IEnumerable @{ - ViewBag.Title = Localizer["ApiScope"]; - + Layout = null; } + + + + + + Index + +

Create New

@@ -68,3 +75,5 @@ } + + diff --git a/src/Yavsc.Org/Views/Blogspot/Details.cshtml b/src/Yavsc.Org/Views/Blogspot/Details.cshtml index dc0bea8a..d7d5a791 100644 --- a/src/Yavsc.Org/Views/Blogspot/Details.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Details.cshtml @@ -7,7 +7,7 @@ \ No newline at end of file + diff --git a/src/Yavsc.Org/Views/HairCutCommand/CommandConfirmation.cshtml b/src/Yavsc.Org/Views/HairCutCommand/CommandConfirmation.cshtml index 4031b7f0..ecdd8876 100644 --- a/src/Yavsc.Org/Views/HairCutCommand/CommandConfirmation.cshtml +++ b/src/Yavsc.Org/Views/HairCutCommand/CommandConfirmation.cshtml @@ -54,7 +54,7 @@
@Html.DisplayNameFor(m => m.Location)
@if (Model.Location == null) { -

Pas de lieu convenu ...

+

Pas de lieu convenu ...

} else { @Html.DisplayFor(m => m.Location) @@ -62,7 +62,7 @@
Notification
-
@if (ViewBag.GooglePayload !=null) +
@if (ViewBag.GooglePayload !=null) { @if (ViewBag.GooglePayload.success>0) {

GCM Notifications sent

@@ -85,10 +85,10 @@
@await Component.InvokeAsync("Bill", Model)
- -
@Html.DisplayNameFor(m=>m.Regularization)
+ +
@Html.DisplayNameFor(m=>m.Regularisation)
@await Component.InvokeAsync("PayPalButton", Model)
- +
diff --git a/src/Yavsc.Org/Views/Home/About.cshtml b/src/Yavsc.Org/Views/Home/About.cshtml index 39062cb2..dbde9bc5 100755 --- a/src/Yavsc.Org/Views/Home/About.cshtml +++ b/src/Yavsc.Org/Views/Home/About.cshtml @@ -1,7 +1,4 @@ @using System.Diagnostics -@{ - ViewBag.Title = Localizer["Home"]; -}

@SiteSettings.Value.Title - À Propos

@@ -70,4 +67,4 @@ Il a accès à la connaissance des journées connues comme libres des artistes p De plus, le droit de retrait est permanent et sa mise en oeuvre immédiate. Les artistes comme les clients peuvent demander leur désinscription, qui désactive immédiatement les publications associées à leurs informations, et programme la suppression complète de ces dites informations dans les quinze jours à compter de la demande, sauf demande contradictoire. L'opération est annulable, jusqu'à deux semaines après sa programmation. - \ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Home/About.pt.cshtml b/src/Yavsc.Org/Views/Home/About.pt.cshtml index cca60a94..85b4d291 100755 --- a/src/Yavsc.Org/Views/Home/About.pt.cshtml +++ b/src/Yavsc.Org/Views/Home/About.pt.cshtml @@ -1,7 +1,3 @@ -@{ - ViewBag.Title = Localizer["Home"]; -} -

@SiteSettings.Value.Title - objetivo

diff --git a/src/Yavsc.Org/Views/Home/AboutIdentityServer.cshtml b/src/Yavsc.Org/Views/Home/AboutIdentityServer.cshtml index b77f2032..b34a19c8 100644 --- a/src/Yavsc.Org/Views/Home/AboutIdentityServer.cshtml +++ b/src/Yavsc.Org/Views/Home/AboutIdentityServer.cshtml @@ -1,7 +1,4 @@ @using System.Diagnostics -@{ - ViewBag.Title = Localizer["Home"]; -} @{ var version = FileVersionInfo.GetVersionInfo(typeof(IdentityServer8.Hosting.IdentityServerMiddleware).Assembly.Location).ProductVersion.Split('+').First(); @@ -32,4 +29,4 @@ and ready to use samples. -
\ No newline at end of file +
diff --git a/src/Yavsc.Org/Views/Home/Basket.cshtml b/src/Yavsc.Org/Views/Home/Basket.cshtml index 120b96d3..d9882ae3 100644 --- a/src/Yavsc.Org/Views/Home/Basket.cshtml +++ b/src/Yavsc.Org/Views/Home/Basket.cshtml @@ -1,7 +1,3 @@ -@{ - ViewBag.Title = Localizer["Home"]; -} - @Model BasketView
    diff --git a/src/Yavsc.Org/Views/Home/Contact.cshtml b/src/Yavsc.Org/Views/Home/Contact.cshtml index 2488e2a0..d7ae2fa7 100755 --- a/src/Yavsc.Org/Views/Home/Contact.cshtml +++ b/src/Yavsc.Org/Views/Home/Contact.cshtml @@ -1,9 +1,5 @@ @using Microsoft.IdentityModel.Protocols.Configuration @model SiteSettings -@{ - ViewBag.Title = Localizer["Home"]; -} -

    Contact

    @Model.Owner.Name

    @@ -13,4 +9,4 @@
    Support: @(Model.Admin.Name)<@(Model.Admin.EMail)>
    Marketing: @(Model.Owner.Name)<@(Model.Owner.EMail)> -
    \ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Home/Privacy.cshtml b/src/Yavsc.Org/Views/Home/Privacy.cshtml index 34ae1803..2f76522b 100644 --- a/src/Yavsc.Org/Views/Home/Privacy.cshtml +++ b/src/Yavsc.Org/Views/Home/Privacy.cshtml @@ -1,6 +1,3 @@ -@{ - ViewBag.Title = Localizer["Home"]; -} = La confidentialité @@ -13,4 +10,4 @@ ne sont transmis à personne. Seul le système et son link:Contact[possesseur] o De plus, le droit de retrait est permanent et sa mise en oeuvre link:/Account/Delete[immédiate]. - \ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Manage/DoDirectCredit.cshtml b/src/Yavsc.Org/Views/Manage/DoDirectCredit.cshtml index b6a21b88..c51ad586 100644 --- a/src/Yavsc.Org/Views/Manage/DoDirectCredit.cshtml +++ b/src/Yavsc.Org/Views/Manage/DoDirectCredit.cshtml @@ -1,9 +1,5 @@ @model DoDirectCreditViewModel -@{ - ViewBag.Title = Localizer["Manage"]; -} -
    @@ -128,4 +124,4 @@
- \ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Manage/ProfileEMailUsage.cshtml b/src/Yavsc.Org/Views/Manage/ProfileEMailUsage.cshtml index 95e70fb0..008897fe 100644 --- a/src/Yavsc.Org/Views/Manage/ProfileEMailUsage.cshtml +++ b/src/Yavsc.Org/Views/Manage/ProfileEMailUsage.cshtml @@ -1,7 +1,4 @@ @model Yavsc.ViewModels.Manage.ProfileEMailUsageViewModel -@{ - ViewBag.Title = Localizer["Manage"]; -}
@@ -20,4 +17,4 @@ -
\ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Manage/SetActivity.cshtml b/src/Yavsc.Org/Views/Manage/SetActivity.cshtml index 48690359..41866484 100644 --- a/src/Yavsc.Org/Views/Manage/SetActivity.cshtml +++ b/src/Yavsc.Org/Views/Manage/SetActivity.cshtml @@ -1,5 +1,5 @@ -@model PerformerProfile -@{ ViewBag.Title = "Your performer profile"; } +@model PerformerProfile +@{ ViewBag.Title = "Your performer profile"; } @section header {