diff --git a/.vscode/launch.json b/.vscode/launch.json index f3411003c..d5088f01d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,15 +43,9 @@ }, { "name": "Yavsc Org", - "type": "coreclr", + "type": "dotnet", "request": "launch", - "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" - + "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", }, { "name": "Yavsc Blogs", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 15ee3405b..cbd3a744d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -89,34 +89,6 @@ }, "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 885b95854..85139db1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,68 +1,5 @@ # 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 deb55bcc6..4ba6059df 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="$($*_PORT)" \ + HTTP_PORT="$*_$(HTTP_PORT)" \ APPDIR="$(APPDIR)" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ POSTGRES_HOST="$(POSTGRES_HOST)" \ @@ -45,76 +45,6 @@ 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 deleted file mode 100644 index 9564d4ffe..000000000 --- a/contrib/README.nginx.md +++ /dev/null @@ -1,134 +0,0 @@ -# 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 deleted file mode 100644 index b364eeef1..000000000 --- a/contrib/template.appsettings-api.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index f522feaca..000000000 --- a/contrib/template.appsettings-blogs.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "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 deleted file mode 100644 index 76203d9e9..000000000 --- a/contrib/template.appsettings-org.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "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 deleted file mode 100644 index ea2c2cb6d..000000000 --- a/contrib/template.vhost +++ /dev/null @@ -1,61 +0,0 @@ -# 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 14516758d..46702b3a8 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index f2b2a2bbf..bf967849b 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 46df5025f..12e9a8143 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index c00ea086f..d4d16c29e 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 9cdf71299..98dcbe480 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs index c5a098e07..38a50b78f 100644 --- a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs @@ -15,23 +15,5 @@ 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 3de58d2db..0a9e481bf 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 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 80cdf0ae5..c53f9e888 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs index 5d040fd27..51e939ceb 100644 --- a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs +++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs @@ -24,6 +24,7 @@ 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; @@ -167,32 +168,25 @@ 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); - 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 source = string.IsNullOrWhiteSpace(configured) + ? DefaultDevelopmentConnectionString + : configured; - var builder = new NpgsqlConnectionStringBuilder(configured) + var builder = new NpgsqlConnectionStringBuilder(source) { 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. - if (string.IsNullOrWhiteSpace(builder.Database)) + if (string.IsNullOrWhiteSpace(configured)) + { 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 18cf404f5..21cbdc32c 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj index be545e5b0..72231b0af 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs b/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs deleted file mode 100644 index bb38725cd..000000000 --- a/src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -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 4f046af88..4b3abb75c 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 38941be79..204ae6c73 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 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 8c69c08ce..51d3660a9 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/Yavsc.Org/Views/Shared/_Nav.cshtml b/src/Yavsc.Org/Views/Shared/_Nav.cshtml index 4af820fc8..385884fc0 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;"> - Yavsc @SiteSettings.Value.Title diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index 01c38378c..99e0cf29c 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/Yavsc.Org/appsettings-org-template.json b/src/Yavsc.Org/appsettings-org-template.json new file mode 100644 index 000000000..85593635f --- /dev/null +++ b/src/Yavsc.Org/appsettings-org-template.json @@ -0,0 +1,104 @@ +{ + "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 466c6e28b..41ed0f2ed 100644 --- a/src/Yavsc.Org/wwwroot/lib/bootstrap.quartz.css +++ b/src/Yavsc.Org/wwwroot/lib/bootstrap.quartz.css @@ -5033,8 +5033,7 @@ 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); - padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x); + border-radius: var(--bs-card-border-radius) } .card>hr { diff --git a/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs b/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs deleted file mode 100644 index 713957804..000000000 --- a/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index f57f65839..000000000 --- a/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs +++ /dev/null @@ -1,452 +0,0 @@ -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 deleted file mode 100644 index 61264cfc5..000000000 --- a/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - 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 61c426ec0..b291847c0 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 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 dda2c21c8..830377ca2 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 7b95a7e68..39ef7660f 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/yavsc.sln b/yavsc.sln index d7cffd18f..6085a85fd 100644 --- a/yavsc.sln +++ b/yavsc.sln @@ -41,8 +41,6 @@ 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 @@ -245,18 +243,6 @@ 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 @@ -279,6 +265,5 @@ 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