Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
94d8eb538c |
|||
|
daf5949392 |
|||
|
2c8be78175 |
|||
|
e1ce67a76e |
|||
|
384f6bc300 |
|||
|
a17a9cef9a |
|||
|
ed3dc733d3 |
|||
|
5553f2f290 |
|||
|
3fe7b8b46c |
|||
|
a793996b70 |
|||
|
8e3862ed26 |
|||
|
70ab05522b |
|||
|
eb58fc8862 |
|||
|
040446c0b8 |
|||
|
bad09cc7af |
|||
|
2c36d880e1 |
|||
|
f0d262cb37 |
|||
|
1cf3c689d8 |
|||
|
fbf42cc1db |
|||
|
ba40698cc9 |
|||
|
35d784fdf1 |
|||
|
9c1f552bca |
|||
|
bbc37d3095 |
|||
|
7caae103fe |
|||
|
f44dab0334 |
|||
|
97d0584292 |
|||
|
70a123b840 |
|||
|
adc27a006b |
|||
|
827fe6b41f |
|||
|
36ffe4683c |
|||
|
2ad7a55e34 |
|||
|
709846c58d |
|||
|
9aef10f281 |
|||
|
e28959b068 |
|||
|
22c33c5388 |
|||
|
60c10781f0 |
|||
|
625e062b4b |
|||
|
1455ba5187 |
|||
|
605d885917 |
|||
|
efc2296858 |
|||
|
6a69a20364 |
|||
|
f09c657183 |
|||
|
93fd4e61ad |
|||
|
d5f11e83a4 |
|||
|
11ae66f85b |
|||
|
8d1348197d |
|||
|
483abb78ec |
|||
|
b3c2674235 |
|||
|
db5c202918 |
|||
|
1773c5aaea |
|||
|
67da921bce |
|||
| fb0d9589cf | |||
|
c450bd7ff8 |
|||
|
827cf7d2a3 |
|||
|
44f0664a5d |
|||
|
a12830e49b |
|||
|
2d8f92dde9 |
|||
|
156b9c46e1 |
|||
|
1a968346a4 |
|||
|
574616c786 |
|||
|
bc6bebea06 |
|||
|
9e8d00d78f |
|||
|
ac1ba2ee68 |
|||
|
eebd46176d |
|||
|
3bc4142383 |
|||
|
3e7e76edf2 |
|||
|
96254b52cd |
|||
|
d47dd0f4ba |
|||
|
51a2a40c88 |
|||
|
2b1c3d2a9b |
|||
|
ef9ee2fb87 |
|||
|
110ed279f5 |
|||
| 48cacb74cb | |||
|
48ffe835df |
|||
|
2cd24dd383 |
|||
|
c3c5494507 |
|||
|
75e1499bb1 |
|||
|
0c5bd61991 |
|||
|
2484876c5b |
|||
|
848f8be42f |
|||
|
f70f487777 |
|||
|
1af839e14e |
|||
|
f7e1f4a736 |
|||
|
7c565d3af9 |
|||
|
9832d28173 |
|||
|
cbb59f019d |
|||
|
67715f9d67 |
|||
|
d92456e141 |
|||
|
5b48e0bfdb |
|||
|
3836faf872 |
|||
|
2397f4d3a8 |
|||
|
86091990c5 |
|||
|
ddff760c41 |
|||
|
344c38704b |
|||
|
af1a0e0bf9 |
|||
|
c665a3e95d |
|||
|
3824a928fe |
|||
|
77e521b96d |
|||
|
91fc7f168b |
|||
|
3626be3d8b |
|||
|
7b842a9fe9 |
|||
|
b75d0add7d |
|||
|
555087232c |
|||
|
41d8fad8c3 |
|||
|
d8c0678462 |
|||
|
93426986f6 |
|||
|
ec87e8db15 |
|||
|
bb4792d8ae |
|||
|
f7d5b21d66 |
|||
|
e79fba7b66 |
|||
|
d65f78d985 |
|||
|
7a4ca76957 |
|||
|
c902852a24 |
|||
|
38bc659a58 |
|||
|
10fdcb288c |
|||
|
229971783a |
73 changed files with 8776 additions and 161 deletions
|
|
@ -44,7 +44,7 @@ jobs:
|
||||||
- name: Secret scan
|
- name: Secret scan
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Scanning for secrets..."
|
echo "🔍 Scanning for secrets..."
|
||||||
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \
|
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
echo "🏗️ Building the solution..."
|
echo "🏗️ Building the solution..."
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -43,3 +43,9 @@ DataDir/
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# gitleaks scan output (local secret-scan report, not for the repo)
|
||||||
|
/gitleaks-report.json
|
||||||
|
/test-report.html
|
||||||
|
/test-report.xml
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
6
.pre-commit-config.yaml
Normal file
6
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/gitleaks/gitleaks
|
||||||
|
rev: v8.24.2
|
||||||
|
hooks:
|
||||||
|
- id: gitleaks
|
||||||
|
|
||||||
10
.vscode/launch.json
vendored
10
.vscode/launch.json
vendored
|
|
@ -43,9 +43,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Yavsc Org",
|
"name": "Yavsc Org",
|
||||||
"type": "dotnet",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
|
"program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll",
|
||||||
|
"env": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"cwd": "${workspaceFolder}/src/Yavsc.Org",
|
||||||
|
"preLaunchTask": "build-web-yavsc-org"
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Yavsc Blogs",
|
"name": "Yavsc Blogs",
|
||||||
|
|
|
||||||
28
.vscode/tasks.json
vendored
28
.vscode/tasks.json
vendored
|
|
@ -89,6 +89,34 @@
|
||||||
},
|
},
|
||||||
"isBackground": true
|
"isBackground": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "build-web-yavsc-org",
|
||||||
|
"type": "process",
|
||||||
|
"problemMatcher": ["$msCompile"],
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": ["build"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "src/Yavsc.Org"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"kind": "build"
|
||||||
|
},
|
||||||
|
"isBackground": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build-web-yavsc-blogs",
|
||||||
|
"type": "process",
|
||||||
|
"problemMatcher": ["$msCompile"],
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": ["build"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "src/Yavsc.Blogs"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"kind": "build"
|
||||||
|
},
|
||||||
|
"isBackground": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "dotnet: build-postit-desktop",
|
"label": "dotnet: build-postit-desktop",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
|
|
|
||||||
63
CHANGELOG.md
63
CHANGELOG.md
|
|
@ -1,5 +1,68 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.8-rc17] - unstable
|
||||||
|
|
||||||
|
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
|
||||||
|
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
|
||||||
|
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
|
||||||
|
production où `IsPublished` disparaissait du payload de liste du blog API.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
|
||||||
|
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
|
||||||
|
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
|
||||||
|
le détail de demande et la page d'édition de devis.
|
||||||
|
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
|
||||||
|
(`EstimateApiController`) et les demandes front-office
|
||||||
|
(`FrontOfficeApiController`).
|
||||||
|
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
|
||||||
|
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
|
||||||
|
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
|
||||||
|
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
|
||||||
|
`UserFileAttachments`.
|
||||||
|
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
|
||||||
|
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
|
||||||
|
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
|
||||||
|
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
|
||||||
|
`README.nginx.md`.
|
||||||
|
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
|
||||||
|
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
|
||||||
|
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
|
||||||
|
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
|
||||||
|
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
|
||||||
|
`FileSystemApiTests` (pièces jointes et stockage perso).
|
||||||
|
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
|
||||||
|
production sur le vhost Org. La preprod et la prod partageant la même
|
||||||
|
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
|
||||||
|
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
|
||||||
|
la config par nginx.
|
||||||
|
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
|
||||||
|
provient de la variable d'environnement
|
||||||
|
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
|
||||||
|
source.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
|
||||||
|
de la liste du blog API. Le endpoint `GetBlogspot` retourne
|
||||||
|
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
|
||||||
|
déclaré (l'interface), qui omet les propriétés du type concret —
|
||||||
|
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
|
||||||
|
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
|
||||||
|
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
|
||||||
|
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
|
||||||
|
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
|
||||||
|
`CommentsController` (Index/Details/…) atterrissaient sur la même route
|
||||||
|
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
|
||||||
|
`SwaggerGeneratorException` (conflit method/path) au chargement de
|
||||||
|
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
|
||||||
|
les actions scaffoldées.
|
||||||
|
|
||||||
## [1.0.8-rc16] - unstable
|
## [1.0.8-rc16] - unstable
|
||||||
|
|
||||||
Flux de signature et de génération de devis : un devis peut désormais être
|
Flux de signature et de génération de devis : un devis peut désormais être
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
|
||||||
@cat template.service | APP_NAME="$*" \
|
@cat template.service | APP_NAME="$*" \
|
||||||
DESTDIR="$(DESTDIR)" \
|
DESTDIR="$(DESTDIR)" \
|
||||||
HTTP_HOST="$(HTTP_HOST)" \
|
HTTP_HOST="$(HTTP_HOST)" \
|
||||||
HTTP_PORT="$*_$(HTTP_PORT)" \
|
HTTP_PORT="$($*_PORT)" \
|
||||||
APPDIR="$(APPDIR)" \
|
APPDIR="$(APPDIR)" \
|
||||||
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
|
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
|
||||||
POSTGRES_HOST="$(POSTGRES_HOST)" \
|
POSTGRES_HOST="$(POSTGRES_HOST)" \
|
||||||
|
|
@ -45,6 +45,76 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
|
||||||
|
|
||||||
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
|
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
|
||||||
|
|
||||||
|
# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) ---
|
||||||
|
# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
|
||||||
|
# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr)
|
||||||
|
#
|
||||||
|
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
|
||||||
|
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
|
||||||
|
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
|
||||||
|
NGINX_TEMPLATE := template.vhost
|
||||||
|
NGINX_OUT_DIR := generated/nginx
|
||||||
|
NGINX_HOSTS := Org Blogs Api
|
||||||
|
DOMAIN_ROOT ?= pschneider.fr
|
||||||
|
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
|
||||||
|
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
|
||||||
|
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
|
||||||
|
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
|
||||||
|
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
|
||||||
|
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
|
||||||
|
# prod Org -> yavsc.pschneider.fr
|
||||||
|
# preprod Org -> preprod-yavsc.pschneider.fr
|
||||||
|
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
|
||||||
|
|
||||||
|
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
|
||||||
|
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
|
||||||
|
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
|
||||||
|
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
|
||||||
|
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
|
||||||
|
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
|
||||||
|
@cat $(NGINX_TEMPLATE) \
|
||||||
|
| DOMAIN="$(call nginx_domain,$*)" \
|
||||||
|
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
|
||||||
|
HTTP_HOST="$(HTTP_HOST)" \
|
||||||
|
BACKEND_PORT="$($*_PORT)" \
|
||||||
|
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
|
||||||
|
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
|
||||||
|
@echo Created nginx vhost: $@
|
||||||
|
|
||||||
|
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
|
||||||
|
|
||||||
|
generate-nginx-prod:
|
||||||
|
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
|
||||||
|
|
||||||
|
$(NGINX_OUT_DIR):
|
||||||
|
@mkdir -p $@
|
||||||
|
|
||||||
|
# --- appsettings (générés depuis template.appsettings-*.json) ---
|
||||||
|
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
|
||||||
|
#
|
||||||
|
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
|
||||||
|
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
|
||||||
|
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
|
||||||
|
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
|
||||||
|
# contient un \). jq lit le secret depuis l'environnement (export) ->
|
||||||
|
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
|
||||||
|
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
|
||||||
|
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
|
||||||
|
export ASPNETCORE_Smtp__Password
|
||||||
|
APPSETTINGS_OUT_DIR := generated/appsettings
|
||||||
|
APPSETTINGS_HOSTS := org api blogs
|
||||||
|
|
||||||
|
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
|
||||||
|
@jq --arg domain "$(DOMAIN_ROOT)" \
|
||||||
|
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
|
||||||
|
template.appsettings-$*.json > $@
|
||||||
|
@echo Created appsettings: $@
|
||||||
|
|
||||||
|
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
|
||||||
|
|
||||||
|
$(APPSETTINGS_OUT_DIR):
|
||||||
|
@mkdir -p $@
|
||||||
|
|
||||||
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
|
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
|
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
|
||||||
|
|
|
||||||
134
contrib/README.nginx.md
Normal file
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>
|
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ public class EstimateEditionPageViewModelTests
|
||||||
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
|
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
|
||||||
{
|
{
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||||
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
|
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
|
||||||
|
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -143,10 +144,8 @@ public class EstimateEditionPageViewModelTests
|
||||||
|
|
||||||
await vm.SendCommand.ExecuteAsync(null);
|
await vm.SendCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
|
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||||
|
|
||||||
var payload = Assert.IsType<EstimateDto>(api.LastBody);
|
|
||||||
Assert.Equal(42, payload.CommandId);
|
Assert.Equal(42, payload.CommandId);
|
||||||
Assert.Equal("cli-1", payload.ClientId);
|
Assert.Equal("cli-1", payload.ClientId);
|
||||||
Assert.Equal("Brush", payload.CommandType);
|
Assert.Equal("Brush", payload.CommandType);
|
||||||
|
|
@ -216,7 +215,8 @@ public class EstimateEditionPageViewModelTests
|
||||||
|
|
||||||
await vm.SendCommand.ExecuteAsync(null);
|
await vm.SendCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
var payload = Assert.IsType<EstimateDto>(api.LastBody);
|
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||||
|
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||||
Assert.Equal(2, payload.Bill.Count);
|
Assert.Equal(2, payload.Bill.Count);
|
||||||
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
|
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
|
||||||
Assert.True(vm.HasSent);
|
Assert.True(vm.HasSent);
|
||||||
|
|
@ -230,11 +230,20 @@ public class EstimateEditionPageViewModelTests
|
||||||
public object? LastBody { get; private set; }
|
public object? LastBody { get; private set; }
|
||||||
public Exception? Failure { get; init; }
|
public Exception? Failure { get; init; }
|
||||||
|
|
||||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
/// <summary>Historique complet des appels (le plus récent en dernier).</summary>
|
||||||
|
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
|
||||||
|
|
||||||
|
private void Record(HttpMethod method, string path, object? body)
|
||||||
{
|
{
|
||||||
LastMethod = method;
|
LastMethod = method;
|
||||||
LastPath = path;
|
LastPath = path;
|
||||||
LastBody = body;
|
LastBody = body;
|
||||||
|
Calls.Add((method, path, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Record(method, path, body);
|
||||||
|
|
||||||
if (Failure is not null)
|
if (Failure is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -253,9 +262,7 @@ public class EstimateEditionPageViewModelTests
|
||||||
|
|
||||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
LastMethod = method;
|
Record(method, path, body);
|
||||||
LastPath = path;
|
|
||||||
LastBody = body;
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,6 +272,12 @@ public class EstimateEditionPageViewModelTests
|
||||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
=> CallAsync(method, path, (object?)null, ct);
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Record(method, path, null);
|
||||||
|
return Task.FromResult(Array.Empty<byte>());
|
||||||
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public static class ServiceCollectionHelpers
|
||||||
|
|
||||||
var api = new YavscApiClient(settings, tokenStore);
|
var api = new YavscApiClient(settings, tokenStore);
|
||||||
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
||||||
|
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
|
||||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||||
|
|
@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddTransient<EstimateEditionPage>();
|
services.AddTransient<EstimateEditionPage>();
|
||||||
services.AddTransient<EstimateListPage>();
|
services.AddTransient<EstimateListPage>();
|
||||||
services.AddTransient<EstimateValidationPage>();
|
services.AddTransient<EstimateValidationPage>();
|
||||||
|
services.AddTransient<MyFilesPage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
@ -77,6 +79,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(billingClient);
|
services.AddSingleton(billingClient);
|
||||||
services.AddSingleton(estimateClient);
|
services.AddSingleton(estimateClient);
|
||||||
services.AddSingleton(frontClient);
|
services.AddSingleton(frontClient);
|
||||||
|
services.AddSingleton(userFilesClient);
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ public class ViewLocator : IDataTemplate
|
||||||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||||
|
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
null => new TextBlock { Text = "No view for <null>" },
|
||||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -132,14 +132,18 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
||||||
{
|
{
|
||||||
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
||||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||||
|
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
Activity,
|
Activity,
|
||||||
Performer,
|
Performer,
|
||||||
Form,
|
Form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
frontClient,
|
frontClient,
|
||||||
|
fsClient,
|
||||||
details,
|
details,
|
||||||
IsReadOnly);
|
IsReadOnly,
|
||||||
|
canAttach: !IsReadOnly);
|
||||||
|
await vm.InitializeAsync().ConfigureAwait(true);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
@ -15,8 +16,17 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
{
|
{
|
||||||
private readonly BillingApiClient _billingClient;
|
private readonly BillingApiClient _billingClient;
|
||||||
private readonly FrontOfficeApiClient _frontClient;
|
private readonly FrontOfficeApiClient _frontClient;
|
||||||
|
private readonly UserFilesApiClient _fsClient;
|
||||||
private readonly BillingQueryDetailsDto _details;
|
private readonly BillingQueryDetailsDto _details;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vrai lorsque l'utilisateur courant est le client de la demande
|
||||||
|
/// et peut y attacher/détacher des fichiers de son espace perso.
|
||||||
|
/// Le fournisseur consulte et télécharge les pièces mais n'attache
|
||||||
|
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
|
||||||
|
/// </summary>
|
||||||
|
public bool CanAttach { get; }
|
||||||
|
|
||||||
public ActivityInfo Activity { get; }
|
public ActivityInfo Activity { get; }
|
||||||
public ActivityUserDisplayItem Performer { get; }
|
public ActivityUserDisplayItem Performer { get; }
|
||||||
public CommandFormSummary Form { get; }
|
public CommandFormSummary Form { get; }
|
||||||
|
|
@ -52,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
|
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
|
||||||
|
|
||||||
|
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
|
||||||
|
|
||||||
public override bool CanNavigateNext
|
public override bool CanNavigateNext
|
||||||
{
|
{
|
||||||
get => false;
|
get => false;
|
||||||
|
|
@ -70,20 +84,27 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
CommandFormSummary form,
|
CommandFormSummary form,
|
||||||
BillingApiClient billingClient,
|
BillingApiClient billingClient,
|
||||||
FrontOfficeApiClient frontClient,
|
FrontOfficeApiClient frontClient,
|
||||||
|
UserFilesApiClient fsClient,
|
||||||
BillingQueryDetailsDto details,
|
BillingQueryDetailsDto details,
|
||||||
bool isReadOnly)
|
bool isReadOnly,
|
||||||
|
bool canAttach)
|
||||||
{
|
{
|
||||||
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
||||||
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
||||||
Form = form ?? throw new ArgumentNullException(nameof(form));
|
Form = form ?? throw new ArgumentNullException(nameof(form));
|
||||||
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
||||||
|
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
|
||||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||||
IsReadOnly = isReadOnly;
|
IsReadOnly = isReadOnly;
|
||||||
|
CanAttach = canAttach;
|
||||||
|
|
||||||
this.SetInfoStatus("Details de commande charges.");
|
this.SetInfoStatus("Details de commande charges.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||||
|
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenEditorAsync()
|
private async Task OpenEditorAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -134,6 +155,110 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
await app.GoBackAsync().ConfigureAwait(true);
|
await app.GoBackAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Charge les pièces jointes de la demande.</summary>
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task LoadQueryAttachmentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _frontClient
|
||||||
|
.GetQueryAttachmentsAsync(BillingCode, Id)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
Attachments.Clear();
|
||||||
|
if (list is not null)
|
||||||
|
foreach (var a in list) Attachments.Add(a);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Non bloquant : les pièces jointes sont un complément.
|
||||||
|
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
|
||||||
|
/// est rattaché à la demande par référence via
|
||||||
|
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
|
||||||
|
/// (<see cref="CanAttach"/>).
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddQueryAttachmentAsync()
|
||||||
|
{
|
||||||
|
if (!CanAttach) return;
|
||||||
|
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var billingCode = BillingCode;
|
||||||
|
var queryId = Id;
|
||||||
|
var picker = new MyFilesViewModel(_fsClient, async fileId =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
|
||||||
|
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await picker.InitializeAsync().ConfigureAwait(true);
|
||||||
|
await app.PushPageAsync(picker).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null || !CanAttach) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
|
||||||
|
Attachments.Remove(attachment);
|
||||||
|
this.SetInfoStatus("Pièce jointe détachée de la demande.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
|
||||||
|
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
|
||||||
|
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Download the estimate linked to this command as a LaTeX source
|
/// Download the estimate linked to this command as a LaTeX source
|
||||||
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
|
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ namespace PostIt.ViewModels;
|
||||||
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
|
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
private readonly EstimateApiClient _estimateClient;
|
private readonly EstimateApiClient _estimateClient;
|
||||||
|
private readonly UserFilesApiClient _fsClient;
|
||||||
private readonly BillingQuerySummaryDto _query;
|
private readonly BillingQuerySummaryDto _query;
|
||||||
|
|
||||||
public long QueryId => _query.Id;
|
public long QueryId => _query.Id;
|
||||||
|
|
@ -68,6 +69,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||||
|
|
||||||
|
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
|
||||||
|
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
|
||||||
|
public partial long? EstimateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
|
||||||
|
|
||||||
public decimal Total => Lines.Sum(line => line.LineTotal);
|
public decimal Total => Lines.Sum(line => line.LineTotal);
|
||||||
|
|
||||||
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
|
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
|
||||||
|
|
@ -86,10 +95,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
protected set { _ = value; }
|
protected set { _ = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
|
public EstimateEditionPageViewModel(
|
||||||
|
BillingQuerySummaryDto query,
|
||||||
|
EstimateApiClient estimateClient,
|
||||||
|
UserFilesApiClient fsClient)
|
||||||
{
|
{
|
||||||
_query = query ?? throw new ArgumentNullException(nameof(query));
|
_query = query ?? throw new ArgumentNullException(nameof(query));
|
||||||
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
||||||
|
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
|
||||||
|
|
||||||
EstimateDescription = query.Description ?? string.Empty;
|
EstimateDescription = query.Description ?? string.Empty;
|
||||||
Lines.CollectionChanged += OnLinesCollectionChanged;
|
Lines.CollectionChanged += OnLinesCollectionChanged;
|
||||||
|
|
@ -98,6 +111,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
|
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||||
|
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddLine()
|
private void AddLine()
|
||||||
{
|
{
|
||||||
|
|
@ -141,9 +157,11 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
|
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
|
||||||
|
|
||||||
HasSent = true;
|
HasSent = true;
|
||||||
|
EstimateId = created.Id;
|
||||||
OnPropertyChanged(nameof(SendLabel));
|
OnPropertyChanged(nameof(SendLabel));
|
||||||
this.SetInfoStatus(
|
this.SetInfoStatus(
|
||||||
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
|
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
|
||||||
|
await LoadAttachmentsAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
{
|
{
|
||||||
|
|
@ -171,6 +189,106 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
await app.GoBackAsync().ConfigureAwait(true);
|
await app.GoBackAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Charge les pièces jointes rattachées au devis courant
|
||||||
|
/// (<c>GET estimate/{id}/attachments</c>).
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task LoadAttachmentsAsync()
|
||||||
|
{
|
||||||
|
if (EstimateId is null || _estimateClient is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
|
||||||
|
Attachments.Clear();
|
||||||
|
if (list is not null)
|
||||||
|
foreach (var a in list) Attachments.Add(a);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
|
||||||
|
/// est rattaché au devis par référence via
|
||||||
|
/// <c>POST estimate/{id}/attachments</c>.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
|
||||||
|
private async Task AddAttachmentAsync()
|
||||||
|
{
|
||||||
|
if (EstimateId is null) return;
|
||||||
|
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var estimateId = EstimateId.Value;
|
||||||
|
var picker = new MyFilesViewModel(_fsClient, async fileId =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
|
||||||
|
await LoadAttachmentsAsync().ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await picker.InitializeAsync().ConfigureAwait(true);
|
||||||
|
await app.PushPageAsync(picker).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DetachFileAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null || EstimateId is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
|
||||||
|
Attachments.Remove(attachment);
|
||||||
|
this.SetInfoStatus("Pièce jointe détachée du devis.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
|
||||||
|
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
|
||||||
|
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal EstimateDto BuildPayload()
|
internal EstimateDto BuildPayload()
|
||||||
{
|
{
|
||||||
return new EstimateDto
|
return new EstimateDto
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
||||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||||
|
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||||
|
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
activity,
|
activity,
|
||||||
|
|
@ -162,9 +163,12 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
form,
|
form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
frontClient,
|
frontClient,
|
||||||
|
fsClient,
|
||||||
details,
|
details,
|
||||||
isReadOnly: false);
|
isReadOnly: false,
|
||||||
|
canAttach: false);
|
||||||
|
|
||||||
|
await vm.InitializeAsync().ConfigureAwait(true);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -243,7 +247,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
|
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||||
|
if (fsClient is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client fichiers indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,14 @@ public class HomePageViewModel : ViewModelBase
|
||||||
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
|
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
|
||||||
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
||||||
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||||
|
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
||||||
}
|
}
|
||||||
public IAsyncRelayCommand OpenBlogs { get; }
|
public IAsyncRelayCommand OpenBlogs { get; }
|
||||||
public IAsyncRelayCommand OpenActivities { get; }
|
public IAsyncRelayCommand OpenActivities { get; }
|
||||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||||
|
public IAsyncRelayCommand OpenMyFiles { get; }
|
||||||
|
|
||||||
private async Task OpenActivitiesAsync()
|
private async Task OpenActivitiesAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -101,6 +103,29 @@ public class HomePageViewModel : ViewModelBase
|
||||||
await app.PushPageAsync(vm);
|
await app.PushPageAsync(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
|
||||||
|
/// servie par le host Blogs via <c>api/v1/fs</c>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task OpenMyFilesAsync()
|
||||||
|
{
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
|
||||||
|
if (fsClient is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client fichiers indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new MyFilesViewModel(fsClient);
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
await app.PushPageAsync(vm);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Avalonia designer constructor. Builds a self-contained VM
|
/// Avalonia designer constructor. Builds a self-contained VM
|
||||||
/// with a freshly-constructed Settings so the XAML preview can
|
/// with a freshly-constructed Settings so the XAML preview can
|
||||||
|
|
|
||||||
229
src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
Normal file
229
src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PostIt.Helpers;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gestion de l'espace de stockage personnel (« My Files ») :
|
||||||
|
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
|
||||||
|
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
|
||||||
|
/// (fournisseur) ou à une demande (client) par référence : lorsque
|
||||||
|
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
|
||||||
|
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
|
||||||
|
/// puis revient à la page précédente.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
|
{
|
||||||
|
private readonly UserFilesApiClient _fs;
|
||||||
|
private readonly Action<long>? _onFilePicked;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sous-répertoire courant, relatif à la racine de l'espace perso
|
||||||
|
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string CurrentPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
|
||||||
|
? "Mes fichiers"
|
||||||
|
: $"Mes fichiers / {CurrentPath}";
|
||||||
|
|
||||||
|
public bool IsPickerMode => _onFilePicked is not null;
|
||||||
|
|
||||||
|
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string StatusMessage { get; set; } = "Prêt.";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||||
|
|
||||||
|
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
|
||||||
|
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
|
||||||
|
|
||||||
|
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
|
||||||
|
{
|
||||||
|
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
|
||||||
|
_onFilePicked = onFilePicked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||||
|
public MyFilesViewModel() : this(null!) { }
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
|
||||||
|
Files.Clear();
|
||||||
|
SubDirectories.Clear();
|
||||||
|
if (dir?.Files is not null)
|
||||||
|
foreach (var f in dir.Files) Files.Add(f);
|
||||||
|
if (dir?.SubDirectories is not null)
|
||||||
|
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
|
||||||
|
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenDirAsync(DirEntryDto? dir)
|
||||||
|
{
|
||||||
|
if (dir is null) return;
|
||||||
|
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
|
||||||
|
OnPropertyChanged(nameof(PathLabel));
|
||||||
|
OnPropertyChanged(nameof(CanGoUp));
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task UpAsync()
|
||||||
|
{
|
||||||
|
if (!CanGoUp) return;
|
||||||
|
CurrentPath = ParentPath(CurrentPath);
|
||||||
|
OnPropertyChanged(nameof(PathLabel));
|
||||||
|
OnPropertyChanged(nameof(CanGoUp));
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
|
||||||
|
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
|
||||||
|
{
|
||||||
|
if (uploads is null || uploads.Count == 0) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DeleteFileAsync(FileEntryDto? file)
|
||||||
|
{
|
||||||
|
if (file is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus($"Fichier supprimé.");
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DownloadFileAsync(FileEntryDto? file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Id is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
|
||||||
|
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PickFileAsync(FileEntryDto? file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Id is null)
|
||||||
|
{
|
||||||
|
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var callback = _onFilePicked;
|
||||||
|
if (callback is null)
|
||||||
|
{
|
||||||
|
// Mode gestion : le téléchargement est l'action par défaut.
|
||||||
|
await DownloadFileAsync(file).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(file.Id.Value);
|
||||||
|
await GoBackAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task BackAsync()
|
||||||
|
{
|
||||||
|
await GoBackAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task GoBackAsync()
|
||||||
|
{
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null) throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
await app.GoBackAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CombinePath(string current, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(current)) return name;
|
||||||
|
return $"{current}/{name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParentPath(string current)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
|
||||||
|
var idx = current.LastIndexOf('/');
|
||||||
|
return idx < 0 ? string.Empty : current[..idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:client="using:Yavsc.Api.Client"
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
x:Class="PostIt.Views.BillingQueryDetailsPage"
|
x:Class="PostIt.Views.BillingQueryDetailsPage"
|
||||||
x:DataType="vm:BillingQueryDetailsPageViewModel"
|
x:DataType="vm:BillingQueryDetailsPageViewModel"
|
||||||
|
|
@ -80,6 +81,53 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Pièces jointes (par référence à l'espace perso du client) -->
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="Pièces jointes"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Ajouter depuis My Files"
|
||||||
|
Command="{Binding AddQueryAttachmentCommand}"
|
||||||
|
IsVisible="{Binding CanAttach}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:AttachmentDto">
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#11000000"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="8"
|
||||||
|
Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,8,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Télécharger"
|
||||||
|
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Content="Détacher"
|
||||||
|
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Expander Header="Metadonnees techniques"
|
<Expander Header="Metadonnees techniques"
|
||||||
IsExpanded="False">
|
IsExpanded="False">
|
||||||
<Border BorderThickness="1"
|
<Border BorderThickness="1"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:client="using:Yavsc.Api.Client"
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
x:Class="PostIt.Views.EstimateEditionPage"
|
x:Class="PostIt.Views.EstimateEditionPage"
|
||||||
x:DataType="vm:EstimateEditionPageViewModel"
|
x:DataType="vm:EstimateEditionPageViewModel"
|
||||||
|
|
@ -133,6 +134,52 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10"
|
||||||
|
IsVisible="{Binding HasSent}">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="Pièces jointes"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Ajouter depuis My Files"
|
||||||
|
Command="{Binding AddAttachmentCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:AttachmentDto">
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#11000000"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="8"
|
||||||
|
Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Télécharger"
|
||||||
|
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Content="Détacher"
|
||||||
|
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,5 +34,9 @@
|
||||||
Command="{Binding OpenProviderEstimates}"
|
Command="{Binding OpenProviderEstimates}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
<Button Content="Mes fichiers"
|
||||||
|
Command="{Binding OpenMyFiles}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
90
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml
Normal file
90
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:client="using:Yavsc.Api.Client"
|
||||||
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
|
x:Class="PostIt.Views.MyFilesPage"
|
||||||
|
x:DataType="vm:MyFilesViewModel"
|
||||||
|
Header="Mes fichiers">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="3">
|
||||||
|
<TextBlock Text="{Binding PathLabel}"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold" />
|
||||||
|
<TextBlock Text="Espace de stockage personnel. Téléversez vos fichiers puis attachez-les à un devis ou une demande."
|
||||||
|
Opacity="0.7"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*" Margin="0,10,0,8">
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Content="Retour"
|
||||||
|
Command="{Binding BackCommand}" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Content="Remonter"
|
||||||
|
Command="{Binding UpCommand}"
|
||||||
|
IsEnabled="{Binding CanGoUp}" />
|
||||||
|
<Button Grid.Column="4"
|
||||||
|
Content="Téléverser…"
|
||||||
|
Click="Upload_Click"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="2">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<ItemsControl ItemsSource="{Binding SubDirectories}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:DirEntryDto">
|
||||||
|
<Button Content="📁 {Binding Name}"
|
||||||
|
Command="{Binding $parent[vm:MyFilesViewModel].OpenDirCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Files}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:FileEntryDto">
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="8"
|
||||||
|
Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextWrapping="NoWrap" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Joindre"
|
||||||
|
Command="{Binding $parent[vm:MyFilesViewModel].PickFileCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding $parent[vm:MyFilesViewModel].IsPickerMode}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Content="Télécharger"
|
||||||
|
Command="{Binding $parent[vm:MyFilesViewModel].DownloadFileCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Grid.Column="5"
|
||||||
|
Content="Supprimer"
|
||||||
|
Command="{Binding $parent[vm:MyFilesViewModel].DeleteFileCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
|
||||||
|
<postitControls:StatusBar Grid.Column="0"
|
||||||
|
DataContext="{Binding ActionStatus}" />
|
||||||
|
<ProgressBar Grid.Column="3"
|
||||||
|
Width="120"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsBusy}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
58
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml.cs
Normal file
58
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class MyFilesPage : ContentPage
|
||||||
|
{
|
||||||
|
public MyFilesPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Upload_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel is null || DataContext is not MyFilesViewModel vm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Choisir des fichiers à téléverser dans Mes fichiers",
|
||||||
|
AllowMultiple = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var uploads = new List<Yavsc.Api.Client.UploadFile>(files.Count);
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
await using var stream = await file.OpenReadAsync();
|
||||||
|
using var memory = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(memory);
|
||||||
|
uploads.Add(new Yavsc.Api.Client.UploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await vm.UploadAsync(uploads);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMimeType(string fileName)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
|
||||||
|
return ext switch
|
||||||
|
{
|
||||||
|
".png" => "image/png",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,5 +15,23 @@ namespace Yavsc.Blogspot
|
||||||
// BlogPost entity implements this getter by mapping its
|
// BlogPost entity implements this getter by mapping its
|
||||||
// ApplicationUser navigation to a BlogPostAuthorDto.
|
// ApplicationUser navigation to a BlogPostAuthorDto.
|
||||||
BlogPostAuthorDto? Author { get; }
|
BlogPostAuthorDto? Author { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this post is published. The server derives this
|
||||||
|
/// from the existence of a row in <c>BlogSpotPublication</c>
|
||||||
|
/// and serves it on the wire so clients (PostIt) can render
|
||||||
|
/// the current publication state without a follow-up request.
|
||||||
|
///
|
||||||
|
/// <para>This MUST be declared on the interface — not just on
|
||||||
|
/// the concrete <c>BlogPost</c> entity / <c>BlogPostDto</c> —
|
||||||
|
/// because the blog API's list endpoint returns
|
||||||
|
/// <c>IEnumerable<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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,17 @@
|
||||||
|
|
||||||
public DateTime CreationTime { get; set; }
|
public DateTime CreationTime { get; set; }
|
||||||
|
|
||||||
public DateTime LastModified { get; set; }
|
public DateTime LastModified { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant de la ligne <c>UploadedFile</c> associée, quand le
|
||||||
|
/// fichier a été téléversé via <c>api/v1/fs</c> (donc référencé en
|
||||||
|
/// base). <c>null</c> pour un fichier présent sur disque sans ligne
|
||||||
|
/// de métadonnées (fichier antérieur à la fonctionnalité, ou non
|
||||||
|
/// encore backfillé). C'est cette valeur que le client lourd
|
||||||
|
/// (PostIt) envoie pour attacher le fichier à un devis/demande.
|
||||||
|
/// </summary>
|
||||||
|
public long? Id { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
66
src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs
Normal file
66
src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Représentation d'un répertoire de l'espace de stockage personnel,
|
||||||
|
/// renvoyée par <c>GET api/v1/fs/{subdir}</c> (host Blogs). Ne dépend pas
|
||||||
|
/// des helpers serveur : c'est un pur DTO de désérialisation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserDirectoryDto
|
||||||
|
{
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? SubPath { get; set; }
|
||||||
|
public FileEntryDto[] Files { get; set; } = Array.Empty<FileEntryDto>();
|
||||||
|
public DirEntryDto[] SubDirectories { get; set; } = Array.Empty<DirEntryDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FileEntryDto
|
||||||
|
{
|
||||||
|
/// <summary>Identifiant de la ligne UploadedFile, pour l'attachement par référence.</summary>
|
||||||
|
public long? Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public long Size { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DirEntryDto
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Métadonnées d'une pièce jointe (fichier de l'espace perso rattaché à
|
||||||
|
/// un devis ou une demande), renvoyées par les endpoints d'attachement.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AttachmentDto
|
||||||
|
{
|
||||||
|
public long FileId { get; set; }
|
||||||
|
/// <summary>Chemin relatif dans l'espace perso du propriétaire.</summary>
|
||||||
|
public string? Path { get; set; }
|
||||||
|
public long Size { get; set; }
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
public string? OwnerId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Métadonnées d'un fichier reçu après un téléversement multipart vers
|
||||||
|
/// <c>POST api/v1/fs/{subdir}</c>. Porte notamment l'identifiant
|
||||||
|
/// <see cref="FileId"/> de la ligne UploadedFile créée côté serveur.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileReceivedDto
|
||||||
|
{
|
||||||
|
public long? FileId { get; set; }
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public long Length { get; set; }
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
public bool QuotaOffense { get; set; }
|
||||||
|
public bool Overridden { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contenu d'un fichier à téléverser (nom, octets, type MIME), à l'image
|
||||||
|
/// de <see cref="BlogUploadFile"/> mais pour l'espace perso générique.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UploadFile(string FileName, byte[] Content, string? ContentType);
|
||||||
|
|
@ -113,6 +113,41 @@ public sealed class EstimateApiClient
|
||||||
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste les pièces jointes (fichiers de l'espace perso du fournisseur)
|
||||||
|
/// rattachées au devis <paramref name="id"/> par référence
|
||||||
|
/// (<c>GET estimate/{id}/attachments</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<AttachmentDto>> GetAttachmentsAsync(long id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||||
|
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}/attachments"), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attache un fichier de l'espace perso du fournisseur au devis
|
||||||
|
/// <paramref name="id"/> par référence (<c>POST estimate/{id}/attachments</c>).
|
||||||
|
/// Réservé au fournisseur côté serveur.
|
||||||
|
/// </summary>
|
||||||
|
public Task AttachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
return _api.CallAsync(HttpMethod.Post, Absolute($"{PathPrefix}/{id}/attachments"),
|
||||||
|
body: new { fileId }, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Détache un fichier du devis <paramref name="id"/>
|
||||||
|
/// (<c>DELETE estimate/{id}/attachments/{fileId}</c>). Réservé au fournisseur.
|
||||||
|
/// </summary>
|
||||||
|
public Task DetachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}/attachments/{fileId}"), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
private Uri ResolveBusinessBaseAddress()
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,48 @@ public sealed class FrontOfficeApiClient
|
||||||
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste les pièces jointes (fichiers de l'espace perso du client)
|
||||||
|
/// rattachées à la demande <paramref name="queryId"/> par référence
|
||||||
|
/// (<c>GET front/query/{queryId}/attachments</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<AttachmentDto>> GetQueryAttachmentsAsync(string billingCode, long queryId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||||
|
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute(path), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attache un fichier de l'espace perso du client à sa demande
|
||||||
|
/// (<c>POST front/query/{queryId}/attachments</c>). Réservé au client.
|
||||||
|
/// </summary>
|
||||||
|
public Task AttachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||||
|
return _api.CallAsync(HttpMethod.Post, Absolute(path), body: new { fileId }, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Détache un fichier de la demande
|
||||||
|
/// (<c>DELETE front/query/{queryId}/attachments/{fileId}</c>). Réservé au client.
|
||||||
|
/// </summary>
|
||||||
|
public Task DetachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/attachments/{fileId}?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||||
|
return _api.CallAsync(HttpMethod.Delete, Absolute(path), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
private Uri ResolveBusinessBaseAddress()
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
|
|
||||||
112
src/Yavsc.Api.Client/UserFilesApiClient.cs
Normal file
112
src/Yavsc.Api.Client/UserFilesApiClient.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client HTTP de l'espace de stockage personnel
|
||||||
|
/// (<c>api/v1/fs</c>, servi par le host <c>Yavsc.Blogs</c>). Même forme
|
||||||
|
/// que <see cref="BlogApiClient"/> : le transport (URL de base, JSON,
|
||||||
|
/// Bearer, refresh silencieux sur 401) est délégué à
|
||||||
|
/// <see cref="IYavscApiClient"/> ; cette classe n'est qu'un mappeur
|
||||||
|
/// DTO↔chemin.
|
||||||
|
///
|
||||||
|
/// <para><b>Convention d'URL.</b> Le <c>pathPrefix</c> est relatif au
|
||||||
|
/// segment de version déjà porté par l'URL de base
|
||||||
|
/// (<c>…/api/v1/</c>) : <c>"fs"</c> résout en <c>…/api/v1/fs</c>, qui
|
||||||
|
/// correspond au <c>[Route(APIPrefix + "/fs")]</c> de
|
||||||
|
/// <c>FileSystemApiController</c>. Ne pas ré-inclure <c>api/</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserFilesApiClient
|
||||||
|
{
|
||||||
|
private const string PathPrefix = "fs";
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
private readonly Uri _baseAddress;
|
||||||
|
|
||||||
|
public UserFilesApiClient(IYavscApiClient api, string blogsBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
if (string.IsNullOrEmpty(blogsBaseAddress))
|
||||||
|
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
|
||||||
|
|
||||||
|
_baseAddress = new Uri(blogsBaseAddress);
|
||||||
|
_api.Http.BaseAddress = _baseAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste le contenu du sous-répertoire <paramref name="subdir"/> de
|
||||||
|
/// l'espace perso de l'utilisateur (<c>GET fs/{subdir}</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task<UserDirectoryDto> ListAsync(string? subdir = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var path = string.IsNullOrWhiteSpace(subdir) ? PathPrefix : $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||||
|
return _api.CallAsync<UserDirectoryDto>(HttpMethod.Get, path, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Téléverse des fichiers dans le sous-répertoire
|
||||||
|
/// <paramref name="subdir"/> (<c>POST fs/{subdir}</c> multipart).
|
||||||
|
/// Renvoie les métadonnées des fichiers reçus, dont l'identifiant
|
||||||
|
/// <see cref="FileReceivedDto.FileId"/> servant à l'attachement.
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<FileReceivedDto>> UploadAsync(string subdir, IReadOnlyCollection<UploadFile> files, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (files is null || files.Count == 0)
|
||||||
|
throw new ArgumentException("At least one file is required.", nameof(files));
|
||||||
|
|
||||||
|
var path = string.IsNullOrWhiteSpace(subdir)
|
||||||
|
? PathPrefix
|
||||||
|
: $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||||
|
|
||||||
|
return _api.CallAsync<List<FileReceivedDto>>(HttpMethod.Post, path, () => CreateMultipartContent(files), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supprime un fichier ou répertoire de l'espace perso
|
||||||
|
/// (<c>DELETE fs/{path}</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task DeleteAsync(string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
throw new ArgumentException("Path is required.", nameof(path));
|
||||||
|
return _api.CallAsync(HttpMethod.Delete, $"{PathPrefix}/{path.TrimStart('/')}", ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Télécharge une pièce jointe référencée par son identifiant
|
||||||
|
/// <see cref="FileReceivedDto.FileId"/> / <see cref="AttachmentDto.FileId"/>
|
||||||
|
/// (<c>GET fs/file/{fileId}</c>). Renvoie les octets bruts ; le serveur
|
||||||
|
/// (host Blogs) autorise l'appelant (propriétaire ou partie au
|
||||||
|
/// devis/demande auquel le fichier est rattaché).
|
||||||
|
/// </summary>
|
||||||
|
public Task<byte[]> DownloadFileAsync(long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
return _api.DownloadAsync(HttpMethod.Get, $"{PathPrefix}/file/{fileId}", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construit le corps <c>multipart/form-data</c> d'un téléversement :
|
||||||
|
/// une part <c>file</c> par fichier (type MIME retombant sur
|
||||||
|
/// <c>application/octet-stream</c>). Exposé <c>public</c> pour les
|
||||||
|
/// tests backend qui assertent la forme filaire.
|
||||||
|
/// </summary>
|
||||||
|
public static HttpContent CreateMultipartContent(IReadOnlyCollection<UploadFile> files)
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent();
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var fileContent = new ByteArrayContent(file.Content);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||||
|
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
|
||||||
|
content.Add(fileContent, "file", file.FileName);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
<Library>true</Library>
|
<Library>true</Library>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
303
src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs
Normal file
303
src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Api.Test.Fixtures;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the estimate attachment endpoints
|
||||||
|
/// (<c>GET/POST/DELETE api/v1/estimate/{id}/attachments</c>), which
|
||||||
|
/// attach a file from the provider's personal storage to an estimate
|
||||||
|
/// **by reference** (<see cref="EstimateAttachedFile"/>).
|
||||||
|
///
|
||||||
|
/// <para>Coverage mirrors the authorization contract documented on the
|
||||||
|
/// controller: only the provider (<c>estimate.OwnerId</c>) may attach or
|
||||||
|
/// detach, the file must belong to the caller, and listing is open to
|
||||||
|
/// both parties (provider/client) plus admin. A third party is refused.
|
||||||
|
/// The download of the bytes themselves is served by the Blogs host and
|
||||||
|
/// exercised in <c>Yavsc.Blogs.Tests</c>; these tests cover only the
|
||||||
|
/// metadata/relationship endpoints that live on the API host.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class EstimateAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly ApiWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public EstimateAttachmentsApiTests(ApiWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient NewClient(string subject = "alice", string scope = "api")
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.BaseAddress)
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the graph (seeding alice the provider and bob the client),
|
||||||
|
/// create an estimate owned by alice for bob, and seed two personal
|
||||||
|
/// <see cref="UploadedFile"/> rows: one owned by alice (the provider's
|
||||||
|
/// own file) and one owned by bob (a file that does NOT belong to the
|
||||||
|
/// provider, used to assert the ownership guard on attach).
|
||||||
|
/// </summary>
|
||||||
|
private (long estimateId, long aliceFileId, long bobFileId) Seed()
|
||||||
|
{
|
||||||
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
var estimate = new Estimate
|
||||||
|
{
|
||||||
|
ClientId = "bob",
|
||||||
|
OwnerId = "alice",
|
||||||
|
CommandType = BillingCodes.Rdv,
|
||||||
|
Title = "Devis prestation",
|
||||||
|
Description = "Devis de test",
|
||||||
|
AttachedFiles = new List<string>(),
|
||||||
|
AttachedGraphics = new List<string>(),
|
||||||
|
Bill = new List<CommandLine>
|
||||||
|
{
|
||||||
|
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
db.Estimates.Add(estimate);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var aliceFile = new UploadedFile
|
||||||
|
{
|
||||||
|
OwnerId = "alice",
|
||||||
|
Path = "provider-doc.pdf",
|
||||||
|
ContentType = "application/pdf",
|
||||||
|
Length = 2048,
|
||||||
|
};
|
||||||
|
var bobFile = new UploadedFile
|
||||||
|
{
|
||||||
|
OwnerId = "bob",
|
||||||
|
Path = "client-note.txt",
|
||||||
|
ContentType = "text/plain",
|
||||||
|
Length = 64,
|
||||||
|
};
|
||||||
|
db.UploadedFiles.AddRange(aliceFile, bobFile);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
return (estimate.Id, aliceFile.Id, bobFile.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CountAttachments(long estimateId)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
return db.EstimateAttachedFiles.Count(a => a.EstimateId == estimateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_attaching_own_file_creates_the_link()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||||
|
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
Assert.Equal(1, CountAttachments(estimateId));
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var link = Assert.Single(db.EstimateAttachedFiles.Where(a => a.EstimateId == estimateId));
|
||||||
|
Assert.Equal(aliceFileId, link.FileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Attaching_the_same_file_twice_is_idempotent()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var second = await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||||
|
Assert.Equal(1, CountAttachments(estimateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_provider_cannot_attach_to_an_estimate()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
// bob is the client of the estimate, not the provider.
|
||||||
|
using var http = NewClient("bob");
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Equal(0, CountAttachments(estimateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_cannot_attach_a_file_they_do_not_own()
|
||||||
|
{
|
||||||
|
var (estimateId, _, bobFileId) = Seed();
|
||||||
|
|
||||||
|
// alice is the provider, but the file belongs to bob.
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = bobFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Equal(0, CountAttachments(estimateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_detaches_a_file()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(1, CountAttachments(estimateId));
|
||||||
|
|
||||||
|
var response = await http.DeleteAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(0, CountAttachments(estimateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_provider_cannot_detach()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
var response = await bob.DeleteAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Equal(1, CountAttachments(estimateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_lists_attachments()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
await http.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||||
|
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||||
|
Assert.Equal(aliceFileId, item.GetProperty("fileId").GetInt64());
|
||||||
|
Assert.Equal("provider-doc.pdf", item.GetProperty("path").GetString());
|
||||||
|
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_can_list_attachments_as_a_party()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var alice = NewClient("alice");
|
||||||
|
await alice.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// bob is the client of the estimate: a party, may list.
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
var response = await bob.GetAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Third_party_cannot_list_attachments()
|
||||||
|
{
|
||||||
|
var (estimateId, aliceFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var alice = NewClient("alice");
|
||||||
|
await alice.PostAsJsonAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// carol is neither the provider nor the client.
|
||||||
|
using var carol = NewClient("carol");
|
||||||
|
var response = await carol.GetAsync(
|
||||||
|
$"/api/v1/estimate/{estimateId}/attachments",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Listing_an_unknown_estimate_returns_404()
|
||||||
|
{
|
||||||
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
"/api/v1/estimate/999999/attachments",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,6 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
|
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
|
||||||
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
|
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
|
||||||
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
|
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
|
||||||
private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
|
|
||||||
|
|
||||||
protected override int HttpsPort => 5104;
|
protected override int HttpsPort => 5104;
|
||||||
|
|
||||||
|
|
@ -168,25 +167,32 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
|
|
||||||
private static string BuildAdminConnectionString()
|
private static string BuildAdminConnectionString()
|
||||||
{
|
{
|
||||||
|
// The admin connection string (host + credentials) is supplied
|
||||||
|
// via environment, never embedded in source — committing a
|
||||||
|
// real dev password would leak it into git history. The Npgsql
|
||||||
|
// path is opt-in, so requiring the env var is fine: a missing
|
||||||
|
// value produces a clear failure rather than a silent fallback
|
||||||
|
// onto someone's local dev database.
|
||||||
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
|
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
|
||||||
var source = string.IsNullOrWhiteSpace(configured)
|
if (string.IsNullOrWhiteSpace(configured))
|
||||||
? DefaultDevelopmentConnectionString
|
throw new InvalidOperationException(
|
||||||
: configured;
|
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
|
||||||
|
+ "provider requires an admin PostgreSQL connection string "
|
||||||
|
+ "in that environment variable (e.g. "
|
||||||
|
+ "'Server=localhost;Port=5432;Database=postgres;"
|
||||||
|
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
|
||||||
|
|
||||||
var builder = new NpgsqlConnectionStringBuilder(source)
|
var builder = new NpgsqlConnectionStringBuilder(configured)
|
||||||
{
|
{
|
||||||
Pooling = false,
|
Pooling = false,
|
||||||
IncludeErrorDetail = true
|
IncludeErrorDetail = true
|
||||||
};
|
};
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(configured))
|
// The admin connection targets the maintenance database
|
||||||
{
|
// (postgres) so we can CREATE/DROP the dedicated test database;
|
||||||
|
// whatever database the env var names is overridden here.
|
||||||
|
if (string.IsNullOrWhiteSpace(builder.Database))
|
||||||
builder.Database = "postgres";
|
builder.Database = "postgres";
|
||||||
}
|
|
||||||
else if (string.IsNullOrWhiteSpace(builder.Database))
|
|
||||||
{
|
|
||||||
builder.Database = "postgres";
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.ToString();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
256
src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs
Normal file
256
src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Api.Test.Fixtures;
|
||||||
|
using Yavsc.Helpers;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Models.Workflow;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the query (demande) attachment endpoints
|
||||||
|
/// (<c>GET/POST/DELETE api/v1/front/query/{queryId}/attachments</c>),
|
||||||
|
/// which attach a file from the client's personal storage to their
|
||||||
|
/// <c>NominativeServiceCommand</c> **by reference**
|
||||||
|
/// (<see cref="QueryAttachedFile"/>).
|
||||||
|
///
|
||||||
|
/// <para>Coverage mirrors the controller contract: only the client
|
||||||
|
/// (the demande's <c>ClientId</c>) may attach or detach, the file must
|
||||||
|
/// belong to the caller, and listing is open to both parties
|
||||||
|
/// (client/provider) plus admin. A third party is refused.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly ApiWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public FrontOfficeQueryAttachmentsApiTests(ApiWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient NewClient(string subject = "bob", string scope = "api")
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.BaseAddress)
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seed an Rdv query from bob (client) to alice (provider) and two
|
||||||
|
/// personal <see cref="UploadedFile"/> rows: one owned by bob (the
|
||||||
|
/// client's own file) and one owned by alice (a file that does NOT
|
||||||
|
/// belong to the client, used to assert the ownership guard).
|
||||||
|
/// </summary>
|
||||||
|
private (long queryId, long bobFileId, long aliceFileId) Seed()
|
||||||
|
{
|
||||||
|
WorkflowHelpers.ConfigureBillingService();
|
||||||
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
|
||||||
|
|
||||||
|
var query = new RdvQuery
|
||||||
|
{
|
||||||
|
ActivityCode = "dev",
|
||||||
|
ClientId = "bob",
|
||||||
|
PerformerId = "alice",
|
||||||
|
Consent = true,
|
||||||
|
UserCreated = "bob",
|
||||||
|
UserModified = "bob",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
EventDate = DateTime.UtcNow.AddDays(1),
|
||||||
|
Location = location,
|
||||||
|
Reason = "Demande de devis",
|
||||||
|
Status = QueryStatus.InProgress,
|
||||||
|
Description = "Demande en attente",
|
||||||
|
};
|
||||||
|
db.RdvQueries.Add(query);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var bobFile = new UploadedFile
|
||||||
|
{
|
||||||
|
OwnerId = "bob",
|
||||||
|
Path = "demande-photo.jpg",
|
||||||
|
ContentType = "image/jpeg",
|
||||||
|
Length = 4096,
|
||||||
|
};
|
||||||
|
var aliceFile = new UploadedFile
|
||||||
|
{
|
||||||
|
OwnerId = "alice",
|
||||||
|
Path = "provider-spec.pdf",
|
||||||
|
ContentType = "application/pdf",
|
||||||
|
Length = 1024,
|
||||||
|
};
|
||||||
|
db.UploadedFiles.AddRange(bobFile, aliceFile);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
return (query.Id, bobFile.Id, aliceFile.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CountAttachments(long queryId)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
return db.QueryAttachedFiles.Count(a => a.CommandId == queryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string AttachUrl(long queryId) =>
|
||||||
|
$"/api/v1/front/query/{queryId}/attachments?billingCode=Rdv";
|
||||||
|
|
||||||
|
private string DetachUrl(long queryId, long fileId) =>
|
||||||
|
$"/api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=Rdv";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_attaching_own_file_creates_the_link()
|
||||||
|
{
|
||||||
|
var (queryId, bobFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var http = NewClient("bob");
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
AttachUrl(queryId),
|
||||||
|
new { fileId = bobFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||||
|
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
Assert.Equal(1, CountAttachments(queryId));
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var link = Assert.Single(db.QueryAttachedFiles.Where(a => a.CommandId == queryId));
|
||||||
|
Assert.Equal(bobFileId, link.FileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_cannot_attach_to_a_query()
|
||||||
|
{
|
||||||
|
var (queryId, bobFileId, _) = Seed();
|
||||||
|
|
||||||
|
// alice is the provider of the query; only the client attaches.
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
AttachUrl(queryId),
|
||||||
|
new { fileId = bobFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Equal(0, CountAttachments(queryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_cannot_attach_a_file_they_do_not_own()
|
||||||
|
{
|
||||||
|
var (queryId, _, aliceFileId) = Seed();
|
||||||
|
|
||||||
|
// bob is the client, but the file belongs to alice.
|
||||||
|
using var http = NewClient("bob");
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
AttachUrl(queryId),
|
||||||
|
new { fileId = aliceFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Equal(0, CountAttachments(queryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_detaches_a_file()
|
||||||
|
{
|
||||||
|
var (queryId, bobFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var http = NewClient("bob");
|
||||||
|
await http.PostAsJsonAsync(
|
||||||
|
AttachUrl(queryId),
|
||||||
|
new { fileId = bobFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(1, CountAttachments(queryId));
|
||||||
|
|
||||||
|
var response = await http.DeleteAsync(
|
||||||
|
DetachUrl(queryId, bobFileId),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(0, CountAttachments(queryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_cannot_detach()
|
||||||
|
{
|
||||||
|
var (queryId, bobFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
await bob.PostAsJsonAsync(
|
||||||
|
AttachUrl(queryId),
|
||||||
|
new { fileId = bobFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
using var alice = NewClient("alice");
|
||||||
|
var response = await alice.DeleteAsync(
|
||||||
|
DetachUrl(queryId, bobFileId),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Equal(1, CountAttachments(queryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provider_lists_attachments_as_a_party()
|
||||||
|
{
|
||||||
|
var (queryId, bobFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
await bob.PostAsJsonAsync(
|
||||||
|
AttachUrl(queryId),
|
||||||
|
new { fileId = bobFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// alice is the provider: a party, may list.
|
||||||
|
using var alice = NewClient("alice");
|
||||||
|
var response = await alice.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||||
|
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||||
|
Assert.Equal(bobFileId, item.GetProperty("fileId").GetInt64());
|
||||||
|
Assert.Equal("bob", item.GetProperty("ownerId").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Third_party_cannot_list_attachments()
|
||||||
|
{
|
||||||
|
var (queryId, bobFileId, _) = Seed();
|
||||||
|
|
||||||
|
using var bob = NewClient("bob");
|
||||||
|
await bob.PostAsJsonAsync(
|
||||||
|
AttachUrl(queryId),
|
||||||
|
new { fileId = bobFileId },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
using var carol = NewClient("carol");
|
||||||
|
var response = await carol.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
|
|
@ -237,6 +238,96 @@ namespace Yavsc.Controllers
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/v1/estimate/{id}/attachments
|
||||||
|
//
|
||||||
|
// Liste les fichiers de l'espace perso du fournisseur rattachés au
|
||||||
|
// devis, par référence (UploadedFile). Lecture ouverte aux deux
|
||||||
|
// parties (fournisseur/client) du devis et aux admin, comme
|
||||||
|
// GetEstimate : la contrepartie doit pouvoir voir et télécharger
|
||||||
|
// les pièces (le téléchargement des octets reste servi par le host
|
||||||
|
// Blogs, qui autorise via cette même table de jointure).
|
||||||
|
[HttpGet("{id}/attachments")]
|
||||||
|
public IActionResult GetAttachments(long id)
|
||||||
|
{
|
||||||
|
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||||
|
if (estimate == null) return NotFound();
|
||||||
|
if (!UserIsAdminOrInThese(estimate.ClientId, estimate.OwnerId))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var attached = _context.EstimateAttachedFiles
|
||||||
|
.Where(a => a.EstimateId == id)
|
||||||
|
.Select(a => new
|
||||||
|
{
|
||||||
|
fileId = a.FileId,
|
||||||
|
path = a.File.Path,
|
||||||
|
size = a.File.Length,
|
||||||
|
contentType = a.File.ContentType,
|
||||||
|
ownerId = a.File.OwnerId
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
return Ok(attached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/v1/estimate/{id}/attachments { fileId }
|
||||||
|
//
|
||||||
|
// Attache un fichier de l'espace perso du fournisseur au devis, par
|
||||||
|
// référence. Réservé au fournisseur (estimate.OwnerId == caller) :
|
||||||
|
// seul le fournisseur constitue le devis et y joint ses documents
|
||||||
|
// (dont les documents à faire signer par le client, per README).
|
||||||
|
// Le fichier doit appartenir au fournisseur (UploadedFile.OwnerId).
|
||||||
|
[HttpPost("{id}/attachments")]
|
||||||
|
public IActionResult AttachFile(long id, [FromBody] AttachFileRequest body)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||||
|
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||||
|
if (estimate == null) return NotFound();
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (body == null || body.FileId <= 0)
|
||||||
|
return BadRequest(new { error = "fileId required" });
|
||||||
|
|
||||||
|
var file = _context.UploadedFiles.FirstOrDefault(f => f.Id == body.FileId);
|
||||||
|
if (file == null) return BadRequest(new { error = "file not found" });
|
||||||
|
if (file.OwnerId != uid)
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var existing = _context.EstimateAttachedFiles
|
||||||
|
.FirstOrDefault(a => a.EstimateId == id && a.FileId == body.FileId);
|
||||||
|
if (existing != null) return Ok(new { estimateId = id, fileId = body.FileId });
|
||||||
|
|
||||||
|
_context.EstimateAttachedFiles.Add(new EstimateAttachedFile
|
||||||
|
{
|
||||||
|
EstimateId = id,
|
||||||
|
FileId = body.FileId
|
||||||
|
});
|
||||||
|
_context.SaveChanges(User.GetUserId());
|
||||||
|
return Ok(new { estimateId = id, fileId = body.FileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: api/v1/estimate/{id}/attachments/{fileId}
|
||||||
|
//
|
||||||
|
// Détache un fichier du devis. Réservé au fournisseur.
|
||||||
|
[HttpDelete("{id}/attachments/{fileId}")]
|
||||||
|
public IActionResult DetachFile(long id, long fileId)
|
||||||
|
{
|
||||||
|
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||||
|
if (estimate == null) return NotFound();
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var link = _context.EstimateAttachedFiles
|
||||||
|
.FirstOrDefault(a => a.EstimateId == id && a.FileId == fileId);
|
||||||
|
if (link == null) return NotFound();
|
||||||
|
_context.EstimateAttachedFiles.Remove(link);
|
||||||
|
_context.SaveChanges(User.GetUserId());
|
||||||
|
return Ok(new { estimateId = id, fileId });
|
||||||
|
}
|
||||||
|
|
||||||
private bool EstimateExists(long id)
|
private bool EstimateExists(long id)
|
||||||
{
|
{
|
||||||
return _context.Estimates.Count(e => e.Id == id) > 0;
|
return _context.Estimates.Count(e => e.Id == id) > 0;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using Yavsc.Abstract.Workflow;
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.ViewModels.FrontOffice;
|
using Yavsc.ViewModels.FrontOffice;
|
||||||
|
|
@ -366,6 +367,95 @@ namespace Yavsc.ApiControllers
|
||||||
|
|
||||||
private static bool HasSignature(QueryAcceptanceRequest? body)
|
private static bool HasSignature(QueryAcceptanceRequest? body)
|
||||||
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
|
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
|
||||||
|
|
||||||
|
// GET: api/v1/front/query/{queryId}/attachments?billingCode=…
|
||||||
|
//
|
||||||
|
// Liste les fichiers de l'espace perso du client rattachés à la
|
||||||
|
// demande, par référence (UploadedFile). Lecture ouverte aux deux
|
||||||
|
// parties (client/fournisseur) de la demande et aux admin ; le
|
||||||
|
// téléchargement des octets est servi par le host Blogs.
|
||||||
|
[HttpGet("query/{queryId}/attachments")]
|
||||||
|
public async Task<IActionResult> GetQueryAttachments(
|
||||||
|
string billingCode, long queryId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
|
if (error is not null) return error;
|
||||||
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
|
var attached = await dbContext.QueryAttachedFiles
|
||||||
|
.Where(a => a.CommandId == queryId)
|
||||||
|
.Select(a => new
|
||||||
|
{
|
||||||
|
fileId = a.FileId,
|
||||||
|
path = a.File.Path,
|
||||||
|
size = a.File.Length,
|
||||||
|
contentType = a.File.ContentType,
|
||||||
|
ownerId = a.File.OwnerId
|
||||||
|
})
|
||||||
|
.ToListAsync(token);
|
||||||
|
return Ok(attached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/v1/front/query/{queryId}/attachments?billingCode=… { fileId }
|
||||||
|
//
|
||||||
|
// Attache un fichier de l'espace perso du client à sa demande, par
|
||||||
|
// référence. Réservé au client (rôle Client) : c'est lui qui
|
||||||
|
// documente sa demande. Le fichier doit lui appartenir.
|
||||||
|
[HttpPost("query/{queryId}/attachments")]
|
||||||
|
public async Task<IActionResult> AttachQueryFile(
|
||||||
|
string billingCode, long queryId, [FromBody] AttachFileRequest? body, CancellationToken token)
|
||||||
|
{
|
||||||
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
|
if (error is not null) return error;
|
||||||
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
|
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (body == null || body.FileId <= 0)
|
||||||
|
return BadRequest(new { error = "fileId required" });
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
var file = await dbContext.UploadedFiles
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
|
||||||
|
if (file == null) return BadRequest(new { error = "file not found" });
|
||||||
|
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var existing = await dbContext.QueryAttachedFiles
|
||||||
|
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == body.FileId, token);
|
||||||
|
if (existing != null) return Ok(new { queryId, fileId = body.FileId });
|
||||||
|
|
||||||
|
dbContext.QueryAttachedFiles.Add(new QueryAttachedFile
|
||||||
|
{
|
||||||
|
CommandId = queryId,
|
||||||
|
FileId = body.FileId
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||||
|
return Ok(new { queryId, fileId = body.FileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=…
|
||||||
|
//
|
||||||
|
// Détache un fichier de la demande. Réservé au client.
|
||||||
|
[HttpDelete("query/{queryId}/attachments/{fileId}")]
|
||||||
|
public async Task<IActionResult> DetachQueryFile(
|
||||||
|
string billingCode, long queryId, long fileId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
|
if (error is not null) return error;
|
||||||
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
|
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var link = await dbContext.QueryAttachedFiles
|
||||||
|
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == fileId, token);
|
||||||
|
if (link == null) return NotFound();
|
||||||
|
dbContext.QueryAttachedFiles.Remove(link);
|
||||||
|
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||||
|
return Ok(new { queryId, fileId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal enum ActorRole { None, Provider, Client, Admin }
|
internal enum ActorRole { None, Provider, Client, Admin }
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
294
src/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal file
294
src/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Blogs.Tests.Fixtures;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Models.Relationship;
|
||||||
|
using Yavsc.Models.Workflow;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the personal-storage file endpoints
|
||||||
|
/// (<c>api/v1/fs</c>) served by <c>FileSystemApiController</c> on the
|
||||||
|
/// Blogs host.
|
||||||
|
///
|
||||||
|
/// <para>Two concerns:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description><b>Upload records metadata</b>: a multipart
|
||||||
|
/// <c>POST api/v1/fs</c> persists an <see cref="UploadedFile"/> row
|
||||||
|
/// owned by the caller and returns its <c>fileId</c>, so the client
|
||||||
|
/// can later attach that file to an estimate/query by
|
||||||
|
/// reference.</description></item>
|
||||||
|
/// <item><description><b>Download authorization</b>:
|
||||||
|
/// <c>GET api/v1/fs/file/{fileId}</c> serves the bytes from the
|
||||||
|
/// owner's personal space. The owner may always read; a party
|
||||||
|
/// (client or provider) of an estimate/query the file is attached
|
||||||
|
/// to may read; a third party is refused. The bytes live on the
|
||||||
|
/// Blogs host's disk — this is the cross-container reason the
|
||||||
|
/// download endpoint lives here and not on the API host.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Yavsc Blogs")]
|
||||||
|
public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly BlogsWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public FileSystemApiTests(BlogsWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient NewClient(string subject = "alice")
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) =>
|
||||||
|
Path.Combine(fixture.BlogFilesRoot, owner, fileName);
|
||||||
|
|
||||||
|
/// <summary>Write a physical file into the owner's personal space,
|
||||||
|
/// so <c>GetFile</c> can stream it back. Creates the directory if
|
||||||
|
/// needed.</summary>
|
||||||
|
private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content)
|
||||||
|
{
|
||||||
|
var path = PhysicalFile(fixture, owner, fileName);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllBytes(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long SeedUploadedFile(string ownerId, string path, string contentType, long length)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var file = new UploadedFile
|
||||||
|
{
|
||||||
|
OwnerId = ownerId,
|
||||||
|
Path = path,
|
||||||
|
ContentType = contentType,
|
||||||
|
Length = length,
|
||||||
|
};
|
||||||
|
db.UploadedFiles.Add(file);
|
||||||
|
db.SaveChanges();
|
||||||
|
return file.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
||||||
|
{
|
||||||
|
_fixture.ResetDatabase();
|
||||||
|
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||||
|
// once usage >= DiskQuota, and the controller skips the metadata
|
||||||
|
// row when QuotaOffense is set. A fresh user has quota 0.
|
||||||
|
_fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000);
|
||||||
|
|
||||||
|
var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage");
|
||||||
|
|
||||||
|
using var form = new MultipartFormDataContent();
|
||||||
|
var fileContent = new ByteArrayContent(payload);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||||
|
form.Add(fileContent, "file", "note.txt");
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||||
|
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||||
|
var fileId = item.GetProperty("fileId").GetInt64();
|
||||||
|
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
||||||
|
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
|
||||||
|
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
||||||
|
Assert.Equal("note.txt", row.Path);
|
||||||
|
Assert.Equal("text/plain", row.ContentType);
|
||||||
|
Assert.Equal(payload.Length, row.Length);
|
||||||
|
|
||||||
|
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
||||||
|
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
||||||
|
"Expected the uploaded file on disk under the owner's personal root");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFile_owner_can_download_their_own_file()
|
||||||
|
{
|
||||||
|
_fixture.ResetDatabase();
|
||||||
|
_fixture.SeedUser("alice");
|
||||||
|
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload");
|
||||||
|
WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes);
|
||||||
|
var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length);
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(bytes, downloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFile_refuses_a_third_party()
|
||||||
|
{
|
||||||
|
_fixture.ResetDatabase();
|
||||||
|
_fixture.SeedUser("alice");
|
||||||
|
_fixture.SeedUser("carol");
|
||||||
|
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice");
|
||||||
|
WritePhysicalFile(_fixture, "alice", "secret.txt", bytes);
|
||||||
|
var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length);
|
||||||
|
|
||||||
|
// carol is neither the owner nor a party to any estimate/query
|
||||||
|
// this file is attached to.
|
||||||
|
using var http = NewClient("carol");
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seed the graph needed to prove a party (the client of an
|
||||||
|
/// estimate the file is attached to) can download a file they do
|
||||||
|
/// not own: alice owns the file and is the provider; bob is the
|
||||||
|
/// client; the file is attached to the estimate by reference.
|
||||||
|
/// </summary>
|
||||||
|
private long SeedPartyDownloadGraph(out long estimateId, out string fileName)
|
||||||
|
{
|
||||||
|
_fixture.ResetDatabase();
|
||||||
|
_fixture.SeedUser("alice");
|
||||||
|
_fixture.SeedUser("bob");
|
||||||
|
|
||||||
|
fileName = "party-spec.pdf";
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes("party payload");
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
// Location + PerformerProfile(alice): Estimate.OwnerId is a FK
|
||||||
|
// to PerformerProfile.PerformerId, so the provider profile must
|
||||||
|
// exist before the estimate can be inserted.
|
||||||
|
var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 };
|
||||||
|
db.Locations.Add(location);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
db.Performers.Add(new PerformerProfile
|
||||||
|
{
|
||||||
|
PerformerId = "alice",
|
||||||
|
SIREN = "12345678901234",
|
||||||
|
OrganizationAddressId = location.Id,
|
||||||
|
ExerciseCountryCode = "fr",
|
||||||
|
AcceptNotifications = true,
|
||||||
|
AcceptPublicContact = true,
|
||||||
|
Active = true,
|
||||||
|
Rate = 5,
|
||||||
|
WebSite = "https://alice.dev",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var estimate = new Estimate
|
||||||
|
{
|
||||||
|
OwnerId = "alice",
|
||||||
|
ClientId = "bob",
|
||||||
|
CommandType = "Rdv",
|
||||||
|
Title = "Devis party test",
|
||||||
|
Description = "Devis",
|
||||||
|
AttachedFiles = new List<string>(),
|
||||||
|
AttachedGraphics = new List<string>(),
|
||||||
|
Bill = new List<CommandLine>
|
||||||
|
{
|
||||||
|
new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
db.Estimates.Add(estimate);
|
||||||
|
db.SaveChanges();
|
||||||
|
estimateId = estimate.Id;
|
||||||
|
|
||||||
|
var file = new UploadedFile
|
||||||
|
{
|
||||||
|
OwnerId = "alice",
|
||||||
|
Path = fileName,
|
||||||
|
ContentType = "application/pdf",
|
||||||
|
Length = bytes.Length,
|
||||||
|
};
|
||||||
|
db.UploadedFiles.Add(file);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
db.EstimateAttachedFiles.Add(new EstimateAttachedFile
|
||||||
|
{
|
||||||
|
FileId = file.Id,
|
||||||
|
EstimateId = estimate.Id,
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
// The bytes must exist on disk for GetFile to stream them.
|
||||||
|
WritePhysicalFile(_fixture, "alice", fileName, bytes);
|
||||||
|
return file.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFile_party_client_of_an_attached_estimate_can_download()
|
||||||
|
{
|
||||||
|
var fileId = SeedPartyDownloadGraph(out _, out _);
|
||||||
|
|
||||||
|
// bob is the client of the estimate the file is attached to:
|
||||||
|
// a party, authorized to read even though alice owns the file.
|
||||||
|
using var http = NewClient("bob");
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||||
|
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFile_party_provider_of_an_attached_estimate_can_download()
|
||||||
|
{
|
||||||
|
var fileId = SeedPartyDownloadGraph(out _, out _);
|
||||||
|
|
||||||
|
// alice is the owner too, so this also exercises the owner path;
|
||||||
|
// the provider of an attached estimate is authorized regardless.
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFile_unknown_id_returns_404()
|
||||||
|
{
|
||||||
|
_fixture.ResetDatabase();
|
||||||
|
_fixture.SeedUser("alice");
|
||||||
|
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
"/api/v1/fs/file/999999", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@ namespace Yavsc.Blogs.Controllers
|
||||||
{
|
{
|
||||||
using Yavsc.Attributes.Validation;
|
using Yavsc.Attributes.Validation;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
using Yavsc.Exceptions;
|
using Yavsc.Exceptions;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.Abstract.Helpers;
|
using Yavsc.Abstract.Helpers;
|
||||||
using Yavsc.Server.Models.FileSystem;
|
using Yavsc.Server.Models.FileSystem;
|
||||||
|
using Yavsc.ViewModels.UserFiles;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
[Authorize,Route(APIPrefix + "/fs")]
|
[Authorize,Route(APIPrefix + "/fs")]
|
||||||
|
|
@ -34,21 +38,55 @@ namespace Yavsc.Blogs.Controllers
|
||||||
this.siteSettings = siteSettings.Value;
|
this.siteSettings = siteSettings.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/v1/fs
|
||||||
[HttpGet()]
|
[HttpGet()]
|
||||||
public IActionResult Get()
|
public IActionResult Get()
|
||||||
{
|
{
|
||||||
return GetDir(null);
|
return GetDir(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/v1/fs/{subdir}
|
||||||
|
// Liste les fichiers du sous-répertoire personnel de l'utilisateur,
|
||||||
|
// en enrichissant chaque entrée de l'identifiant de métadonnées
|
||||||
|
// UploadedFile.Id quand une ligne existe (cf. POST ci-dessous).
|
||||||
[HttpGet("{*subdir}")]
|
[HttpGet("{*subdir}")]
|
||||||
public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="")
|
public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="")
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
||||||
// _logger.LogInformation($"listing files from {User.Identity.Name}{subdir}");
|
var uid = User.GetUserId();
|
||||||
var files = FileSystemHelpers.GetUserFiles(siteSettings, User.GetUserId(), subdir);
|
var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir);
|
||||||
|
EnrichWithFileIds(files, uid, subdir);
|
||||||
return Ok(files);
|
return Ok(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reporte l'UploadedFile.Id (quand il existe) sur chaque entrée
|
||||||
|
// retournée par la liste, pour que le client lourd puisse attacher
|
||||||
|
// le fichier à un devis/demande par référence.
|
||||||
|
void EnrichWithFileIds(UserDirectoryInfo dir, string ownerId, string subdir)
|
||||||
|
{
|
||||||
|
if (dir?.Files == null || dir.Files.Length == 0) return;
|
||||||
|
var relPaths = dir.Files
|
||||||
|
.Select(f => RelPath(subdir, f.Name))
|
||||||
|
.ToHashSet();
|
||||||
|
var byPath = dbContext.UploadedFiles
|
||||||
|
.Where(u => u.OwnerId == ownerId && relPaths.Contains(u.Path))
|
||||||
|
.ToDictionary(u => u.Path, u => u.Id);
|
||||||
|
foreach (var f in dir.Files)
|
||||||
|
{
|
||||||
|
var rel = RelPath(subdir, f.Name);
|
||||||
|
if (byPath.TryGetValue(rel, out var id)) f.Id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chemin relatif au racine de l'espace perso de l'utilisateur,
|
||||||
|
// tel que stocké dans UploadedFile.Path (sans segment utilisateur).
|
||||||
|
static string RelPath(string subdir, string fileName)
|
||||||
|
{
|
||||||
|
// Normalize with '/' for cross-platform consistency in DB.
|
||||||
|
if (string.IsNullOrWhiteSpace(subdir)) return fileName;
|
||||||
|
return $"{subdir.TrimStart('/')}/{fileName}";
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{*subdir}")]
|
[HttpPost("{*subdir}")]
|
||||||
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
|
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
|
||||||
{
|
{
|
||||||
|
|
@ -78,7 +116,38 @@ namespace Yavsc.Blogs.Controllers
|
||||||
foreach (var f in Request.Form.Files)
|
foreach (var f in Request.Form.Files)
|
||||||
{
|
{
|
||||||
var item = user.ReceiveUserFile(destDir, f);
|
var item = user.ReceiveUserFile(destDir, f);
|
||||||
dbContext.SaveChanges(User.GetUserId());
|
if (!item.QuotaOffense)
|
||||||
|
{
|
||||||
|
// Persiste une ligne de métadonnées UploadedFile,
|
||||||
|
// propriétaire = l'utilisateur courant, chemin relatif
|
||||||
|
// à son espace perso. Upsert sur (OwnerId, Path) pour
|
||||||
|
// le re-téléversement (même fichier écrasé sur disque).
|
||||||
|
var rel = RelPath(subdir, item.FileName);
|
||||||
|
var existing = dbContext.UploadedFiles
|
||||||
|
.FirstOrDefault(u => u.OwnerId == uid && u.Path == rel);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
var uploaded = new UploadedFile
|
||||||
|
{
|
||||||
|
OwnerId = uid,
|
||||||
|
Path = rel,
|
||||||
|
ContentType = f.ContentType,
|
||||||
|
Length = f.Length
|
||||||
|
};
|
||||||
|
dbContext.UploadedFiles.Add(uploaded);
|
||||||
|
dbContext.SaveChanges(User.GetUserId());
|
||||||
|
item.FileId = uploaded.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.ContentType = f.ContentType;
|
||||||
|
existing.Length = f.Length;
|
||||||
|
dbContext.SaveChanges(User.GetUserId());
|
||||||
|
item.FileId = existing.Id;
|
||||||
|
}
|
||||||
|
item.Length = f.Length;
|
||||||
|
item.ContentType = f.ContentType;
|
||||||
|
}
|
||||||
received.Add(item);
|
received.Add(item);
|
||||||
_logger.LogInformation($"Received '{item.FileName}'.");
|
_logger.LogInformation($"Received '{item.FileName}'.");
|
||||||
if (item.QuotaOffense)
|
if (item.QuotaOffense)
|
||||||
|
|
@ -88,6 +157,62 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return Ok(received);
|
return Ok(received);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/v1/fs/file/{fileId}
|
||||||
|
// Télécharge une pièce jointe référencée par son UploadedFile.Id.
|
||||||
|
// Autorise : le propriétaire du fichier, ou toute partie (client /
|
||||||
|
// fournisseur) d'un devis ou d'une demande auquel ce fichier est
|
||||||
|
// rattaché via EstimateAttachedFile / QueryAttachedFile. Les octets
|
||||||
|
// sont lus depuis l'espace perso du propriétaire, sur le disque de
|
||||||
|
// ce host Blogs (le host API ne possède pas ces octets).
|
||||||
|
[HttpGet("file/{fileId:long}")]
|
||||||
|
public IActionResult GetFile(long fileId)
|
||||||
|
{
|
||||||
|
var file = dbContext.UploadedFiles.FirstOrDefault(u => u.Id == fileId);
|
||||||
|
if (file == null) return NotFound(new { error = "file not found" });
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (!CanReadFile(file, uid))
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
|
var ownerUserName = dbContext.Users
|
||||||
|
.Where(u => u.Id == file.OwnerId)
|
||||||
|
.Select(u => u.UserName)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (string.IsNullOrEmpty(ownerUserName))
|
||||||
|
return NotFound(new { error = "owner not found" });
|
||||||
|
|
||||||
|
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
|
||||||
|
var fi = new FileInfo(physicalPath);
|
||||||
|
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||||
|
|
||||||
|
var contentType = string.IsNullOrWhiteSpace(file.ContentType)
|
||||||
|
? "application/octet-stream" : file.ContentType;
|
||||||
|
var stream = fi.OpenRead();
|
||||||
|
return File(stream, contentType, fi.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le propriétaire peut toujours lire son fichier ; sinon, il faut
|
||||||
|
// que le fichier soit rattaché à un devis ou une demande dont
|
||||||
|
// l'appelant est partie (client ou fournisseur). Les admin sont
|
||||||
|
// autorisés comme pour le reste du workflow.
|
||||||
|
bool CanReadFile(UploadedFile file, string uid)
|
||||||
|
{
|
||||||
|
if (User.IsInRole(Constants.AdminGroupName)) return true;
|
||||||
|
if (file.OwnerId == uid) return true;
|
||||||
|
|
||||||
|
if (dbContext.EstimateAttachedFiles.Any(a =>
|
||||||
|
a.FileId == file.Id &&
|
||||||
|
(a.Estimate.ClientId == uid || a.Estimate.OwnerId == uid)))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (dbContext.QueryAttachedFiles.Any(a =>
|
||||||
|
a.FileId == file.Id &&
|
||||||
|
(a.Command.ClientId == uid || a.Command.PerformerId == uid)))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
[Route("/api/fsc/addquota/{uname}/{len}")]
|
[Route("/api/fsc/addquota/{uname}/{len}")]
|
||||||
[Authorize("AdministratorOnly")]
|
[Authorize("AdministratorOnly")]
|
||||||
public IActionResult AddQuota(string uname, int len)
|
public IActionResult AddQuota(string uname, int len)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
|
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
|
@ -82,8 +83,17 @@ namespace Yavsc.Controllers
|
||||||
return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated });
|
return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Actions MVC scaffoldées (vues CRUD) ---------------------
|
||||||
|
// Ces actions ne font pas partie de la surface d'API documentée
|
||||||
|
// par Swagger. Elles sont exclues de l'ApiExplorer : sinon, comme
|
||||||
|
// le contrôleur porte [Route("~/api/v1/blogcomments")], leurs
|
||||||
|
// [HttpPost] sans template propre atterrissent sur la même route
|
||||||
|
// "POST api/v1/blogcomments" que l'action Post ci-dessus, et
|
||||||
|
// Swashbuckle lève une SwaggerGeneratorException (conflicting
|
||||||
|
// method/path) au démarrage de /swagger.
|
||||||
// GET: Comments
|
// GET: Comments
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var applicationDbContext = _context.Comment.Include(c => c.Post);
|
var applicationDbContext = _context.Comment.Include(c => c.Post);
|
||||||
|
|
@ -91,6 +101,7 @@ namespace Yavsc.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: Comments/Details/5
|
// GET: Comments/Details/5
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public async Task<IActionResult> Details(long? id)
|
public async Task<IActionResult> Details(long? id)
|
||||||
{
|
{
|
||||||
if (id == null)
|
if (id == null)
|
||||||
|
|
@ -109,6 +120,7 @@ namespace Yavsc.Controllers
|
||||||
|
|
||||||
// GET: Comments/Create (MVC form endpoint)
|
// GET: Comments/Create (MVC form endpoint)
|
||||||
[HttpGet("form")]
|
[HttpGet("form")]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public IActionResult Create()
|
public IActionResult Create()
|
||||||
{
|
{
|
||||||
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title");
|
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title");
|
||||||
|
|
@ -118,6 +130,7 @@ namespace Yavsc.Controllers
|
||||||
// POST: Comments/Create (MVC form endpoint)
|
// POST: Comments/Create (MVC form endpoint)
|
||||||
[HttpPost("form")]
|
[HttpPost("form")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public async Task<IActionResult> Create(Comment comment)
|
public async Task<IActionResult> Create(Comment comment)
|
||||||
{
|
{
|
||||||
comment.UserCreated = User.GetUserId();
|
comment.UserCreated = User.GetUserId();
|
||||||
|
|
@ -138,6 +151,7 @@ namespace Yavsc.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: Comments/Edit/5
|
// GET: Comments/Edit/5
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public async Task<IActionResult> Edit(long? id)
|
public async Task<IActionResult> Edit(long? id)
|
||||||
{
|
{
|
||||||
if (id == null)
|
if (id == null)
|
||||||
|
|
@ -157,6 +171,7 @@ namespace Yavsc.Controllers
|
||||||
// POST: Comments/Edit/5
|
// POST: Comments/Edit/5
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public async Task<IActionResult> Edit(Comment comment)
|
public async Task<IActionResult> Edit(Comment comment)
|
||||||
{
|
{
|
||||||
if (ModelState.IsValid)
|
if (ModelState.IsValid)
|
||||||
|
|
@ -171,6 +186,7 @@ namespace Yavsc.Controllers
|
||||||
|
|
||||||
// GET: Comments/Delete/5
|
// GET: Comments/Delete/5
|
||||||
[ActionName("Delete")]
|
[ActionName("Delete")]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public async Task<IActionResult> Delete(long? id)
|
public async Task<IActionResult> Delete(long? id)
|
||||||
{
|
{
|
||||||
if (id == null)
|
if (id == null)
|
||||||
|
|
@ -190,6 +206,7 @@ namespace Yavsc.Controllers
|
||||||
// POST: Comments/Delete/5
|
// POST: Comments/Delete/5
|
||||||
[HttpPost, ActionName("Delete")]
|
[HttpPost, ActionName("Delete")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public async Task<IActionResult> DeleteConfirmed(long id)
|
public async Task<IActionResult> DeleteConfirmed(long id)
|
||||||
{
|
{
|
||||||
Comment comment = await _context.Comment.SingleAsync(m => m.Id == id);
|
Comment comment = await _context.Comment.SingleAsync(m => m.Id == id);
|
||||||
|
|
|
||||||
4797
src/Yavsc.Org/Migrations/20260924150433_UserFileAttachments.Designer.cs
generated
Normal file
4797
src/Yavsc.Org/Migrations/20260924150433_UserFileAttachments.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
148
src/Yavsc.Org/Migrations/20260924150433_UserFileAttachments.cs
Normal file
148
src/Yavsc.Org/Migrations/20260924150433_UserFileAttachments.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Yavsc.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UserFileAttachments : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Path",
|
||||||
|
table: "UploadedFiles",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "ContentType",
|
||||||
|
table: "UploadedFiles",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OwnerId",
|
||||||
|
table: "UploadedFiles",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EstimateAttachedFiles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
FileId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
EstimateId = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EstimateAttachedFiles", x => new { x.FileId, x.EstimateId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EstimateAttachedFiles_Estimates_EstimateId",
|
||||||
|
column: x => x.EstimateId,
|
||||||
|
principalTable: "Estimates",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EstimateAttachedFiles_UploadedFiles_FileId",
|
||||||
|
column: x => x.FileId,
|
||||||
|
principalTable: "UploadedFiles",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "QueryAttachedFiles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
FileId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
CommandId = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_QueryAttachedFiles", x => new { x.FileId, x.CommandId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_QueryAttachedFiles_NominativeServiceCommands_CommandId",
|
||||||
|
column: x => x.CommandId,
|
||||||
|
principalTable: "NominativeServiceCommands",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_QueryAttachedFiles_UploadedFiles_FileId",
|
||||||
|
column: x => x.FileId,
|
||||||
|
principalTable: "UploadedFiles",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UploadedFiles_OwnerId",
|
||||||
|
table: "UploadedFiles",
|
||||||
|
column: "OwnerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EstimateAttachedFiles_EstimateId",
|
||||||
|
table: "EstimateAttachedFiles",
|
||||||
|
column: "EstimateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QueryAttachedFiles_CommandId",
|
||||||
|
table: "QueryAttachedFiles",
|
||||||
|
column: "CommandId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
|
||||||
|
table: "UploadedFiles",
|
||||||
|
column: "OwnerId",
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
|
||||||
|
table: "UploadedFiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EstimateAttachedFiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "QueryAttachedFiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_UploadedFiles_OwnerId",
|
||||||
|
table: "UploadedFiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OwnerId",
|
||||||
|
table: "UploadedFiles");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Path",
|
||||||
|
table: "UploadedFiles",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "ContentType",
|
||||||
|
table: "UploadedFiles",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1394,6 +1394,21 @@ namespace Yavsc.Migrations
|
||||||
b.ToTable("Estimates");
|
b.ToTable("Estimates");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("FileId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("EstimateId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("FileId", "EstimateId");
|
||||||
|
|
||||||
|
b.HasIndex("EstimateId");
|
||||||
|
|
||||||
|
b.ToTable("EstimateAttachedFiles");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
|
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
|
@ -1502,6 +1517,21 @@ namespace Yavsc.Migrations
|
||||||
b.UseTphMappingStrategy();
|
b.UseTphMappingStrategy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("FileId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("CommandId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("FileId", "CommandId");
|
||||||
|
|
||||||
|
b.HasIndex("CommandId");
|
||||||
|
|
||||||
|
b.ToTable("QueryAttachedFiles");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
|
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
|
@ -1674,16 +1704,23 @@ namespace Yavsc.Migrations
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("ContentType")
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<long>("Length")
|
b.Property<long>("Length")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("OwnerId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Path")
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerId");
|
||||||
|
|
||||||
b.ToTable("UploadedFiles");
|
b.ToTable("UploadedFiles");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3827,6 +3864,25 @@ namespace Yavsc.Migrations
|
||||||
b.Navigation("Query");
|
b.Navigation("Query");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EstimateId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Estimate");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
|
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
|
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
|
||||||
|
|
@ -3860,6 +3916,25 @@ namespace Yavsc.Migrations
|
||||||
b.Navigation("Regularization");
|
b.Navigation("Regularization");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CommandId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Command");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
|
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
|
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
|
||||||
|
|
@ -3952,6 +4027,15 @@ namespace Yavsc.Migrations
|
||||||
b.Navigation("Post");
|
b.Navigation("Post");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Yavsc.Models.ApplicationUser", "Owner")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OwnerId");
|
||||||
|
|
||||||
|
b.Navigation("Owner");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
|
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
style="background-image: url('@SiteSettings.Value.Banner');
|
style="background-image: url('@SiteSettings.Value.Banner');
|
||||||
background-position: 0%;
|
background-position: 0%;
|
||||||
background-attachment: fixed;">
|
background-attachment: fixed;">
|
||||||
<a class="navbar-brand" href="/">
|
<a class="navbar-brand logo" href="/">
|
||||||
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
|
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
|
||||||
@SiteSettings.Value.Title
|
@SiteSettings.Value.Title
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
|
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -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-color: var(--bs-card-bg);
|
||||||
background-clip: border-box;
|
background-clip: border-box;
|
||||||
border: var(--bs-card-border-width) solid var(--bs-card-border-color);
|
border: var(--bs-card-border-width) solid var(--bs-card-border-color);
|
||||||
border-radius: var(--bs-card-border-radius)
|
border-radius: var(--bs-card-border-radius);
|
||||||
|
padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card>hr {
|
.card>hr {
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -509,5 +509,9 @@ namespace Yavsc.Models
|
||||||
public DbSet<ModerationLog> ModerationLogs { get; set; }
|
public DbSet<ModerationLog> ModerationLogs { get; set; }
|
||||||
|
|
||||||
public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; }
|
public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; }
|
||||||
|
|
||||||
|
public DbSet<EstimateAttachedFile> EstimateAttachedFiles { get; set; }
|
||||||
|
|
||||||
|
public DbSet<QueryAttachedFile> QueryAttachedFiles { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/Yavsc.Server/Models/Billing/AttachFileRequest.cs
Normal file
13
src/Yavsc.Server/Models/Billing/AttachFileRequest.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace Yavsc.Models.Billing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Corps de requête pour attacher un fichier de l'espace perso à un
|
||||||
|
/// devis (fournisseur) ou à une demande (client), par référence à son
|
||||||
|
/// identifiant <see cref="Yavsc.Models.Blog.UploadedFile"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class AttachFileRequest
|
||||||
|
{
|
||||||
|
/// <summary>Identifiant du fichier référencé.</summary>
|
||||||
|
public long FileId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Yavsc.Server/Models/Billing/EstimateAttachedFile.cs
Normal file
35
src/Yavsc.Server/Models/Billing/EstimateAttachedFile.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
|
||||||
|
namespace Yavsc.Models.Billing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pièce jointe d'un devis, attachée **par référence** à un fichier
|
||||||
|
/// de l'espace de stockage personnel du fournisseur
|
||||||
|
/// (<see cref="UploadedFile"/>). Le fichier reste dans l'espace perso
|
||||||
|
/// du fournisseur (pas de duplication) ; seul le lien est persisté.
|
||||||
|
/// Le téléchargement est servi par le host Blogs, qui autorise
|
||||||
|
/// l'appelant (fournisseur ou client du devis) via cette table.
|
||||||
|
/// </summary>
|
||||||
|
[PrimaryKey(nameof(FileId), nameof(EstimateId))]
|
||||||
|
public class EstimateAttachedFile
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant du fichier référencé (partie de la clé composite).
|
||||||
|
/// </summary>
|
||||||
|
public long FileId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("FileId")]
|
||||||
|
public virtual UploadedFile File { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant du devis auquel le fichier est rattaché
|
||||||
|
/// (partie de la clé composite).
|
||||||
|
/// </summary>
|
||||||
|
public long EstimateId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("EstimateId")]
|
||||||
|
public virtual Estimate Estimate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Yavsc.Server/Models/Billing/QueryAttachedFile.cs
Normal file
36
src/Yavsc.Server/Models/Billing/QueryAttachedFile.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
|
|
||||||
|
namespace Yavsc.Models.Billing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pièce jointe d'une demande (NominativeServiceCommand), attachée
|
||||||
|
/// **par référence** à un fichier de l'espace de stockage personnel
|
||||||
|
/// du client (<see cref="UploadedFile"/>). Le fichier reste dans
|
||||||
|
/// l'espace perso du client (pas de duplication) ; seul le lien est
|
||||||
|
/// persisté. Le téléchargement est servi par le host Blogs, qui
|
||||||
|
/// autorise l'appelant (client ou fournisseur de la demande) via
|
||||||
|
/// cette table.
|
||||||
|
/// </summary>
|
||||||
|
[PrimaryKey(nameof(FileId), nameof(CommandId))]
|
||||||
|
public class QueryAttachedFile
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant du fichier référencé (partie de la clé composite).
|
||||||
|
/// </summary>
|
||||||
|
public long FileId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("FileId")]
|
||||||
|
public virtual UploadedFile File { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant de la demande à laquelle le fichier est rattaché
|
||||||
|
/// (partie de la clé composite).
|
||||||
|
/// </summary>
|
||||||
|
public long CommandId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("CommandId")]
|
||||||
|
public virtual NominativeServiceCommand Command { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
#nullable enable annotations
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -33,6 +34,19 @@ namespace Yavsc.Models.Blog
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value></value>
|
/// <value></value>
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Propriétaire du fichier dans l'espace de stockage personnel.
|
||||||
|
/// Renseigné pour les fichiers téléversés via <c>api/v1/fs</c>
|
||||||
|
/// (référencés par <see cref="EstimateAttachedFile"/> /
|
||||||
|
/// <see cref="QueryAttachedFile"/>). Reste <c>null</c> pour les
|
||||||
|
/// pièces jointes de blog historiques (<see cref="BlogAttachedFile"/>),
|
||||||
|
/// qui ne sont pas adressées par propriétaire.
|
||||||
|
/// </summary>
|
||||||
|
public string? OwnerId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("OwnerId")]
|
||||||
|
public virtual ApplicationUser? Owner { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[PrimaryKey(nameof(FileId), nameof(PostId))]
|
[PrimaryKey(nameof(FileId), nameof(PostId))]
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,24 @@ namespace Yavsc.Server.Models.FileSystem
|
||||||
public bool Overridden { get; set; }
|
public bool Overridden { get; set; }
|
||||||
|
|
||||||
public bool QuotaOffense { get; set; }
|
public bool QuotaOffense { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant de la ligne <see cref="Yavsc.Models.Blog.UploadedFile"/>
|
||||||
|
/// créée à l'enregistrement du fichier dans l'espace perso
|
||||||
|
/// (<c>api/v1/fs</c>). Nul si aucune métadonnée n'a été persistée.
|
||||||
|
/// </summary>
|
||||||
|
public long? FileId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Taille du fichier reçu (octets), pour le reflet côté métadonnées.
|
||||||
|
/// </summary>
|
||||||
|
public long Length { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type MIME du fichier reçu.
|
||||||
|
/// </summary>
|
||||||
|
public string ContentType { get; set; }
|
||||||
|
|
||||||
public string FullName { get => Path.Combine(DestDir, FileName); }
|
public string FullName { get => Path.Combine(DestDir, FileName); }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<Library>true</Library>
|
<Library>true</Library>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
-->
|
-->
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
15
yavsc.sln
15
yavsc.sln
|
|
@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Server.Tests", "src\Yavsc.Server.Tests\Yavsc.Server.Tests.csproj", "{899B7240-729A-48BC-A993-300A6CCFCBEA}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
@ -243,6 +245,18 @@ Global
|
||||||
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU
|
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU
|
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU
|
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
@ -265,5 +279,6 @@ Global
|
||||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
{021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
{021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||||
{49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F}
|
{49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F}
|
||||||
|
{899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue