Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
94d8eb538c |
|||
|
daf5949392 |
|||
|
2c8be78175 |
|||
|
e1ce67a76e |
|||
|
384f6bc300 |
|||
|
a17a9cef9a |
|||
|
ed3dc733d3 |
|||
|
5553f2f290 |
|||
|
3fe7b8b46c |
35 changed files with 1376 additions and 138 deletions
10
.vscode/launch.json
vendored
10
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
28
.vscode/tasks.json
vendored
28
.vscode/tasks.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
63
CHANGELOG.md
63
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<IBlogPost>` sérialisé par System.Text.Json d'après le type
|
||||
déclaré (l'interface), qui omet les propriétés du type concret —
|
||||
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
|
||||
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
|
||||
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
|
||||
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
|
||||
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
|
||||
`CommentsController` (Index/Details/…) atterrissaient sur la même route
|
||||
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
|
||||
`SwaggerGeneratorException` (conflit method/path) au chargement de
|
||||
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
|
||||
les actions scaffoldées.
|
||||
|
||||
## [1.0.8-rc16] - unstable
|
||||
|
||||
Flux de signature et de génération de devis : un devis peut désormais être
|
||||
|
|
|
|||
|
|
@ -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 : <prefix><subdomain>.<DOMAIN_ROOT>
|
||||
# prod Org -> yavsc.pschneider.fr
|
||||
# preprod Org -> preprod-yavsc.pschneider.fr
|
||||
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
|
||||
|
||||
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
|
||||
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
|
||||
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
|
||||
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
|
||||
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
|
||||
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
|
||||
@cat $(NGINX_TEMPLATE) \
|
||||
| DOMAIN="$(call nginx_domain,$*)" \
|
||||
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
|
||||
HTTP_HOST="$(HTTP_HOST)" \
|
||||
BACKEND_PORT="$($*_PORT)" \
|
||||
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
|
||||
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
|
||||
@echo Created nginx vhost: $@
|
||||
|
||||
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
|
||||
|
||||
generate-nginx-prod:
|
||||
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
|
||||
|
||||
$(NGINX_OUT_DIR):
|
||||
@mkdir -p $@
|
||||
|
||||
# --- appsettings (générés depuis template.appsettings-*.json) ---
|
||||
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
|
||||
#
|
||||
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
|
||||
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
|
||||
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
|
||||
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
|
||||
# contient un \). jq lit le secret depuis l'environnement (export) ->
|
||||
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
|
||||
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
|
||||
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
|
||||
export ASPNETCORE_Smtp__Password
|
||||
APPSETTINGS_OUT_DIR := generated/appsettings
|
||||
APPSETTINGS_HOSTS := org api blogs
|
||||
|
||||
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
|
||||
@jq --arg domain "$(DOMAIN_ROOT)" \
|
||||
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
|
||||
template.appsettings-$*.json > $@
|
||||
@echo Created appsettings: $@
|
||||
|
||||
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
|
||||
|
||||
$(APPSETTINGS_OUT_DIR):
|
||||
@mkdir -p $@
|
||||
|
||||
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
|
||||
|
|
|
|||
134
contrib/README.nginx.md
Normal file
134
contrib/README.nginx.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Vhosts nginx — génération par `make generate-nginx`
|
||||
|
||||
Les vhosts nginx sont **générés** à l'image des unités de service systemd
|
||||
(`template.service` + `envsubst` → `generated/*.service`) : un template
|
||||
unique `contrib/template.vhost` est substitué par `envsubst` vers
|
||||
`contrib/generated/nginx/`, qui est **gitignoré**.
|
||||
|
||||
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
|
||||
|
||||
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
|
||||
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
|
||||
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
|
||||
`template.appsettings-*.json`) est une capacité complémentaire — non
|
||||
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
|
||||
uniquement), mais disponible si Kestrel est un jour exposé en direct.
|
||||
|
||||
Directives générées (par vhost) :
|
||||
```nginx
|
||||
listen 443 ssl; listen [::]:443 ssl; http2 on;
|
||||
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
|
||||
add_header Alt-Svc 'h3=":443"; ma=86400' always;
|
||||
ssl_protocols TLSv1.3;
|
||||
```
|
||||
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
|
||||
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
|
||||
confondus).
|
||||
|
||||
## Prérequis côté host
|
||||
|
||||
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
|
||||
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
|
||||
`listen ... quic` suffit à activer h3) :
|
||||
```bash
|
||||
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
|
||||
```
|
||||
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
|
||||
backports ou `nginx-full`.
|
||||
2. **UDP 443 ouvert** :
|
||||
```bash
|
||||
sudo ufw allow 443/udp
|
||||
sudo ufw status | grep '443/udp' # → ALLOW
|
||||
```
|
||||
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
|
||||
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
|
||||
échoue sur le bind IPv6).
|
||||
4. Certbot / certs Let's Encrypt déjà en place (chemins
|
||||
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
|
||||
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
|
||||
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
|
||||
|
||||
## Génération
|
||||
|
||||
```bash
|
||||
cd contrib
|
||||
|
||||
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
|
||||
make generate-nginx
|
||||
|
||||
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
|
||||
make generate-nginx-prod
|
||||
# ou manuellement :
|
||||
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
|
||||
```
|
||||
|
||||
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
|
||||
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
|
||||
`https` en prod, `http` en preprod.
|
||||
|
||||
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|
||||
|-------|--------------|-------------|-------------------------|------------------------|
|
||||
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
|
||||
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
|
||||
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
|
||||
|
||||
Domaines : `<prefix><subdomain>.pschneider.fr`
|
||||
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
|
||||
|
||||
## Déploiement sur le host
|
||||
|
||||
```bash
|
||||
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
|
||||
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
|
||||
# (idem yavscApi, yavscBlogs)
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## Vérification (sur le host)
|
||||
|
||||
```bash
|
||||
# 1. Module h3 présent
|
||||
nginx -V 2>&1 | grep -o 'http_v3_module'
|
||||
|
||||
# 2. UDP 443 ouvert
|
||||
sudo ufw status | grep '443/udp'
|
||||
|
||||
# 3. En-tête Alt-Svc annoncé
|
||||
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
|
||||
|
||||
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
|
||||
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
|
||||
|
||||
# 5. Fallback h2 et h1 toujours fonctionnels
|
||||
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
|
||||
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
|
||||
```
|
||||
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
|
||||
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
|
||||
Network → colonne « Protocole » → `h3` après mise à niveau.
|
||||
|
||||
## Comment ça marche (template + envsubst)
|
||||
|
||||
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
|
||||
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
|
||||
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
|
||||
via `envsubst` avec une **allowlist** :
|
||||
|
||||
```makefile
|
||||
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
|
||||
```
|
||||
|
||||
Cette allowlist limite la substitution aux 5 variables citées, de sorte
|
||||
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
|
||||
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
|
||||
allowlist, `envsubst` détruirait ces variables nginx.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
|
||||
systemd + nginx).
|
||||
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
|
||||
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
|
||||
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
|
||||
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
|
||||
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).
|
||||
33
contrib/template.appsettings-api.json
Normal file
33
contrib/template.appsettings-api.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"Site": {
|
||||
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||
"Audience": ["api"],
|
||||
"CorsAllowedOrigins": [
|
||||
"https://preprod-blogs.$DOMAIN"
|
||||
],
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:92"
|
||||
},
|
||||
"Https": {
|
||||
"Url": "https://localhost:93",
|
||||
"Certificate": {
|
||||
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
|
||||
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
|
||||
},
|
||||
"Protocols": "Http1AndHttp2AndHttp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
contrib/template.appsettings-blogs.json
Normal file
40
contrib/template.appsettings-blogs.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"Site": {
|
||||
"Title": "Yavsc Blogs",
|
||||
"Description": "A collection of blogs about software development, technology, and programming.",
|
||||
"Keywords": "software development, technology, programming, coding, blogs",
|
||||
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||
"Audience": ["blogs"],
|
||||
"CorsAllowedOrigins": [
|
||||
"https://preprod-api.$DOMAIN"
|
||||
],
|
||||
"Blog": "blog"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Microsoft.AspNetCore.Authentication": "Debug"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:90"
|
||||
},
|
||||
"Https": {
|
||||
"Url": "https://localhost:91",
|
||||
"Certificate": {
|
||||
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
|
||||
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
|
||||
},
|
||||
"Protocols": "Http1AndHttp2AndHttp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
contrib/template.appsettings-org.json
Normal file
74
contrib/template.appsettings-org.json
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Site": {
|
||||
"Title": "yavsc",
|
||||
"Slogan": "Yavsc!",
|
||||
"StyleSheet": "/css/default.css",
|
||||
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||
"Audience": [
|
||||
"blogs",
|
||||
"api"
|
||||
],
|
||||
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
|
||||
"ApiUrl": "https://preprod-api.$DOMAIN",
|
||||
"CorsAllowedOrigins": [
|
||||
"https://preprod-blogs.$DOMAIN",
|
||||
"https://preprod-api.$DOMAIN"
|
||||
],
|
||||
"Banner": "/images/banner-dewang.jpg",
|
||||
"Owner": {
|
||||
"Name": "Paul Schneider",
|
||||
"EMail": "paul@pschneider.fr"
|
||||
},
|
||||
"Admin": {
|
||||
"Name": "Paul Schneider",
|
||||
"EMail": "paul@pschneider.fr"
|
||||
},
|
||||
"Quota": 200000000,
|
||||
"Blog": "blog",
|
||||
"Avatars": "avatars",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
"DataDir": "data",
|
||||
"GitRepository": "git"
|
||||
},
|
||||
"Smtp": {
|
||||
"Host": "mercure.pschneider.fr",
|
||||
"Port": 465,
|
||||
"SenderName": "Paul Schneider",
|
||||
"SenderEmail": "paul@pschneider.fr",
|
||||
"UserName": "paul",
|
||||
"Password": "$SMTP_PASSWORD"
|
||||
},
|
||||
"DataProtection": {
|
||||
"Keys": {
|
||||
"Dir": "DataProtection"
|
||||
},
|
||||
"RSAParamFile": "RSA-Params.json",
|
||||
"ExpiresInHours": 168
|
||||
},
|
||||
"Moderation": {
|
||||
"AutoApproveThreshold": 0.7,
|
||||
"AutoRejectThreshold": 0.9
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:88"
|
||||
},
|
||||
"Https": {
|
||||
"Url": "https://localhost:89",
|
||||
"Certificate": {
|
||||
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
|
||||
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
|
||||
},
|
||||
"Protocols": "Http1AndHttp2AndHttp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
contrib/template.vhost
Normal file
61
contrib/template.vhost
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
|
||||
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
|
||||
#
|
||||
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
|
||||
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
|
||||
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
|
||||
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
|
||||
#
|
||||
# Variables substituées par envsubst (allowlist) :
|
||||
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
|
||||
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
|
||||
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
|
||||
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
|
||||
# même IP, donc seule la prod (Org) l'active.
|
||||
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
|
||||
# préservées (hors allowlist).
|
||||
|
||||
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
|
||||
# ACME ; ne pas servir l'app en HTTP clair.)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name $DOMAIN;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
# 1. TCP : HTTPS classique + HTTP/2
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
|
||||
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
|
||||
listen 443 quic$QUIC_OPTS;
|
||||
listen [::]:443 quic$QUIC_OPTS;
|
||||
http3 on;
|
||||
|
||||
server_name $DOMAIN;
|
||||
|
||||
# 3. Certificats Let's Encrypt (Certbot).
|
||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
|
||||
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
|
||||
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
|
||||
ssl_protocols TLSv1.3;
|
||||
|
||||
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
|
||||
add_header Alt-Svc 'h3=":443"; ma=86400' always;
|
||||
|
||||
location / {
|
||||
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 100s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
include server-status.conf;
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -15,5 +15,23 @@ namespace Yavsc.Blogspot
|
|||
// BlogPost entity implements this getter by mapping its
|
||||
// ApplicationUser navigation to a BlogPostAuthorDto.
|
||||
BlogPostAuthorDto? Author { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this post is published. The server derives this
|
||||
/// from the existence of a row in <c>BlogSpotPublication</c>
|
||||
/// and serves it on the wire so clients (PostIt) can render
|
||||
/// the current publication state without a follow-up request.
|
||||
///
|
||||
/// <para>This MUST be declared on the interface — not just on
|
||||
/// the concrete <c>BlogPost</c> entity / <c>BlogPostDto</c> —
|
||||
/// because the blog API's list endpoint returns
|
||||
/// <c>IEnumerable<IBlogPost></c>, and the blog API host
|
||||
/// serialises with System.Text.Json, which emits only the
|
||||
/// members of the declared (interface) type. When
|
||||
/// <c>IsPublished</c> lived only on the concrete classes, STJ
|
||||
/// dropped it from the list payload and PostIt read
|
||||
/// <c>false</c> for every post, including published ones.</para>
|
||||
/// </summary>
|
||||
bool IsPublished { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
<Library>true</Library>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
87
src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs
Normal file
87
src/Yavsc.Blogs.Tests/IBlogPostWireShapeTests.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Text.Json;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models.Blog;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-shape tests pinning that <see cref="IBlogPost.IsPublished"/>
|
||||
/// is carried on the JSON the blog API list endpoint emits.
|
||||
///
|
||||
/// <para>The blog API host (<c>Yavsc.Blogs/Program.cs</c>) serialises
|
||||
/// with System.Text.Json (the <c>.AddControllers()</c> default — no
|
||||
/// <c>AddNewtonsoftJson</c>), and <c>BlogApiController.GetBlogspot</c>
|
||||
/// returns <c>IEnumerable<IBlogPost></c>. System.Text.Json emits
|
||||
/// only the members of the <b>declared</b> type, so a property that
|
||||
/// exists solely on the concrete <c>BlogPost</c> entity is dropped from
|
||||
/// the payload. <c>IsPublished</c> used to be one such property: it was
|
||||
/// on <c>BlogPost</c> and <c>BlogPostDto</c> but not on
|
||||
/// <see cref="IBlogPost"/>, so the list response omitted it and PostIt
|
||||
/// read <c>IsPublished == false</c> for every post — including
|
||||
/// published ones.</para>
|
||||
///
|
||||
/// <para>These tests reproduce that serialisation path directly
|
||||
/// (declared type <c>IEnumerable<IBlogPost></c> through
|
||||
/// <see cref="JsonSerializer.Serialize(object, Type)"/>) and assert
|
||||
/// <c>isPublished</c> survives the round trip. They live in
|
||||
/// <c>Yavsc.Blogs.Tests</c> — the assembly that owns the blog API
|
||||
/// contract — so a regression at the wire boundary is caught here,
|
||||
/// where it manifested in production (PostIt showing a published
|
||||
/// post's status as <c>false</c>).</para>
|
||||
/// </summary>
|
||||
public sealed class IBlogPostWireShapeTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions WebDefaults
|
||||
= new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void IsPublished_is_emitted_when_list_is_serialised_as_IEnumerable_IBlogPost()
|
||||
{
|
||||
var post = new BlogPost
|
||||
{
|
||||
Id = 13,
|
||||
Title = "Published",
|
||||
AuthorId = "alice",
|
||||
Article = "x",
|
||||
};
|
||||
post.IsPublished = true;
|
||||
|
||||
// Declared type mirrors GetBlogspot's return: IEnumerable<IBlogPost>.
|
||||
IEnumerable<IBlogPost> list = new List<BlogPost> { post };
|
||||
|
||||
// Serialize with the DECLARED type, the way ASP.NET Core's
|
||||
// SystemTextJsonOutputFormatter hands context.ObjectType (the
|
||||
// action return type) to JsonSerializer.Serialize.
|
||||
var json = JsonSerializer.Serialize(list, typeof(IEnumerable<IBlogPost>),
|
||||
WebDefaults);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var element = doc.RootElement[0];
|
||||
// Web defaults use camelCase, so the property is "isPublished".
|
||||
Assert.True(element.TryGetProperty("isPublished", out var isPublished),
|
||||
$"isPublished was dropped from the list payload. JSON: {json}");
|
||||
Assert.True(isPublished.GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPublished_round_trips_through_BlogPostDto_on_the_client()
|
||||
{
|
||||
// The shape the client (PostIt) receives and deserialises with
|
||||
// PropertyNameCaseInsensitive = true (cf. YavscApiClient).
|
||||
var json = """
|
||||
[{
|
||||
"id": 13,
|
||||
"title": "Published",
|
||||
"article": "x",
|
||||
"authorId": "alice",
|
||||
"isPublished": true
|
||||
}]
|
||||
""";
|
||||
|
||||
var posts = JsonSerializer.Deserialize<List<BlogPostDto>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var post = Assert.Single(posts);
|
||||
Assert.True(post.IsPublished);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
style="background-image: url('@SiteSettings.Value.Banner');
|
||||
background-position: 0%;
|
||||
background-attachment: fixed;">
|
||||
<a class="navbar-brand" href="/">
|
||||
<a class="navbar-brand logo" href="/">
|
||||
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
|
||||
@SiteSettings.Value.Title
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
222
src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
Normal file
222
src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Exceptions;
|
||||
using Yavsc.Server.Tests.Fixtures;
|
||||
|
||||
namespace Yavsc.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Service-level tests for <see cref="BlogSpotService.Details"/>.
|
||||
///
|
||||
/// <para><see cref="BlogSpotService.Details"/> loads a
|
||||
/// <see cref="BlogPost"/> (with Author, Tags, Comments, ACL and
|
||||
/// Publication navigations), runs a resource-based
|
||||
/// <c>ReadPermission</c> authorization against it, scrubs the ACL for
|
||||
/// non-owners, derives <see cref="BlogPost.IsPublished"/> from the
|
||||
/// loaded <c>Publication</c> navigation, and eagerly loads each
|
||||
/// comment's <c>Author</c>. These tests pin each of those behaviours
|
||||
/// at the service layer against the real
|
||||
/// <see cref="Yavsc.Services.PermissionHandler"/> (the public/owner/
|
||||
/// sponsor/Administrator disjunction) and a real SQLite store — no
|
||||
/// mocking.</para>
|
||||
///
|
||||
/// <para>Seeding and the service call resolve through separate DI
|
||||
/// scopes so the DbContext that writes the seed rows is disposed
|
||||
/// before the one <see cref="BlogSpotService.Details"/> reads through,
|
||||
/// mirroring the per-request DbContext lifetime in production.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Server")]
|
||||
public sealed class BlogSpotServiceDetailsTests : IClassFixture<ServerServicesFixture>
|
||||
{
|
||||
private readonly ServerServicesFixture _fixture;
|
||||
|
||||
public BlogSpotServiceDetailsTests(ServerServicesFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>An authenticated principal whose <c>sub</c> claim is
|
||||
/// <paramref name="userId"/>. <see cref="Yavsc.Server.Helpers.UserHelpers.GetUserId"/>
|
||||
/// reads <c>sub</c> first, which is what the JwtBearer middleware
|
||||
/// in production preserves (MapInboundClaims = false), and what
|
||||
/// <see cref="Yavsc.Services.PermissionHandler"/> compares against
|
||||
/// <see cref="BlogPost.AuthorId"/>.</summary>
|
||||
private static ClaimsPrincipal Authenticated(string userId,
|
||||
params Claim[] extra)
|
||||
{
|
||||
var claims = new List<Claim> { new("sub", userId) };
|
||||
claims.AddRange(extra);
|
||||
return new ClaimsPrincipal(
|
||||
new ClaimsIdentity(claims, authenticationType: "Bearer"));
|
||||
}
|
||||
|
||||
/// <summary>An unauthenticated principal: a
|
||||
/// <see cref="ClaimsIdentity"/> with no authentication type has
|
||||
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false. The
|
||||
/// service still runs authorization — a published post's
|
||||
/// <c>ReadPermission</c> succeeds purely on IsPublic, with no
|
||||
/// identity required.</summary>
|
||||
private static ClaimsPrincipal Anonymous()
|
||||
=> new(new ClaimsIdentity());
|
||||
|
||||
/// <summary>The Microsoft-format role claim
|
||||
/// <see cref="Yavsc.Server.Helpers.UserHelpers.IsInMsRole"/>
|
||||
/// reads when checking the "Administrator" bypass in
|
||||
/// <see cref="Yavsc.Services.PermissionHandler"/>.</summary>
|
||||
private static Claim AdminRole()
|
||||
=> new("http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
|
||||
"Administrator");
|
||||
|
||||
[Fact]
|
||||
public async Task Details_returns_the_post_for_its_owner_and_keeps_the_acl()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var circleId = _fixture.SeedCircle("alice", "alice-private");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Owner reads own draft");
|
||||
_fixture.SeedAcl(postId, circleId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), postId));
|
||||
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.Equal("Owner reads own draft", blog.Title);
|
||||
// The owner is the one viewer ScrubAclForViewer keeps the ACL
|
||||
// for — a non-owner would see an empty list.
|
||||
Assert.Single(blog.ACL!);
|
||||
// No BlogSpotPublication row was seeded, so IsPublished is
|
||||
// derived from the (null) Publication navigation as false.
|
||||
Assert.False(blog.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_serves_IsPublished_true_when_a_publication_row_exists()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Published post");
|
||||
_fixture.Publish(postId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), postId));
|
||||
|
||||
// IsPublished is a wire-only value the service derives from
|
||||
// the loaded Publication navigation (Details Includes it).
|
||||
Assert.True(blog.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_lets_an_anonymous_viewer_read_a_published_post_and_scrubs_its_acl()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
var circleId = _fixture.SeedCircle("alice", "alice-private");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Public post");
|
||||
// The ACL is real, but a non-owner viewer must not see it.
|
||||
_fixture.SeedAcl(postId, circleId);
|
||||
_fixture.Publish(postId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Anonymous(), postId));
|
||||
|
||||
// ReadPermission succeeds purely on IsPublic (the publication
|
||||
// row) — no identity needed.
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.True(blog.IsPublished);
|
||||
// ScrubAclForViewer: the anonymous viewer is not the owner, so
|
||||
// the ACL is replaced with an empty list rather than leaked.
|
||||
Assert.Empty(blog.ACL!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_denies_a_draft_to_a_non_owner_who_is_not_a_sponsor()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Alice's draft");
|
||||
|
||||
// No publication row (not public), bob is not the owner, bob
|
||||
// is not a member of any circle alice owns, bob is not an
|
||||
// Administrator — every branch of ReadPermission fails.
|
||||
await Assert.ThrowsAsync<AuthorizationFailureException>(() =>
|
||||
_fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("bob"), postId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_lets_a_sponsor_read_a_draft_and_scrubs_its_acl()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
// Sponsorship = bob is a member of a circle alice owns.
|
||||
var circleId = _fixture.SeedCircle("alice", "alice-friends");
|
||||
_fixture.SeedCircleMember(circleId, "bob");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Draft for friends");
|
||||
_fixture.SeedAcl(postId, circleId);
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("bob"), postId));
|
||||
|
||||
// ReadPermission succeeds on the IsSponsor branch.
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.False(blog.IsPublished);
|
||||
// bob is not the owner, so the ACL is scrubbed even though he
|
||||
// was granted read access via the circle.
|
||||
Assert.Empty(blog.ACL!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_lets_an_administrator_read_a_draft()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("root");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Draft, admin view");
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("root", AdminRole()), postId));
|
||||
|
||||
// The Administrator bypass in PermissionHandler short-circuits
|
||||
// the public/owner/sponsor checks.
|
||||
Assert.Equal(postId, blog.Id);
|
||||
Assert.False(blog.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_loads_each_comment_author()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
var postId = _fixture.SeedBlogPost("alice", "Post with comments");
|
||||
_fixture.SeedComment(postId, "bob", "Nice post!");
|
||||
|
||||
var blog = await _fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), postId));
|
||||
|
||||
var comment = Assert.Single(blog.Comments);
|
||||
// Details explicitly loads c.Author from _context.Users for
|
||||
// every comment; the navigation must not be left null.
|
||||
Assert.NotNull(comment.Author);
|
||||
Assert.Equal("bob", comment.Author.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_throws_for_a_missing_post()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
|
||||
// The query uses SingleAsync, which throws
|
||||
// InvalidOperationException when no row matches — the
|
||||
// `if (blog == null) return null` guard below it is unreachable.
|
||||
// Pin that current behaviour so a future switch to
|
||||
// SingleOrDefaultAsync (which would make the guard live) is a
|
||||
// deliberate, reviewed change rather than a silent one.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_fixture.WithServiceAsync(svc =>
|
||||
svc.Details(Authenticated("alice"), 424242)));
|
||||
}
|
||||
}
|
||||
452
src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
Normal file
452
src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Access;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Server.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Service-level test host for <see cref="BlogSpotService"/> and the
|
||||
/// other Yavsc.Server services that take an
|
||||
/// <see cref="ApplicationDbContext"/> + <see cref="IAuthorizationService"/>.
|
||||
///
|
||||
/// <para>Unlike <c>BlogsWebServerFixture</c> (in Yavsc.Blogs.Tests),
|
||||
/// this fixture does <b>not</b> spin up Kestrel, JWT bearer, or MVC
|
||||
/// controllers. <see cref="BlogSpotService.Details"/> takes the
|
||||
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> as a parameter,
|
||||
/// so the caller controls identity directly — there is no HTTP/auth
|
||||
/// middleware in the path under test. Building only the DI graph the
|
||||
/// service actually resolves keeps these tests fast and isolates the
|
||||
/// service layer from the blog API's transport concerns, which
|
||||
/// Yavsc.Blogs.Tests already covers end-to-end.</para>
|
||||
///
|
||||
/// <para><b>Database provider.</b> Mirrors
|
||||
/// <c>ApiWebServerFixture</c>: SQLite <c>:memory:</c> by default, or
|
||||
/// real PostgreSQL when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c> is
|
||||
/// set. Production runs on Npgsql, so the Npgsql path — which applies
|
||||
/// the real <c>Yavsc.Org</c> migrations against a dedicated test
|
||||
/// database — is the one that catches provider-specific bugs (FK
|
||||
/// enforcement, type mappings, migration drift). SQLite stays the
|
||||
/// default so the suite runs out-of-the-box with no external server.</para>
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>SQLite: a named <c>:memory:</c> database
|
||||
/// (Cache=Shared) backed by a single connection held open for the
|
||||
/// fixture's lifetime, schema via <c>EnsureCreated</c>. Reset is
|
||||
/// <c>EnsureDeleted</c> + <c>EnsureCreated</c>.</description></item>
|
||||
/// <item><description>Npgsql: a dedicated database
|
||||
/// <c>yavscServerTestDb</c> (created from the admin connection if it
|
||||
/// does not exist), schema via <c>Migrate</c> using the
|
||||
/// <c>Yavsc.Org</c> migrations assembly. Reset purges the blog-graph
|
||||
/// tables in FK order — children before parents — so no DELETE
|
||||
/// violates a constraint, instead of dropping the database (which
|
||||
/// would force a full re-migration per test).</description></item>
|
||||
/// <item><description>The real <see cref="BlogSpotService"/> and the
|
||||
/// real <see cref="PermissionHandler"/>: <c>Details</c> calls
|
||||
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new
|
||||
/// ReadPermission())</c>, and <see cref="PermissionHandler"/> answers
|
||||
/// that requirement with the public/owner/sponsor/Administrator
|
||||
/// disjunction that the tests below pin. No mocking.</description></item>
|
||||
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
|
||||
/// stub: <c>Details</c> never touches the file system, but the DI
|
||||
/// container needs an instance to construct the service.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Marked <see cref="CollectionDefinitionAttribute"/> so a single
|
||||
/// fixture instance (one store, one DI container) is shared across
|
||||
/// every <c>[Collection("Yavsc Server")]</c> test class. Each test
|
||||
/// resets the store via <see cref="ResetDatabase"/>.</para>
|
||||
/// </summary>
|
||||
[CollectionDefinition("Yavsc Server")]
|
||||
public sealed class ServerServicesFixture : IDisposable
|
||||
{
|
||||
private const string DbProviderEnvVar = "YAVSC_SERVER_TEST_DB_PROVIDER";
|
||||
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION";
|
||||
private const string DedicatedNpgsqlDatabaseName = "yavscServerTestDb";
|
||||
|
||||
private static readonly object _npgsqlLock = new();
|
||||
private static string? _sharedNpgsqlConnectionString;
|
||||
|
||||
private readonly SqliteConnection? _sqliteConnection;
|
||||
private readonly ServiceProvider _services;
|
||||
|
||||
public IServiceProvider Services => _services;
|
||||
|
||||
public ServerServicesFixture()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
if (UseNpgsqlProvider())
|
||||
{
|
||||
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
|
||||
services.AddDbContext<ApplicationDbContext>(opt =>
|
||||
opt.UseNpgsql(npgsqlConnectionString,
|
||||
x => x.MigrationsAssembly("Yavsc.Org")));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Mode=Memory + Cache=Shared: a named in-memory database
|
||||
// every connection string referencing "YavscServerTests"
|
||||
// resolves to the same backing store, as long as at least
|
||||
// one connection stays open against it. _sqliteConnection
|
||||
// is held for the fixture's lifetime (disposed in Dispose),
|
||||
// so the store survives across scoped DbContexts per test.
|
||||
_sqliteConnection = new SqliteConnection(
|
||||
"Data Source=YavscServerTests;Mode=Memory;Cache=Shared");
|
||||
_sqliteConnection.Open();
|
||||
|
||||
services.AddDbContext<ApplicationDbContext>(opt =>
|
||||
// UseSqlite(DbConnection) keeps _sqliteConnection alive
|
||||
// for the DbContext's lifetime instead of letting EF
|
||||
// open and close its own — without this each DbContext
|
||||
// would get a fresh connection pointing at an empty
|
||||
// store.
|
||||
opt.UseSqlite(_sqliteConnection));
|
||||
}
|
||||
|
||||
// BlogSpotService.AttachFiles writes under siteSettings.Blog.
|
||||
// Details itself doesn't read it, but the service constructor
|
||||
// throws if SiteSettings.Blog is null, so configure a temp
|
||||
// value to keep construction alive.
|
||||
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
|
||||
|
||||
// Trivial file-system auth: Details never calls into it, but
|
||||
// the DI container needs an instance to construct the service.
|
||||
services.AddSingleton<IFileSystemAuthManager>(
|
||||
new NoopFileSystemAuthManager());
|
||||
|
||||
// The real BlogSpotService — same shape the production host
|
||||
// builds (ApplicationDbContext, IAuthorizationService,
|
||||
// IFileSystemAuthManager, IOptions<SiteSettings>,
|
||||
// ILoggerFactory).
|
||||
services.AddScoped<BlogSpotService>();
|
||||
|
||||
// The real PermissionHandler: Details authorizes with
|
||||
// ReadPermission, and PermissionHandler resolves it via the
|
||||
// IsPublic / IsOwner / IsSponsor / Administrator disjunction.
|
||||
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
|
||||
// Default authorization services register
|
||||
// IAuthorizationService (DefaultAuthorizationService), which
|
||||
// collects the IAuthorizationHandler above and runs it against
|
||||
// the requirement passed to AuthorizeAsync.
|
||||
services.AddAuthorization();
|
||||
services.AddLogging();
|
||||
|
||||
_services = services.BuildServiceProvider();
|
||||
|
||||
// Create the schema once. Npgsql uses Migrate (the Yavsc.Org
|
||||
// migrations) so the test schema matches production; SQLite uses
|
||||
// EnsureCreated (idempotent, model-derived). ResetDatabase
|
||||
// re-creates / purges per test.
|
||||
using (var scope = _services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider
|
||||
.GetRequiredService<ApplicationDbContext>();
|
||||
if (UseNpgsqlProvider())
|
||||
db.Database.Migrate();
|
||||
else
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c>
|
||||
/// is set — the fixture then runs against a real PostgreSQL server
|
||||
/// instead of the SQLite in-memory default.</summary>
|
||||
public static bool UseNpgsqlProvider()
|
||||
=> string.Equals(
|
||||
Environment.GetEnvironmentVariable(DbProviderEnvVar),
|
||||
"npgsql",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Reset the store to a known empty state for the next
|
||||
/// test. SQLite: <c>EnsureDeleted</c> + <c>EnsureCreated</c> (the
|
||||
/// named in-memory store survives because _sqliteConnection is held
|
||||
/// open). Npgsql: purge the blog-graph tables in FK order —
|
||||
/// children before parents — so no DELETE trips a foreign key,
|
||||
/// avoiding a full re-migration per test.</summary>
|
||||
public void ResetDatabase()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider
|
||||
.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
if (UseNpgsqlProvider())
|
||||
{
|
||||
// Children before parents. BlogAttachedFile, Comment and
|
||||
// BlogSpotPublication reference BlogSpot; CircleAuthorization
|
||||
// ToBlogPost references BlogSpot + Circle; BlogTag references
|
||||
// BlogSpot + Tag; BlogSpot references ApplicationUser;
|
||||
// CircleMember references Circle + ApplicationUser.
|
||||
db.BlogAttachedFiles.RemoveRange(db.BlogAttachedFiles);
|
||||
db.Comment.RemoveRange(db.Comment);
|
||||
db.blogSpotPublications.RemoveRange(db.blogSpotPublications);
|
||||
db.CircleAuthorizationToBlogPost
|
||||
.RemoveRange(db.CircleAuthorizationToBlogPost);
|
||||
db.BlogTag.RemoveRange(db.BlogTag);
|
||||
db.UploadedFiles.RemoveRange(db.UploadedFiles);
|
||||
db.BlogSpot.RemoveRange(db.BlogSpot);
|
||||
db.CircleMembers.RemoveRange(db.CircleMembers);
|
||||
db.Circle.RemoveRange(db.Circle);
|
||||
db.Users.RemoveRange(db.Users);
|
||||
db.SaveChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
/// <summary>Resolve a fresh service scope and the
|
||||
/// <see cref="ApplicationDbContext"/> from it, run
|
||||
/// <paramref name="body"/>, and dispose the scope (and its
|
||||
/// DbContext). Use this for seeding writes that should not share
|
||||
/// a DbContext with the service call under test.</summary>
|
||||
public T WithDb<T>(Func<ApplicationDbContext, T> body)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider
|
||||
.GetRequiredService<ApplicationDbContext>();
|
||||
return body(db);
|
||||
}
|
||||
|
||||
public void SeedUser(string userName,
|
||||
Action<ApplicationUser>? configure = null)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
if (db.Users.Any(u => u.Id == userName)) return false;
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = userName,
|
||||
UserName = userName,
|
||||
Email = $"{userName}@example.test",
|
||||
};
|
||||
configure?.Invoke(user);
|
||||
db.Users.Add(user);
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public long SeedBlogPost(string authorId, string title,
|
||||
Action<BlogPost>? configure = null)
|
||||
{
|
||||
return WithDb(db =>
|
||||
{
|
||||
var post = new BlogPost
|
||||
{
|
||||
AuthorId = authorId,
|
||||
Title = title,
|
||||
Article = "Test article body.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
configure?.Invoke(post);
|
||||
db.BlogSpot.Add(post);
|
||||
db.SaveChanges();
|
||||
return post.Id;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Insert a <see cref="BlogSpotPublication"/> row for
|
||||
/// <paramref name="postId"/> — the existence of that row is what
|
||||
/// makes a post "public" (readable by anyone) in
|
||||
/// <see cref="PermissionHandler"/>. This is the same write
|
||||
/// <c>SetPublishAsync</c> performs in production.</summary>
|
||||
public void Publish(long postId)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.blogSpotPublications.Add(
|
||||
new BlogSpotPublication { PostId = postId });
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Seed a circle owned by <paramref name="ownerId"/> and
|
||||
/// return its server-assigned id. Sponsorship in
|
||||
/// <see cref="PermissionHandler"/> is "the viewer is a member of a
|
||||
/// circle the post's author owns", so a circle + a
|
||||
/// <see cref="SeedCircleMember"/> entry is what grants a non-owner
|
||||
/// read access to a draft.</summary>
|
||||
public long SeedCircle(string ownerId, string name)
|
||||
{
|
||||
return WithDb(db =>
|
||||
{
|
||||
var circle = new Circle
|
||||
{
|
||||
OwnerId = ownerId,
|
||||
Name = name,
|
||||
Public = false,
|
||||
};
|
||||
db.Circle.Add(circle);
|
||||
db.SaveChanges();
|
||||
return circle.Id;
|
||||
});
|
||||
}
|
||||
|
||||
public void SeedCircleMember(long circleId, string memberId)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.CircleMembers.Add(
|
||||
new CircleMember { CircleId = circleId, MemberId = memberId });
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Attach a <see cref="CircleAuthorizationToBlogPost"/> ACL
|
||||
/// entry to <paramref name="postId"/> for <paramref name="circleId"/>.
|
||||
/// The owner always sees the ACL; non-owners have it scrubbed by
|
||||
/// <see cref="BlogSpotService"/>'s ScrubAclForViewer.</summary>
|
||||
public void SeedAcl(long postId, long circleId)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.CircleAuthorizationToBlogPost.Add(
|
||||
new CircleAuthorizationToBlogPost
|
||||
{
|
||||
BlogPostId = postId,
|
||||
CircleId = circleId,
|
||||
});
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Seed a comment on <paramref name="postId"/> authored by
|
||||
/// <paramref name="authorId"/>. <see cref="BlogSpotService.Details"/>
|
||||
/// loads each comment's <c>Author</c> navigation from
|
||||
/// <c>_context.Users</c>; seeding the user first keeps the FK
|
||||
/// valid (SQLite and PostgreSQL both enforce it).</summary>
|
||||
public void SeedComment(long postId, string authorId, string article)
|
||||
{
|
||||
WithDb(db =>
|
||||
{
|
||||
db.Comment.Add(new Comment
|
||||
{
|
||||
ReceiverId = postId,
|
||||
AuthorId = authorId,
|
||||
Article = article,
|
||||
Visible = true,
|
||||
UserModified = authorId,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
|
||||
/// fixture's DI container, run <paramref name="body"/> against it,
|
||||
/// and dispose the request scope (and its scoped DbContext). The
|
||||
/// service is scoped, so it must be resolved from a scope — and the
|
||||
/// scope is what we dispose to avoid leaking a DbContext against
|
||||
/// the shared connection across tests.</summary>
|
||||
public async Task<T> WithServiceAsync<T>(
|
||||
Func<BlogSpotService, Task<T>> body)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var service = scope.ServiceProvider
|
||||
.GetRequiredService<BlogSpotService>();
|
||||
return await body(service);
|
||||
}
|
||||
|
||||
private static string EnsureNpgsqlDatabaseCreated()
|
||||
{
|
||||
lock (_npgsqlLock)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
|
||||
return _sharedNpgsqlConnectionString;
|
||||
|
||||
var adminConnectionString = BuildAdminConnectionString();
|
||||
var databaseName = DedicatedNpgsqlDatabaseName;
|
||||
|
||||
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
|
||||
{
|
||||
adminConnection.Open();
|
||||
using var existsCommand = adminConnection.CreateCommand();
|
||||
existsCommand.CommandText =
|
||||
"SELECT 1 FROM pg_database WHERE datname = @databaseName";
|
||||
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
|
||||
|
||||
if (existsCommand.ExecuteScalar() is null)
|
||||
{
|
||||
using var createCommand = adminConnection.CreateCommand();
|
||||
createCommand.CommandText =
|
||||
$"CREATE DATABASE \"{databaseName}\"";
|
||||
createCommand.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
|
||||
{
|
||||
Database = databaseName,
|
||||
Pooling = false,
|
||||
IncludeErrorDetail = true
|
||||
};
|
||||
|
||||
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
|
||||
return _sharedNpgsqlConnectionString;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAdminConnectionString()
|
||||
{
|
||||
// The admin connection string (host + credentials) is supplied
|
||||
// via environment, never embedded in source — committing a
|
||||
// real dev password would leak it into git history. The Npgsql
|
||||
// path is opt-in, so requiring the env var is fine: a missing
|
||||
// value produces a clear failure rather than a silent fallback
|
||||
// onto someone's local dev database.
|
||||
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
throw new InvalidOperationException(
|
||||
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
|
||||
+ "provider requires an admin PostgreSQL connection string "
|
||||
+ "in that environment variable (e.g. "
|
||||
+ "'Server=localhost;Port=5432;Database=postgres;"
|
||||
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
|
||||
|
||||
var builder = new NpgsqlConnectionStringBuilder(configured)
|
||||
{
|
||||
Pooling = false,
|
||||
IncludeErrorDetail = true
|
||||
};
|
||||
|
||||
// The admin connection targets the maintenance database
|
||||
// (postgres) so we can CREATE/DROP the dedicated test database;
|
||||
// whatever database the env var names is overridden here.
|
||||
builder.Database = "postgres";
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_services.Dispose();
|
||||
_sqliteConnection?.Dispose();
|
||||
}
|
||||
|
||||
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
||||
{
|
||||
public FileAccessRight GetFilePathAccess(
|
||||
System.Security.Claims.ClaimsPrincipal user,
|
||||
string fileRelativePath)
|
||||
=> FileAccessRight.None;
|
||||
|
||||
public void SetAccess(long circleId, string normalizedFullPath,
|
||||
FileAccessRight access) { }
|
||||
}
|
||||
}
|
||||
32
src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
Normal file
32
src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Yavsc.Server.Tests</RootNamespace>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.v3.common" />
|
||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<Library>true</Library>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
-->
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
15
yavsc.sln
15
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue