diff --git a/.vscode/launch.json b/.vscode/launch.json index d5088f01d..f3411003c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,9 +43,15 @@ }, { "name": "Yavsc Org", - "type": "dotnet", + "type": "coreclr", "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", + "program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "cwd": "${workspaceFolder}/src/Yavsc.Org", + "preLaunchTask": "build-web-yavsc-org" + }, { "name": "Yavsc Blogs", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index cbd3a744d..15ee3405b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -89,6 +89,34 @@ }, "isBackground": true }, + { + "label": "build-web-yavsc-org", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Org" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, + { + "label": "build-web-yavsc-blogs", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Blogs" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, { "label": "dotnet: build-postit-desktop", "type": "process", diff --git a/CHANGELOG.md b/CHANGELOG.md index 85139db1b..885b95854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # 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` sérialisé par System.Text.Json d'après le type + déclaré (l'interface), qui omet les propriétés du type concret — + `IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur + `IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet, + y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13). + Corrigé en déclarant `IsPublished` sur `IBlogPost`. +* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de + `CommentsController` (Index/Details/…) atterrissaient sur la même route + `POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une + `SwaggerGeneratorException` (conflit method/path) au chargement de + `/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur + les actions scaffoldées. + ## [1.0.8-rc16] - unstable Flux de signature et de génération de devis : un devis peut désormais être diff --git a/contrib/Makefile b/contrib/Makefile index 4ba6059df..deb55bcc6 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN @cat template.service | APP_NAME="$*" \ DESTDIR="$(DESTDIR)" \ HTTP_HOST="$(HTTP_HOST)" \ - HTTP_PORT="$*_$(HTTP_PORT)" \ + HTTP_PORT="$($*_PORT)" \ APPDIR="$(APPDIR)" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ POSTGRES_HOST="$(POSTGRES_HOST)" \ @@ -45,6 +45,76 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service +# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) --- +# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr) +# +# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx +# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement +# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré). +NGINX_TEMPLATE := template.vhost +NGINX_OUT_DIR := generated/nginx +NGINX_HOSTS := Org Blogs Api +DOMAIN_ROOT ?= pschneider.fr +# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's +# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env). +BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http) +# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs +nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1))) +# domaine complet : . +# prod Org -> yavsc.pschneider.fr +# preprod Org -> preprod-yavsc.pschneider.fr +nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT) + +# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port +# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne +# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est +# vide sur tous les vhosts (sinon nginx refuse la config : duplicate +# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org). +$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env + @cat $(NGINX_TEMPLATE) \ + | DOMAIN="$(call nginx_domain,$*)" \ + BACKEND_SCHEME="$(BACKEND_SCHEME)" \ + HTTP_HOST="$(HTTP_HOST)" \ + BACKEND_PORT="$($*_PORT)" \ + QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \ + envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@ + @echo Created nginx vhost: $@ + +generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS)) + +generate-nginx-prod: + make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= + +$(NGINX_OUT_DIR): + @mkdir -p $@ + +# --- appsettings (générés depuis template.appsettings-*.json) --- +# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json +# +# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les +# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password. +# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un +# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel +# contient un \). jq lit le secret depuis l'environnement (export) -> +# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd. +# Les templates actuels sont issus de la preprod (préfixe preprod-, ports +# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe). +export ASPNETCORE_Smtp__Password +APPSETTINGS_OUT_DIR := generated/appsettings +APPSETTINGS_HOSTS := org api blogs + +$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env + @jq --arg domain "$(DOMAIN_ROOT)" \ + 'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \ + template.appsettings-$*.json > $@ + @echo Created appsettings: $@ + +generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS))) + +$(APPSETTINGS_OUT_DIR): + @mkdir -p $@ + install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service sudo systemctl daemon-reload sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* diff --git a/contrib/README.nginx.md b/contrib/README.nginx.md new file mode 100644 index 000000000..9564d4ffe --- /dev/null +++ b/contrib/README.nginx.md @@ -0,0 +1,134 @@ +# Vhosts nginx — génération par `make generate-nginx` + +Les vhosts nginx sont **générés** à l'image des unités de service systemd +(`template.service` + `envsubst` → `generated/*.service`) : un template +unique `contrib/template.vhost` est substitué par `envsubst` vers +`contrib/generated/nginx/`, qui est **gitignoré**. + +## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`) + +nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec +h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`). +Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les +`template.appsettings-*.json`) est une capacité complémentaire — non +joignable par les clients derrière nginx (nginx OSS = upstream h1.1 +uniquement), mais disponible si Kestrel est un jour exposé en direct. + +Directives générées (par vhost) : +```nginx +listen 443 ssl; listen [::]:443 ssl; http2 on; +listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on; +add_header Alt-Svc 'h3=":443"; ma=86400' always; +ssl_protocols TLSv1.3; +``` +`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle +`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts +confondus). + +## Prérequis côté host + +1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;` + exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`, + `listen ... quic` suffit à activer h3) : + ```bash + nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide + ``` + Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon, + backports ou `nginx-full`. +2. **UDP 443 ouvert** : + ```bash + sudo ufw allow 443/udp + sudo ufw status | grep '443/udp' # → ALLOW + ``` +3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le + host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t` + échoue sur le bind IPv6). +4. Certbot / certs Let's Encrypt déjà en place (chemins + `/etc/letsencrypt/live//{fullchain,privkey}.pem` référencés). +5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` — + sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux). + +## Génération + +```bash +cd contrib + +# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +make generate-nginx + +# Production -> generated/nginx/yavsc{Org,Blogs,Api} +make generate-nginx-prod +# ou manuellement : +make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= +``` + +Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env` +(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend +`https` en prod, `http` en preprod. + +| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod | +|-------|--------------|-------------|-------------------------|------------------------| +| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` | +| Api | api | non | `https://localhost:87` | `http://localhost:93` | +| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` | + +Domaines : `.pschneider.fr` +(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`). + +## Déploiement sur le host + +```bash +sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg +sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg +# (idem yavscApi, yavscBlogs) +sudo nginx -t && sudo systemctl reload nginx +``` + +## Vérification (sur le host) + +```bash +# 1. Module h3 présent +nginx -V 2>&1 | grep -o 'http_v3_module' + +# 2. UDP 443 ouvert +sudo ufw status | grep '443/udp' + +# 3. En-tête Alt-Svc annoncé +curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443" + +# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3) +curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3' + +# 5. Fallback h2 et h1 toujours fonctionnels +curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +``` +Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et +les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools → +Network → colonne « Protocole » → `h3` après mise à niveau. + +## Comment ça marche (template + envsubst) + +`contrib/template.vhost` contient les placeholders `$DOMAIN`, +`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle +`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue +via `envsubst` avec une **allowlist** : + +```makefile +envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS' +``` + +Cette allowlist limite la substitution aux 5 variables citées, de sorte +que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`, +`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans +allowlist, `envsubst` détruirait ces variables nginx. + +## Hors périmètre + +- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal + systemd + nginx). +- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que + HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`. +- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2 + côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP, + relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3). \ No newline at end of file diff --git a/contrib/template.appsettings-api.json b/contrib/template.appsettings-api.json new file mode 100644 index 000000000..b364eeef1 --- /dev/null +++ b/contrib/template.appsettings-api.json @@ -0,0 +1,33 @@ +{ + "Site": { + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["api"], + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN" + ], + "Bills": "bills", + "TempDir": "temp" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:92" + }, + "Https": { + "Url": "https://localhost:93", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.appsettings-blogs.json b/contrib/template.appsettings-blogs.json new file mode 100644 index 000000000..f522feaca --- /dev/null +++ b/contrib/template.appsettings-blogs.json @@ -0,0 +1,40 @@ +{ + "Site": { + "Title": "Yavsc Blogs", + "Description": "A collection of blogs about software development, technology, and programming.", + "Keywords": "software development, technology, programming, coding, blogs", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["blogs"], + "CorsAllowedOrigins": [ + "https://preprod-api.$DOMAIN" + ], + "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" + } + } + } +} diff --git a/contrib/template.appsettings-org.json b/contrib/template.appsettings-org.json new file mode 100644 index 000000000..76203d9e9 --- /dev/null +++ b/contrib/template.appsettings-org.json @@ -0,0 +1,74 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "AllowedHosts": "*", + "Site": { + "Title": "yavsc", + "Slogan": "Yavsc!", + "StyleSheet": "/css/default.css", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": [ + "blogs", + "api" + ], + "ExternalUrl": "https://preprod-yavsc.$DOMAIN", + "ApiUrl": "https://preprod-api.$DOMAIN", + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN", + "https://preprod-api.$DOMAIN" + ], + "Banner": "/images/banner-dewang.jpg", + "Owner": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Admin": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Quota": 200000000, + "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" + } + } + } +} diff --git a/contrib/template.vhost b/contrib/template.vhost new file mode 100644 index 000000000..ea2c2cb6d --- /dev/null +++ b/contrib/template.vhost @@ -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; +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 46702b3a8..14516758d 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,7 +11,7 @@ false 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index bf967849b..f2b2a2bbf 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,7 +6,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 12e9a8143..46df5025f 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,7 +7,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index d4d16c29e..c00ea086f 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,7 +8,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 98dcbe480..9cdf71299 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -5,7 +5,7 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs index 38a50b78f..c5a098e07 100644 --- a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs @@ -15,5 +15,23 @@ namespace Yavsc.Blogspot // BlogPost entity implements this getter by mapping its // ApplicationUser navigation to a BlogPostAuthorDto. BlogPostAuthorDto? Author { get; } + + /// + /// Whether this post is published. The server derives this + /// from the existence of a row in BlogSpotPublication + /// and serves it on the wire so clients (PostIt) can render + /// the current publication state without a follow-up request. + /// + /// This MUST be declared on the interface — not just on + /// the concrete BlogPost entity / BlogPostDto — + /// because the blog API's list endpoint returns + /// IEnumerable<IBlogPost>, and the blog API host + /// serialises with System.Text.Json, which emits only the + /// members of the declared (interface) type. When + /// IsPublished lived only on the concrete classes, STJ + /// dropped it from the list payload and PostIt read + /// false for every post, including published ones. + /// + bool IsPublished { get; set; } } } diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj index 0a9e481bf..3de58d2db 100644 --- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj +++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj @@ -11,7 +11,7 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 \ No newline at end of file diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj index c53f9e888..80cdf0ae5 100644 --- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -17,7 +17,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs index 51e939ceb..5d040fd27 100644 --- a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs +++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs @@ -24,7 +24,6 @@ public sealed class ApiWebServerFixture : WebHostFixture private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; 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; @@ -168,25 +167,32 @@ public sealed class ApiWebServerFixture : WebHostFixture 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 source = string.IsNullOrWhiteSpace(configured) - ? DefaultDevelopmentConnectionString - : configured; + 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(source) + var builder = new NpgsqlConnectionStringBuilder(configured) { Pooling = false, 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"; - } - else if (string.IsNullOrWhiteSpace(builder.Database)) - { - builder.Database = "postgres"; - } return builder.ToString(); } diff --git a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj index 21cbdc32c..18cf404f5 100644 --- a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj +++ b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj @@ -8,7 +8,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj index 72231b0af..be545e5b0 100644 --- a/src/Yavsc.Api/Yavsc.Api.csproj +++ b/src/Yavsc.Api/Yavsc.Api.csproj @@ -7,7 +7,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs b/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs new file mode 100644 index 000000000..bb38725cd --- /dev/null +++ b/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Yavsc.Blogspot; +using Yavsc.Models.Blog; + +namespace Yavsc.Blogs.Tests; + +/// +/// Wire-shape tests pinning that +/// is carried on the JSON the blog API list endpoint emits. +/// +/// The blog API host (Yavsc.Blogs/Program.cs) serialises +/// with System.Text.Json (the .AddControllers() default — no +/// AddNewtonsoftJson), and BlogApiController.GetBlogspot +/// returns IEnumerable<IBlogPost>. System.Text.Json emits +/// only the members of the declared type, so a property that +/// exists solely on the concrete BlogPost entity is dropped from +/// the payload. IsPublished used to be one such property: it was +/// on BlogPost and BlogPostDto but not on +/// , so the list response omitted it and PostIt +/// read IsPublished == false for every post — including +/// published ones. +/// +/// These tests reproduce that serialisation path directly +/// (declared type IEnumerable<IBlogPost> through +/// ) and assert +/// isPublished survives the round trip. They live in +/// Yavsc.Blogs.Tests — 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 false). +/// +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. + IEnumerable list = new List { 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), + 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>(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + var post = Assert.Single(posts); + Assert.True(post.IsPublished); + } +} \ No newline at end of file diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index 4b3abb75c..4f046af88 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -9,7 +9,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 204ae6c73..38941be79 100644 --- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj +++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj @@ -8,7 +8,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index 51d3660a9..8c69c08ce 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -11,7 +11,7 @@ $(MSBuildProjectDirectory)\test.runsettings 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Org/Views/Shared/_Nav.cshtml b/src/Yavsc.Org/Views/Shared/_Nav.cshtml index 385884fc0..4af820fc8 100644 --- a/src/Yavsc.Org/Views/Shared/_Nav.cshtml +++ b/src/Yavsc.Org/Views/Shared/_Nav.cshtml @@ -2,7 +2,7 @@ style="background-image: url('@SiteSettings.Value.Banner'); background-position: 0%; background-attachment: fixed;"> - + diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index 99e0cf29c..01c38378c 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -9,7 +9,7 @@ https://github.com/pazof/yavsc 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Org/appsettings-org-template.json b/src/Yavsc.Org/appsettings-org-template.json deleted file mode 100644 index 85593635f..000000000 --- a/src/Yavsc.Org/appsettings-org-template.json +++ /dev/null @@ -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" - } - } - } - } -} diff --git a/src/Yavsc.Org/wwwroot/lib/bootstrap.quartz.css b/src/Yavsc.Org/wwwroot/lib/bootstrap.quartz.css index 41ed0f2ed..466c6e28b 100644 --- a/src/Yavsc.Org/wwwroot/lib/bootstrap.quartz.css +++ b/src/Yavsc.Org/wwwroot/lib/bootstrap.quartz.css @@ -5033,7 +5033,8 @@ fieldset:disabled .btn { background-color: var(--bs-card-bg); background-clip: border-box; 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 { diff --git a/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs b/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs new file mode 100644 index 000000000..713957804 --- /dev/null +++ b/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs @@ -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; + +/// +/// Service-level tests for . +/// +/// loads a +/// (with Author, Tags, Comments, ACL and +/// Publication navigations), runs a resource-based +/// ReadPermission authorization against it, scrubs the ACL for +/// non-owners, derives from the +/// loaded Publication navigation, and eagerly loads each +/// comment's Author. These tests pin each of those behaviours +/// at the service layer against the real +/// (the public/owner/ +/// sponsor/Administrator disjunction) and a real SQLite store — no +/// mocking. +/// +/// Seeding and the service call resolve through separate DI +/// scopes so the DbContext that writes the seed rows is disposed +/// before the one reads through, +/// mirroring the per-request DbContext lifetime in production. +/// +[Collection("Yavsc Server")] +public sealed class BlogSpotServiceDetailsTests : IClassFixture +{ + private readonly ServerServicesFixture _fixture; + + public BlogSpotServiceDetailsTests(ServerServicesFixture fixture) + { + _fixture = fixture; + } + + /// An authenticated principal whose sub claim is + /// . + /// reads sub first, which is what the JwtBearer middleware + /// in production preserves (MapInboundClaims = false), and what + /// compares against + /// . + private static ClaimsPrincipal Authenticated(string userId, + params Claim[] extra) + { + var claims = new List { new("sub", userId) }; + claims.AddRange(extra); + return new ClaimsPrincipal( + new ClaimsIdentity(claims, authenticationType: "Bearer")); + } + + /// An unauthenticated principal: a + /// with no authentication type has + /// == false. The + /// service still runs authorization — a published post's + /// ReadPermission succeeds purely on IsPublic, with no + /// identity required. + private static ClaimsPrincipal Anonymous() + => new(new ClaimsIdentity()); + + /// The Microsoft-format role claim + /// + /// reads when checking the "Administrator" bypass in + /// . + 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(() => + _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(() => + _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("alice"), 424242))); + } +} \ No newline at end of file diff --git a/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs b/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs new file mode 100644 index 000000000..f57f65839 --- /dev/null +++ b/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs @@ -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; + +/// +/// Service-level test host for and the +/// other Yavsc.Server services that take an +/// + . +/// +/// Unlike BlogsWebServerFixture (in Yavsc.Blogs.Tests), +/// this fixture does not spin up Kestrel, JWT bearer, or MVC +/// controllers. takes the +/// 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. +/// +/// Database provider. Mirrors +/// ApiWebServerFixture: SQLite :memory: by default, or +/// real PostgreSQL when YAVSC_SERVER_TEST_DB_PROVIDER=npgsql is +/// set. Production runs on Npgsql, so the Npgsql path — which applies +/// the real Yavsc.Org 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. +/// +/// +/// SQLite: a named :memory: database +/// (Cache=Shared) backed by a single connection held open for the +/// fixture's lifetime, schema via EnsureCreated. Reset is +/// EnsureDeleted + EnsureCreated. +/// Npgsql: a dedicated database +/// yavscServerTestDb (created from the admin connection if it +/// does not exist), schema via Migrate using the +/// Yavsc.Org 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). +/// The real and the +/// real : Details calls +/// IAuthorizationService.AuthorizeAsync(user, blog, new +/// ReadPermission()), and answers +/// that requirement with the public/owner/sponsor/Administrator +/// disjunction that the tests below pin. No mocking. +/// A trivial +/// stub: Details never touches the file system, but the DI +/// container needs an instance to construct the service. +/// +/// +/// Marked so a single +/// fixture instance (one store, one DI container) is shared across +/// every [Collection("Yavsc Server")] test class. Each test +/// resets the store via . +/// +[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(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(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(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( + new NoopFileSystemAuthManager()); + + // The real BlogSpotService — same shape the production host + // builds (ApplicationDbContext, IAuthorizationService, + // IFileSystemAuthManager, IOptions, + // ILoggerFactory). + services.AddScoped(); + + // The real PermissionHandler: Details authorizes with + // ReadPermission, and PermissionHandler resolves it via the + // IsPublic / IsOwner / IsSponsor / Administrator disjunction. + services.AddScoped(); + + // 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(); + if (UseNpgsqlProvider()) + db.Database.Migrate(); + else + db.Database.EnsureCreated(); + } + } + + /// True when YAVSC_SERVER_TEST_DB_PROVIDER=npgsql + /// is set — the fixture then runs against a real PostgreSQL server + /// instead of the SQLite in-memory default. + public static bool UseNpgsqlProvider() + => string.Equals( + Environment.GetEnvironmentVariable(DbProviderEnvVar), + "npgsql", + StringComparison.OrdinalIgnoreCase); + + /// Reset the store to a known empty state for the next + /// test. SQLite: EnsureDeleted + EnsureCreated (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. + public void ResetDatabase() + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider + .GetRequiredService(); + + 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(); + } + + /// Resolve a fresh service scope and the + /// from it, run + /// , and dispose the scope (and its + /// DbContext). Use this for seeding writes that should not share + /// a DbContext with the service call under test. + public T WithDb(Func body) + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider + .GetRequiredService(); + return body(db); + } + + public void SeedUser(string userName, + Action? 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? 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; + }); + } + + /// Insert a row for + /// — the existence of that row is what + /// makes a post "public" (readable by anyone) in + /// . This is the same write + /// SetPublishAsync performs in production. + public void Publish(long postId) + { + WithDb(db => + { + db.blogSpotPublications.Add( + new BlogSpotPublication { PostId = postId }); + db.SaveChanges(); + return true; + }); + } + + /// Seed a circle owned by and + /// return its server-assigned id. Sponsorship in + /// is "the viewer is a member of a + /// circle the post's author owns", so a circle + a + /// entry is what grants a non-owner + /// read access to a draft. + 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; + }); + } + + /// Attach a ACL + /// entry to for . + /// The owner always sees the ACL; non-owners have it scrubbed by + /// 's ScrubAclForViewer. + public void SeedAcl(long postId, long circleId) + { + WithDb(db => + { + db.CircleAuthorizationToBlogPost.Add( + new CircleAuthorizationToBlogPost + { + BlogPostId = postId, + CircleId = circleId, + }); + db.SaveChanges(); + return true; + }); + } + + /// Seed a comment on authored by + /// . + /// loads each comment's Author navigation from + /// _context.Users; seeding the user first keeps the FK + /// valid (SQLite and PostgreSQL both enforce it). + 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; + }); + } + + /// Resolve the real from the + /// fixture's DI container, run 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. + public async Task WithServiceAsync( + Func> body) + { + using var scope = _services.CreateScope(); + var service = scope.ServiceProvider + .GetRequiredService(); + 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) { } + } +} \ No newline at end of file diff --git a/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj b/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj new file mode 100644 index 000000000..61264cfc5 --- /dev/null +++ b/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + false + Yavsc.Server.Tests + true + 1.1.0.0 + 1.1.0.0 + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index b291847c0..61c426ec0 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -9,7 +9,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index 830377ca2..dda2c21c8 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -14,7 +14,7 @@ --> 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 39ef7660f..7b95a7e68 100644 --- a/src/cli/cli.csproj +++ b/src/cli/cli.csproj @@ -7,7 +7,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/yavsc.sln b/yavsc.sln index 6085a85fd..d7cffd18f 100644 --- a/yavsc.sln +++ b/yavsc.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}" 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 GlobalSection(SolutionConfigurationPlatforms) = preSolution 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|x86.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -265,5 +279,6 @@ Global {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE} {49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F} + {899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} EndGlobalSection EndGlobal