Compare commits

...
Author SHA1 Message Date
94d8eb538c
CHANGELOG: add 1.0.8-rc17 section
All checks were successful
Forgejo Release / release (push) Successful in 9m46s
Dotnet build and test / build (pull_request) Successful in 10m42s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:42:01 +01:00
daf5949392
contrib: gate QUIC reuseport on production + Org only
reuseport is legal only once per addr:port (443/udp). preprod and prod
share the same IP today, so enabling it on preprod-Org clashed with
prod-Org and made nginx refuse the config. QUIC_OPTS now resolves to
" reuseport" only when APP_INSTALL_ENV=production AND the host is Org;
preprod and the Api/Blogs vhosts get a plain `listen 443 quic;`.

Also drop the stray space before `;` when QUIC_OPTS is empty by moving
the separator into the QUIC_OPTS value (`listen 443 quic$QUIC_OPTS;`).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:32:44 +01:00
2c8be78175
presentation 2026-09-25 17:23:27 +01:00
e1ce67a76e
laucnh my Debug session 2026-09-25 17:23:15 +01:00
384f6bc300
IBlogPost porte IsPublished : le endpoint liste ne le lâche plus sur le wire
Le blog API (Yavsc.Blogs/Program.cs) sérialise en System.Text.Json
(.AddControllers() par défaut), et BlogApiController.GetBlogspot retourne
IEnumerable<IBlogPost>. STJ n'émet que les membres du type déclaré
(l'interface), donc IsPublished — qui n'était que sur les classes
concrètes BlogPost / BlogPostDto — était dropped du payload liste.
PostIt lisait alors IsPublished=false pour tout billet, y compris
publiés (cas prod /BlogSpot/Details/13).

Fix : déclarer IsPublished sur IBlogPost. BlogPost et BlogPostDto
l'implémentent déjà. Tests de wire-shape
(IBlogPostWireShapeTests) ajoutés pour verrouiller le contrat au
niveau du boundary.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 15:11:25 +01:00
a17a9cef9a
ApiWebServerFixture : connexion admin Npgsql depuis l'env, plus de mot de passe en source
Applique le même traitement que ServerServicesFixture : la chaîne de
connexion admin PostgreSQL se lit depuis YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION
au lieu d'être codée en dur (mot de passe dev 8*5idas retiré). Sans cette
variable, le chemin Npgsql échoue avec un message explicite plutôt que de
retomber silencieusement sur la base de dev locale.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 15:11:00 +01:00
ed3dc733d3
Projet de tests Yavsc.Server.Tests : BlogSpotService.Details
Ajoute un projet de tests service-level pour BlogSpotService.Details
(owner/public/sponsor/Administrator + ACL scrub + IsPublished + auteurs
de commentaires). Le fixture supporte SQLite (défaut) et PostgreSQL via
YAVSC_SERVER_TEST_DB_PROVIDER=npgsql, sur le modèle de
ApiWebServerFixture (migrations Yavsc.Org, base dédiée yavscServerTestDb,
reset par purge FK-ordonnée). La connexion admin Npgsql se lit depuis
YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION (aucun mot de passe en source).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 14:45:22 +01:00
5553f2f290
Génération contrib : vhosts nginx h3 edge + appsettings (jq) + QUIC logiciel Kestrel
Vhosts nginx (contrib/template.vhost + Makefile, envsubst allowlist) :
HTTP/3 edge per contrib/quic.md — listen 443 quic reuseport (Org only),
http2/http3 on, Alt-Svc, ssl_protocols TLSv1.3. Cibles generate-nginx /
generate-nginx-prod -> generated/nginx/ (gitignoré). Allowlist préserve
les variables nginx intrinsèques ($host, $scheme, ...).

Appsettings (contrib/template.appsettings-{org,api,blogs}.json + Makefile) :
génération via jq (et non envsubst) car le mot de passe SMTP contient un qui cassait le JSON. Substitue $DOMAIN (DOMAIN_ROOT) partout et injecte
$SMTP_PASSWORD depuis env (échappement JSON, secret non exposé en cmd).
Cible generate-appsettings -> generated/appsettings/.

QUIC logiciel : Kestrel:Endpoints:Https:Protocols = Http1AndHttp2AndHttp3
sur les 3 templates appsettings (capacité, non joignable derrière nginx).

Fix bonus : générateur systemd HTTP_PORT"$($*_PORT)" (était
"$*_$(HTTP_PORT)" -> port vide localhost:Org_).

Runbook contrib/README.nginx.md. Prérequis host : nginx http_v3_module,
UDP 443, libmsquic (Kestrel h3).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 13:31:56 +01:00
3fe7b8b46c
templates for appsettings 2026-09-25 13:27:00 +01:00
35 changed files with 1376 additions and 138 deletions

10
.vscode/launch.json vendored
View file

@ -43,9 +43,15 @@
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc Org",
"type": "dotnet", "type": "coreclr",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", "program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"cwd": "${workspaceFolder}/src/Yavsc.Org",
"preLaunchTask": "build-web-yavsc-org"
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc Blogs",

28
.vscode/tasks.json vendored
View file

@ -89,6 +89,34 @@
}, },
"isBackground": true "isBackground": true
}, },
{
"label": "build-web-yavsc-org",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Org"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "build-web-yavsc-blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Blogs"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{ {
"label": "dotnet: build-postit-desktop", "label": "dotnet: build-postit-desktop",
"type": "process", "type": "process",

View file

@ -1,5 +1,68 @@
# Changelog # Changelog
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
production où `IsPublished` disparaissait du payload de liste du blog API.
### Added
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
le détail de demande et la page d'édition de devis.
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
(`EstimateApiController`) et les demandes front-office
(`FrontOfficeApiController`).
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
`UserFileAttachments`.
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
`README.nginx.md`.
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
`FileSystemApiTests` (pièces jointes et stockage perso).
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
### Changed
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
production sur le vhost Org. La preprod et la prod partageant la même
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
la config par nginx.
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
provient de la variable d'environnement
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
source.
### Fixed
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
de la liste du blog API. Le endpoint `GetBlogspot` retourne
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
déclaré (l'interface), qui omet les propriétés du type concret —
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
`CommentsController` (Index/Details/…) atterrissaient sur la même route
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
`SwaggerGeneratorException` (conflit method/path) au chargement de
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
les actions scaffoldées.
## [1.0.8-rc16] - unstable ## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être Flux de signature et de génération de devis : un devis peut désormais être

View file

@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \ HTTP_PORT="$($*_PORT)" \
APPDIR="$(APPDIR)" \ APPDIR="$(APPDIR)" \
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_HOST="$(POSTGRES_HOST)" \
@ -45,6 +45,76 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) ---
# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr)
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
NGINX_TEMPLATE := template.vhost
NGINX_OUT_DIR := generated/nginx
NGINX_HOSTS := Org Blogs Api
DOMAIN_ROOT ?= pschneider.fr
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
# prod Org -> yavsc.pschneider.fr
# preprod Org -> preprod-yavsc.pschneider.fr
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
@cat $(NGINX_TEMPLATE) \
| DOMAIN="$(call nginx_domain,$*)" \
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
HTTP_HOST="$(HTTP_HOST)" \
BACKEND_PORT="$($*_PORT)" \
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
@echo Created nginx vhost: $@
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
generate-nginx-prod:
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
$(NGINX_OUT_DIR):
@mkdir -p $@
# --- appsettings (générés depuis template.appsettings-*.json) ---
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
#
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
# contient un \). jq lit le secret depuis l'environnement (export) ->
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
export ASPNETCORE_Smtp__Password
APPSETTINGS_OUT_DIR := generated/appsettings
APPSETTINGS_HOSTS := org api blogs
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
@jq --arg domain "$(DOMAIN_ROOT)" \
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
template.appsettings-$*.json > $@
@echo Created appsettings: $@
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
$(APPSETTINGS_OUT_DIR):
@mkdir -p $@
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*

134
contrib/README.nginx.md Normal file
View file

@ -0,0 +1,134 @@
# Vhosts nginx — génération par `make generate-nginx`
Les vhosts nginx sont **générés** à l'image des unités de service systemd
(`template.service` + `envsubst` → `generated/*.service`) : un template
unique `contrib/template.vhost` est substitué par `envsubst` vers
`contrib/generated/nginx/`, qui est **gitignoré**.
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
`template.appsettings-*.json`) est une capacité complémentaire — non
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
uniquement), mais disponible si Kestrel est un jour exposé en direct.
Directives générées (par vhost) :
```nginx
listen 443 ssl; listen [::]:443 ssl; http2 on;
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_protocols TLSv1.3;
```
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
confondus).
## Prérequis côté host
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
`listen ... quic` suffit à activer h3) :
```bash
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
```
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
backports ou `nginx-full`.
2. **UDP 443 ouvert** :
```bash
sudo ufw allow 443/udp
sudo ufw status | grep '443/udp' # → ALLOW
```
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
échoue sur le bind IPv6).
4. Certbot / certs Let's Encrypt déjà en place (chemins
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
## Génération
```bash
cd contrib
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
make generate-nginx
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
make generate-nginx-prod
# ou manuellement :
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
```
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
`https` en prod, `http` en preprod.
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|-------|--------------|-------------|-------------------------|------------------------|
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
Domaines : `<prefix><subdomain>.pschneider.fr`
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
## Déploiement sur le host
```bash
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
# (idem yavscApi, yavscBlogs)
sudo nginx -t && sudo systemctl reload nginx
```
## Vérification (sur le host)
```bash
# 1. Module h3 présent
nginx -V 2>&1 | grep -o 'http_v3_module'
# 2. UDP 443 ouvert
sudo ufw status | grep '443/udp'
# 3. En-tête Alt-Svc annoncé
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
# 5. Fallback h2 et h1 toujours fonctionnels
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
```
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
Network → colonne « Protocole » → `h3` après mise à niveau.
## Comment ça marche (template + envsubst)
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
via `envsubst` avec une **allowlist** :
```makefile
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
```
Cette allowlist limite la substitution aux 5 variables citées, de sorte
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
allowlist, `envsubst` détruirait ces variables nginx.
## Hors périmètre
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
systemd + nginx).
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).

View file

@ -0,0 +1,33 @@
{
"Site": {
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["api"],
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN"
],
"Bills": "bills",
"TempDir": "temp"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:92"
},
"Https": {
"Url": "https://localhost:93",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -0,0 +1,40 @@
{
"Site": {
"Title": "Yavsc Blogs",
"Description": "A collection of blogs about software development, technology, and programming.",
"Keywords": "software development, technology, programming, coding, blogs",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["blogs"],
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"Blog": "blog"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:90"
},
"Https": {
"Url": "https://localhost:91",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -0,0 +1,74 @@
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"AllowedHosts": "*",
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": [
"blogs",
"api"
],
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
"ApiUrl": "https://preprod-api.$DOMAIN",
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN",
"https://preprod-api.$DOMAIN"
],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Admin": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"Blog": "blog",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"
},
"Smtp": {
"Host": "mercure.pschneider.fr",
"Port": 465,
"SenderName": "Paul Schneider",
"SenderEmail": "paul@pschneider.fr",
"UserName": "paul",
"Password": "$SMTP_PASSWORD"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:88"
},
"Https": {
"Url": "https://localhost:89",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

61
contrib/template.vhost Normal file
View file

@ -0,0 +1,61 @@
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
#
# Variables substituées par envsubst (allowlist) :
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
# même IP, donc seule la prod (Org) l'active.
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
# préservées (hors allowlist).
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
# ACME ; ne pas servir l'app en HTTP clair.)
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
return 301 https://$host$request_uri;
}
server {
# 1. TCP : HTTPS classique + HTTP/2
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
listen 443 quic$QUIC_OPTS;
listen [::]:443 quic$QUIC_OPTS;
http3 on;
server_name $DOMAIN;
# 3. Certificats Let's Encrypt (Certbot).
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
ssl_protocols TLSv1.3;
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 100s;
proxy_buffering off;
}
include server-status.conf;
}

View file

@ -11,7 +11,7 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -5,7 +5,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -15,5 +15,23 @@ namespace Yavsc.Blogspot
// BlogPost entity implements this getter by mapping its // BlogPost entity implements this getter by mapping its
// ApplicationUser navigation to a BlogPostAuthorDto. // ApplicationUser navigation to a BlogPostAuthorDto.
BlogPostAuthorDto? Author { get; } BlogPostAuthorDto? Author { get; }
/// <summary>
/// Whether this post is published. The server derives this
/// from the existence of a row in <c>BlogSpotPublication</c>
/// and serves it on the wire so clients (PostIt) can render
/// the current publication state without a follow-up request.
///
/// <para>This MUST be declared on the interface — not just on
/// the concrete <c>BlogPost</c> entity / <c>BlogPostDto</c> —
/// because the blog API's list endpoint returns
/// <c>IEnumerable&lt;IBlogPost&gt;</c>, and the blog API host
/// serialises with System.Text.Json, which emits only the
/// members of the declared (interface) type. When
/// <c>IsPublished</c> lived only on the concrete classes, STJ
/// dropped it from the list payload and PostIt read
/// <c>false</c> for every post, including published ones.</para>
/// </summary>
bool IsPublished { get; set; }
} }
} }

View file

@ -11,7 +11,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -17,7 +17,7 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -24,7 +24,6 @@ public sealed class ApiWebServerFixture : WebHostFixture
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb"; private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
protected override int HttpsPort => 5104; protected override int HttpsPort => 5104;
@ -168,25 +167,32 @@ public sealed class ApiWebServerFixture : WebHostFixture
private static string BuildAdminConnectionString() private static string BuildAdminConnectionString()
{ {
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar); var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
var source = string.IsNullOrWhiteSpace(configured) if (string.IsNullOrWhiteSpace(configured))
? DefaultDevelopmentConnectionString throw new InvalidOperationException(
: configured; $"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(source) var builder = new NpgsqlConnectionStringBuilder(configured)
{ {
Pooling = false, Pooling = false,
IncludeErrorDetail = true IncludeErrorDetail = true
}; };
if (string.IsNullOrWhiteSpace(configured)) // The admin connection targets the maintenance database
{ // (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
if (string.IsNullOrWhiteSpace(builder.Database))
builder.Database = "postgres"; builder.Database = "postgres";
}
else if (string.IsNullOrWhiteSpace(builder.Database))
{
builder.Database = "postgres";
}
return builder.ToString(); return builder.ToString();
} }

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,87 @@
using System.Text.Json;
using Yavsc.Blogspot;
using Yavsc.Models.Blog;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Wire-shape tests pinning that <see cref="IBlogPost.IsPublished"/>
/// is carried on the JSON the blog API list endpoint emits.
///
/// <para>The blog API host (<c>Yavsc.Blogs/Program.cs</c>) serialises
/// with System.Text.Json (the <c>.AddControllers()</c> default — no
/// <c>AddNewtonsoftJson</c>), and <c>BlogApiController.GetBlogspot</c>
/// returns <c>IEnumerable&lt;IBlogPost&gt;</c>. System.Text.Json emits
/// only the members of the <b>declared</b> type, so a property that
/// exists solely on the concrete <c>BlogPost</c> entity is dropped from
/// the payload. <c>IsPublished</c> used to be one such property: it was
/// on <c>BlogPost</c> and <c>BlogPostDto</c> but not on
/// <see cref="IBlogPost"/>, so the list response omitted it and PostIt
/// read <c>IsPublished == false</c> for every post — including
/// published ones.</para>
///
/// <para>These tests reproduce that serialisation path directly
/// (declared type <c>IEnumerable&lt;IBlogPost&gt;</c> through
/// <see cref="JsonSerializer.Serialize(object, Type)"/>) and assert
/// <c>isPublished</c> survives the round trip. They live in
/// <c>Yavsc.Blogs.Tests</c> — the assembly that owns the blog API
/// contract — so a regression at the wire boundary is caught here,
/// where it manifested in production (PostIt showing a published
/// post's status as <c>false</c>).</para>
/// </summary>
public sealed class IBlogPostWireShapeTests
{
private static readonly JsonSerializerOptions WebDefaults
= new(JsonSerializerDefaults.Web);
[Fact]
public void IsPublished_is_emitted_when_list_is_serialised_as_IEnumerable_IBlogPost()
{
var post = new BlogPost
{
Id = 13,
Title = "Published",
AuthorId = "alice",
Article = "x",
};
post.IsPublished = true;
// Declared type mirrors GetBlogspot's return: IEnumerable<IBlogPost>.
IEnumerable<IBlogPost> list = new List<BlogPost> { post };
// Serialize with the DECLARED type, the way ASP.NET Core's
// SystemTextJsonOutputFormatter hands context.ObjectType (the
// action return type) to JsonSerializer.Serialize.
var json = JsonSerializer.Serialize(list, typeof(IEnumerable<IBlogPost>),
WebDefaults);
using var doc = JsonDocument.Parse(json);
var element = doc.RootElement[0];
// Web defaults use camelCase, so the property is "isPublished".
Assert.True(element.TryGetProperty("isPublished", out var isPublished),
$"isPublished was dropped from the list payload. JSON: {json}");
Assert.True(isPublished.GetBoolean());
}
[Fact]
public void IsPublished_round_trips_through_BlogPostDto_on_the_client()
{
// The shape the client (PostIt) receives and deserialises with
// PropertyNameCaseInsensitive = true (cf. YavscApiClient).
var json = """
[{
"id": 13,
"title": "Published",
"article": "x",
"authorId": "alice",
"isPublished": true
}]
""";
var posts = JsonSerializer.Deserialize<List<BlogPostDto>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var post = Assert.Single(posts);
Assert.True(post.IsPublished);
}
}

View file

@ -9,7 +9,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -11,7 +11,7 @@
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath> <RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -2,7 +2,7 @@
style="background-image: url('@SiteSettings.Value.Banner'); style="background-image: url('@SiteSettings.Value.Banner');
background-position: 0%; background-position: 0%;
background-attachment: fixed;"> background-attachment: fixed;">
<a class="navbar-brand" href="/"> <a class="navbar-brand logo" href="/">
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" /> <img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
@SiteSettings.Value.Title @SiteSettings.Value.Title
</a> </a>

View file

@ -9,7 +9,7 @@
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,104 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"YavscConnection": "*** via dotnet user-secrets ou variable d'environnement *** Server=[YOURSERVERNAME];Port=5432;Database=[YOURDBNAME];Username=[YOURDBUSERNAME];Password=[YOURDBPASSW];"
},
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://[Your domaine name]",
"ExternalUrl": "https://[Your domaine name]",
"CorsAllowedOrigins": [
"https://your.api.url.com/api/v1",
"https://localhost:5003/api/v1",
"https://localhost:5005/api/v1"
],
"Audience": [ "api", "blogs"],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "[Site owner's name]",
"EMail": "[Site owner's e-mail address]"
},
"Admin": {
"Name": "[a name for a site admin]",
"EMail": "[an e-mail for a site admin]"
},
"Avatars": "avatars",
"Quota": 200000000,
"Bills": "bills",
"Blog": "blog",
"TempDir": "temp",
"DataDir": "data"
},
"Smtp": {
"Host": "[YOURSMTPHOST]",
"Port": 465,
"Username": "[YOURSMTPUSERNAME]",
"Password": "[YOURSMTPPASSWORD]"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Authentication": {
"PayPal": {
"Mode": "[sandbox|production]",
"ClientId": "[Your PayPal REST API ClientId]",
"Secret": "[Your PayPal REST API Secret]"
},
"Google": {
"ApiKey": "[Your ApiKey]",
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]",
"ServiceAccount": {
"project_id": "[Yours]",
"private_key_id": "[Yours]",
"private_key": "-----[Yours]---\n",
"client_email": "[Yours]@appspot.gserviceaccount.com",
"client_id": "[Yours]",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www41.googleapis.com/robot/v1/[Yours].com"
}
},
"Facebook": {
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]"
},
"Twitter": {
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]"
}
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:82"
},
"Https": {
"Url": "https://localhost:83",
"Certificate": {
"Path": "/etc/letsencrypt/live/[Your domaine name]/cert.pem",
"KeyPath": "/etc/letsencrypt/live/[Your domaine name]/privkey.pem"
}
}
}
}
}

View file

@ -5033,7 +5033,8 @@ fieldset:disabled .btn {
background-color: var(--bs-card-bg); background-color: var(--bs-card-bg);
background-clip: border-box; background-clip: border-box;
border: var(--bs-card-border-width) solid var(--bs-card-border-color); border: var(--bs-card-border-width) solid var(--bs-card-border-color);
border-radius: var(--bs-card-border-radius) border-radius: var(--bs-card-border-radius);
padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
} }
.card>hr { .card>hr {

View file

@ -0,0 +1,222 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Tests.Fixtures;
namespace Yavsc.Server.Tests;
/// <summary>
/// Service-level tests for <see cref="BlogSpotService.Details"/>.
///
/// <para><see cref="BlogSpotService.Details"/> loads a
/// <see cref="BlogPost"/> (with Author, Tags, Comments, ACL and
/// Publication navigations), runs a resource-based
/// <c>ReadPermission</c> authorization against it, scrubs the ACL for
/// non-owners, derives <see cref="BlogPost.IsPublished"/> from the
/// loaded <c>Publication</c> navigation, and eagerly loads each
/// comment's <c>Author</c>. These tests pin each of those behaviours
/// at the service layer against the real
/// <see cref="Yavsc.Services.PermissionHandler"/> (the public/owner/
/// sponsor/Administrator disjunction) and a real SQLite store — no
/// mocking.</para>
///
/// <para>Seeding and the service call resolve through separate DI
/// scopes so the DbContext that writes the seed rows is disposed
/// before the one <see cref="BlogSpotService.Details"/> reads through,
/// mirroring the per-request DbContext lifetime in production.</para>
/// </summary>
[Collection("Yavsc Server")]
public sealed class BlogSpotServiceDetailsTests : IClassFixture<ServerServicesFixture>
{
private readonly ServerServicesFixture _fixture;
public BlogSpotServiceDetailsTests(ServerServicesFixture fixture)
{
_fixture = fixture;
}
/// <summary>An authenticated principal whose <c>sub</c> claim is
/// <paramref name="userId"/>. <see cref="Yavsc.Server.Helpers.UserHelpers.GetUserId"/>
/// reads <c>sub</c> first, which is what the JwtBearer middleware
/// in production preserves (MapInboundClaims = false), and what
/// <see cref="Yavsc.Services.PermissionHandler"/> compares against
/// <see cref="BlogPost.AuthorId"/>.</summary>
private static ClaimsPrincipal Authenticated(string userId,
params Claim[] extra)
{
var claims = new List<Claim> { new("sub", userId) };
claims.AddRange(extra);
return new ClaimsPrincipal(
new ClaimsIdentity(claims, authenticationType: "Bearer"));
}
/// <summary>An unauthenticated principal: a
/// <see cref="ClaimsIdentity"/> with no authentication type has
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false. The
/// service still runs authorization — a published post's
/// <c>ReadPermission</c> succeeds purely on IsPublic, with no
/// identity required.</summary>
private static ClaimsPrincipal Anonymous()
=> new(new ClaimsIdentity());
/// <summary>The Microsoft-format role claim
/// <see cref="Yavsc.Server.Helpers.UserHelpers.IsInMsRole"/>
/// reads when checking the "Administrator" bypass in
/// <see cref="Yavsc.Services.PermissionHandler"/>.</summary>
private static Claim AdminRole()
=> new("http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"Administrator");
[Fact]
public async Task Details_returns_the_post_for_its_owner_and_keeps_the_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var circleId = _fixture.SeedCircle("alice", "alice-private");
var postId = _fixture.SeedBlogPost("alice", "Owner reads own draft");
_fixture.SeedAcl(postId, circleId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
Assert.Equal(postId, blog.Id);
Assert.Equal("Owner reads own draft", blog.Title);
// The owner is the one viewer ScrubAclForViewer keeps the ACL
// for — a non-owner would see an empty list.
Assert.Single(blog.ACL!);
// No BlogSpotPublication row was seeded, so IsPublished is
// derived from the (null) Publication navigation as false.
Assert.False(blog.IsPublished);
}
[Fact]
public async Task Details_serves_IsPublished_true_when_a_publication_row_exists()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var postId = _fixture.SeedBlogPost("alice", "Published post");
_fixture.Publish(postId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
// IsPublished is a wire-only value the service derives from
// the loaded Publication navigation (Details Includes it).
Assert.True(blog.IsPublished);
}
[Fact]
public async Task Details_lets_an_anonymous_viewer_read_a_published_post_and_scrubs_its_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var circleId = _fixture.SeedCircle("alice", "alice-private");
var postId = _fixture.SeedBlogPost("alice", "Public post");
// The ACL is real, but a non-owner viewer must not see it.
_fixture.SeedAcl(postId, circleId);
_fixture.Publish(postId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Anonymous(), postId));
// ReadPermission succeeds purely on IsPublic (the publication
// row) — no identity needed.
Assert.Equal(postId, blog.Id);
Assert.True(blog.IsPublished);
// ScrubAclForViewer: the anonymous viewer is not the owner, so
// the ACL is replaced with an empty list rather than leaked.
Assert.Empty(blog.ACL!);
}
[Fact]
public async Task Details_denies_a_draft_to_a_non_owner_who_is_not_a_sponsor()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
var postId = _fixture.SeedBlogPost("alice", "Alice's draft");
// No publication row (not public), bob is not the owner, bob
// is not a member of any circle alice owns, bob is not an
// Administrator — every branch of ReadPermission fails.
await Assert.ThrowsAsync<AuthorizationFailureException>(() =>
_fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("bob"), postId)));
}
[Fact]
public async Task Details_lets_a_sponsor_read_a_draft_and_scrubs_its_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
// Sponsorship = bob is a member of a circle alice owns.
var circleId = _fixture.SeedCircle("alice", "alice-friends");
_fixture.SeedCircleMember(circleId, "bob");
var postId = _fixture.SeedBlogPost("alice", "Draft for friends");
_fixture.SeedAcl(postId, circleId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("bob"), postId));
// ReadPermission succeeds on the IsSponsor branch.
Assert.Equal(postId, blog.Id);
Assert.False(blog.IsPublished);
// bob is not the owner, so the ACL is scrubbed even though he
// was granted read access via the circle.
Assert.Empty(blog.ACL!);
}
[Fact]
public async Task Details_lets_an_administrator_read_a_draft()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("root");
var postId = _fixture.SeedBlogPost("alice", "Draft, admin view");
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("root", AdminRole()), postId));
// The Administrator bypass in PermissionHandler short-circuits
// the public/owner/sponsor checks.
Assert.Equal(postId, blog.Id);
Assert.False(blog.IsPublished);
}
[Fact]
public async Task Details_loads_each_comment_author()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
var postId = _fixture.SeedBlogPost("alice", "Post with comments");
_fixture.SeedComment(postId, "bob", "Nice post!");
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
var comment = Assert.Single(blog.Comments);
// Details explicitly loads c.Author from _context.Users for
// every comment; the navigation must not be left null.
Assert.NotNull(comment.Author);
Assert.Equal("bob", comment.Author.UserName);
}
[Fact]
public async Task Details_throws_for_a_missing_post()
{
_fixture.ResetDatabase();
// The query uses SingleAsync, which throws
// InvalidOperationException when no row matches — the
// `if (blog == null) return null` guard below it is unreachable.
// Pin that current behaviour so a future switch to
// SingleOrDefaultAsync (which would make the guard live) is a
// deliberate, reviewed change rather than a silent one.
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), 424242)));
}
}

View file

@ -0,0 +1,452 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Npgsql;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services;
namespace Yavsc.Server.Tests.Fixtures;
/// <summary>
/// Service-level test host for <see cref="BlogSpotService"/> and the
/// other Yavsc.Server services that take an
/// <see cref="ApplicationDbContext"/> + <see cref="IAuthorizationService"/>.
///
/// <para>Unlike <c>BlogsWebServerFixture</c> (in Yavsc.Blogs.Tests),
/// this fixture does <b>not</b> spin up Kestrel, JWT bearer, or MVC
/// controllers. <see cref="BlogSpotService.Details"/> takes the
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> as a parameter,
/// so the caller controls identity directly — there is no HTTP/auth
/// middleware in the path under test. Building only the DI graph the
/// service actually resolves keeps these tests fast and isolates the
/// service layer from the blog API's transport concerns, which
/// Yavsc.Blogs.Tests already covers end-to-end.</para>
///
/// <para><b>Database provider.</b> Mirrors
/// <c>ApiWebServerFixture</c>: SQLite <c>:memory:</c> by default, or
/// real PostgreSQL when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c> is
/// set. Production runs on Npgsql, so the Npgsql path — which applies
/// the real <c>Yavsc.Org</c> migrations against a dedicated test
/// database — is the one that catches provider-specific bugs (FK
/// enforcement, type mappings, migration drift). SQLite stays the
/// default so the suite runs out-of-the-box with no external server.</para>
///
/// <list type="bullet">
/// <item><description>SQLite: a named <c>:memory:</c> database
/// (Cache=Shared) backed by a single connection held open for the
/// fixture's lifetime, schema via <c>EnsureCreated</c>. Reset is
/// <c>EnsureDeleted</c> + <c>EnsureCreated</c>.</description></item>
/// <item><description>Npgsql: a dedicated database
/// <c>yavscServerTestDb</c> (created from the admin connection if it
/// does not exist), schema via <c>Migrate</c> using the
/// <c>Yavsc.Org</c> migrations assembly. Reset purges the blog-graph
/// tables in FK order — children before parents — so no DELETE
/// violates a constraint, instead of dropping the database (which
/// would force a full re-migration per test).</description></item>
/// <item><description>The real <see cref="BlogSpotService"/> and the
/// real <see cref="PermissionHandler"/>: <c>Details</c> calls
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new
/// ReadPermission())</c>, and <see cref="PermissionHandler"/> answers
/// that requirement with the public/owner/sponsor/Administrator
/// disjunction that the tests below pin. No mocking.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: <c>Details</c> never touches the file system, but the DI
/// container needs an instance to construct the service.</description></item>
/// </list>
///
/// <para>Marked <see cref="CollectionDefinitionAttribute"/> so a single
/// fixture instance (one store, one DI container) is shared across
/// every <c>[Collection("Yavsc Server")]</c> test class. Each test
/// resets the store via <see cref="ResetDatabase"/>.</para>
/// </summary>
[CollectionDefinition("Yavsc Server")]
public sealed class ServerServicesFixture : IDisposable
{
private const string DbProviderEnvVar = "YAVSC_SERVER_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscServerTestDb";
private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString;
private readonly SqliteConnection? _sqliteConnection;
private readonly ServiceProvider _services;
public IServiceProvider Services => _services;
public ServerServicesFixture()
{
var services = new ServiceCollection();
if (UseNpgsqlProvider())
{
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseNpgsql(npgsqlConnectionString,
x => x.MigrationsAssembly("Yavsc.Org")));
}
else
{
// Mode=Memory + Cache=Shared: a named in-memory database
// every connection string referencing "YavscServerTests"
// resolves to the same backing store, as long as at least
// one connection stays open against it. _sqliteConnection
// is held for the fixture's lifetime (disposed in Dispose),
// so the store survives across scoped DbContexts per test.
_sqliteConnection = new SqliteConnection(
"Data Source=YavscServerTests;Mode=Memory;Cache=Shared");
_sqliteConnection.Open();
services.AddDbContext<ApplicationDbContext>(opt =>
// UseSqlite(DbConnection) keeps _sqliteConnection alive
// for the DbContext's lifetime instead of letting EF
// open and close its own — without this each DbContext
// would get a fresh connection pointing at an empty
// store.
opt.UseSqlite(_sqliteConnection));
}
// BlogSpotService.AttachFiles writes under siteSettings.Blog.
// Details itself doesn't read it, but the service constructor
// throws if SiteSettings.Blog is null, so configure a temp
// value to keep construction alive.
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
// Trivial file-system auth: Details never calls into it, but
// the DI container needs an instance to construct the service.
services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager());
// The real BlogSpotService — same shape the production host
// builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager, IOptions<SiteSettings>,
// ILoggerFactory).
services.AddScoped<BlogSpotService>();
// The real PermissionHandler: Details authorizes with
// ReadPermission, and PermissionHandler resolves it via the
// IsPublic / IsOwner / IsSponsor / Administrator disjunction.
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
// Default authorization services register
// IAuthorizationService (DefaultAuthorizationService), which
// collects the IAuthorizationHandler above and runs it against
// the requirement passed to AuthorizeAsync.
services.AddAuthorization();
services.AddLogging();
_services = services.BuildServiceProvider();
// Create the schema once. Npgsql uses Migrate (the Yavsc.Org
// migrations) so the test schema matches production; SQLite uses
// EnsureCreated (idempotent, model-derived). ResetDatabase
// re-creates / purges per test.
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
db.Database.Migrate();
else
db.Database.EnsureCreated();
}
}
/// <summary>True when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c>
/// is set — the fixture then runs against a real PostgreSQL server
/// instead of the SQLite in-memory default.</summary>
public static bool UseNpgsqlProvider()
=> string.Equals(
Environment.GetEnvironmentVariable(DbProviderEnvVar),
"npgsql",
StringComparison.OrdinalIgnoreCase);
/// <summary>Reset the store to a known empty state for the next
/// test. SQLite: <c>EnsureDeleted</c> + <c>EnsureCreated</c> (the
/// named in-memory store survives because _sqliteConnection is held
/// open). Npgsql: purge the blog-graph tables in FK order —
/// children before parents — so no DELETE trips a foreign key,
/// avoiding a full re-migration per test.</summary>
public void ResetDatabase()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
{
// Children before parents. BlogAttachedFile, Comment and
// BlogSpotPublication reference BlogSpot; CircleAuthorization
// ToBlogPost references BlogSpot + Circle; BlogTag references
// BlogSpot + Tag; BlogSpot references ApplicationUser;
// CircleMember references Circle + ApplicationUser.
db.BlogAttachedFiles.RemoveRange(db.BlogAttachedFiles);
db.Comment.RemoveRange(db.Comment);
db.blogSpotPublications.RemoveRange(db.blogSpotPublications);
db.CircleAuthorizationToBlogPost
.RemoveRange(db.CircleAuthorizationToBlogPost);
db.BlogTag.RemoveRange(db.BlogTag);
db.UploadedFiles.RemoveRange(db.UploadedFiles);
db.BlogSpot.RemoveRange(db.BlogSpot);
db.CircleMembers.RemoveRange(db.CircleMembers);
db.Circle.RemoveRange(db.Circle);
db.Users.RemoveRange(db.Users);
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Resolve a fresh service scope and the
/// <see cref="ApplicationDbContext"/> from it, run
/// <paramref name="body"/>, and dispose the scope (and its
/// DbContext). Use this for seeding writes that should not share
/// a DbContext with the service call under test.</summary>
public T WithDb<T>(Func<ApplicationDbContext, T> body)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
return body(db);
}
public void SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{
WithDb(db =>
{
if (db.Users.Any(u => u.Id == userName)) return false;
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return true;
});
}
public long SeedBlogPost(string authorId, string title,
Action<BlogPost>? configure = null)
{
return WithDb(db =>
{
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
configure?.Invoke(post);
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
});
}
/// <summary>Insert a <see cref="BlogSpotPublication"/> row for
/// <paramref name="postId"/> — the existence of that row is what
/// makes a post "public" (readable by anyone) in
/// <see cref="PermissionHandler"/>. This is the same write
/// <c>SetPublishAsync</c> performs in production.</summary>
public void Publish(long postId)
{
WithDb(db =>
{
db.blogSpotPublications.Add(
new BlogSpotPublication { PostId = postId });
db.SaveChanges();
return true;
});
}
/// <summary>Seed a circle owned by <paramref name="ownerId"/> and
/// return its server-assigned id. Sponsorship in
/// <see cref="PermissionHandler"/> is "the viewer is a member of a
/// circle the post's author owns", so a circle + a
/// <see cref="SeedCircleMember"/> entry is what grants a non-owner
/// read access to a draft.</summary>
public long SeedCircle(string ownerId, string name)
{
return WithDb(db =>
{
var circle = new Circle
{
OwnerId = ownerId,
Name = name,
Public = false,
};
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
});
}
public void SeedCircleMember(long circleId, string memberId)
{
WithDb(db =>
{
db.CircleMembers.Add(
new CircleMember { CircleId = circleId, MemberId = memberId });
db.SaveChanges();
return true;
});
}
/// <summary>Attach a <see cref="CircleAuthorizationToBlogPost"/> ACL
/// entry to <paramref name="postId"/> for <paramref name="circleId"/>.
/// The owner always sees the ACL; non-owners have it scrubbed by
/// <see cref="BlogSpotService"/>'s ScrubAclForViewer.</summary>
public void SeedAcl(long postId, long circleId)
{
WithDb(db =>
{
db.CircleAuthorizationToBlogPost.Add(
new CircleAuthorizationToBlogPost
{
BlogPostId = postId,
CircleId = circleId,
});
db.SaveChanges();
return true;
});
}
/// <summary>Seed a comment on <paramref name="postId"/> authored by
/// <paramref name="authorId"/>. <see cref="BlogSpotService.Details"/>
/// loads each comment's <c>Author</c> navigation from
/// <c>_context.Users</c>; seeding the user first keeps the FK
/// valid (SQLite and PostgreSQL both enforce it).</summary>
public void SeedComment(long postId, string authorId, string article)
{
WithDb(db =>
{
db.Comment.Add(new Comment
{
ReceiverId = postId,
AuthorId = authorId,
Article = article,
Visible = true,
UserModified = authorId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
db.SaveChanges();
return true;
});
}
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
/// fixture's DI container, run <paramref name="body"/> against it,
/// and dispose the request scope (and its scoped DbContext). The
/// service is scoped, so it must be resolved from a scope — and the
/// scope is what we dispose to avoid leaking a DbContext against
/// the shared connection across tests.</summary>
public async Task<T> WithServiceAsync<T>(
Func<BlogSpotService, Task<T>> body)
{
using var scope = _services.CreateScope();
var service = scope.ServiceProvider
.GetRequiredService<BlogSpotService>();
return await body(service);
}
private static string EnsureNpgsqlDatabaseCreated()
{
lock (_npgsqlLock)
{
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
return _sharedNpgsqlConnectionString;
var adminConnectionString = BuildAdminConnectionString();
var databaseName = DedicatedNpgsqlDatabaseName;
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{
adminConnection.Open();
using var existsCommand = adminConnection.CreateCommand();
existsCommand.CommandText =
"SELECT 1 FROM pg_database WHERE datname = @databaseName";
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText =
$"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
}
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
{
Database = databaseName,
Pooling = false,
IncludeErrorDetail = true
};
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
return _sharedNpgsqlConnectionString;
}
}
private static string BuildAdminConnectionString()
{
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured))
throw new InvalidOperationException(
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured)
{
Pooling = false,
IncludeErrorDetail = true
};
// The admin connection targets the maintenance database
// (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
builder.Database = "postgres";
return builder.ToString();
}
public void Dispose()
{
_services.Dispose();
_sqliteConnection?.Dispose();
}
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(
System.Security.Claims.ClaimsPrincipal user,
string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath,
FileAccessRight access) { }
}
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Server.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -9,7 +9,7 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -14,7 +14,7 @@
--> -->
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Server.Tests", "src\Yavsc.Server.Tests\Yavsc.Server.Tests.csproj", "{899B7240-729A-48BC-A993-300A6CCFCBEA}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -243,6 +245,18 @@ Global
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -265,5 +279,6 @@ Global
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE} {021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE}
{49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F} {49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F}
{899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal