Compare commits

...
Author SHA1 Message Date
a94a5a0497
fixes the default setup for PostIt.Android
Some checks failed
Forgejo Release / release (push) Successful in 6m39s
Dotnet build and test / build (pull_request) Failing after 9m34s
2026-09-26 01:51:12 +01:00
8c8ab70d38
REORG 2026-09-26 00:35:43 +01:00
f0792a844f
make test # also 2026-09-25 23:26:26 +01:00
26e4fde1e7
make test-postit 2026-09-25 23:25:03 +01:00
70f8b7986e
View the test results
All checks were successful
Dotnet build and test / build (pull_request) Successful in 17m5s
2026-09-25 23:05:16 +01:00
1d12166611
https://wiki.openstreetmap.org/wiki/Blocked_tiles
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Forgejo Release / release (push) Successful in 10m3s
2026-09-25 22:47:20 +01:00
5104cbc3f1
Status bar text is selectionnable
Some checks failed
Forgejo Release / release (push) Successful in 13m17s
Dotnet build and test / build (pull_request) Failing after 17m15s
2026-09-25 21:40:33 +01:00
7844449c99
Blogs: fix delete returning 40x despite the file being removed
DeleteUserDirOrFile (and DeleteUserFile) read FileInfo.Length AFTER
fi.Delete(), and FileInfo.Length throws FileNotFoundException once
the file is gone. The controller's catch turned that into a 400, so
the user saw "le fichier n'existe pas sur le serveur" while the delete
had in fact succeeded. Capture the length before deleting.

Adds FileSystemApiTests.Delete_fs_removes_the_file_and_returns_200
(asserts 200 + file gone from disk). 8/8 FileSystemApiTests pass.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 21:33:13 +01:00
f1a17cd8fe
CHANGELOG: add 1.0.8-rc18 section
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 21:21:18 +01:00
4554886e33
PostIt: fix grayed/inert file-row action buttons (wrong $parent binding)
The action buttons inside ItemsControl item templates (Télécharger /
Supprimer / Joindre on MyFiles; download/detach on the attachment
lists of BillingQueryDetailsPage and EstimateEditionPage) were bound
with $parent[vm:XxxViewModel].Cmd, which searches for an ancestor
CONTROL of type XxxViewModel — a viewmodel, not a control — so the
binding resolved to null and the Button stayed disabled (grayed) and
inert on click. Switch to the working CirclesPage/EstimateListPage
syntax: $parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd.

Adds MyFilesPageHeadlessTests: mounts MyFilesPage via NavigationPage
+ Window.Show() and asserts each row button's Command is bound,
armed (CanExecute), and fires the API call on click. 136/136 pass.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 21:17:55 +01:00
a307885310
Blogs: list personal files under the user's login, not the sub claim
GET api/v1/fs passed User.GetUserId() (the "sub" claim — a GUID in
production) to GetUserFiles, so the listing looked under
{Blog}/{sub-GUID}. But the personal-storage tree is keyed by the
user's login everywhere else: EnsureDestinationDirectory writes under
{Blog}/{name-claim}, the download endpoint and avatar paths use
user.UserName. So once the upload path was fixed (previous commit),
files landed under {Blog}/{login} while the listing still looked under
{Blog}/{GUID} — an empty tree — hence the PostIt "Mes fichiers" page
listing nothing.

GetDir now resolves the user from the DB (as Post already does) and
lists under user.UserName. UploadedFile.OwnerId stays "sub", so
EnrichWithFileIds still queries by uid.

Tests:
- TestTokenIssuer.Issue gains an optional `name` param (defaults to
  subject, preserving existing behavior) so a test token can mirror
  production where sub (GUID) ≠ name (login).
- FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login
  decouples sub from the login, uploads under the login, and asserts
  GET /api/v1/fs returns the file (and the subject-named directory does
  NOT exist). Verified to fail when the controller uses uid for the
  listing.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:50:58 +01:00
d8c13c81d7
Server: wire ProfileService so access tokens carry the name claim
The custom ProfileService (Yavsc.Services.ProfileService) was never
registered with IdentityServer, so IS8 fell back to its default
profile service — which only emits claims listed in each API
resource's UserClaims (RequestedClaimTypes). "name" was never among
them, so every access token lacked "name" and GetUserName() resolved
to null on all bearer resource hosts (Blogs/Api): the MyFiles upload
500 and the unreachable User.Identity.Name. The class was dead code;
all prior tweaks to it (FullName→UserName) and to ServiceExtensions
(MapInboundClaims=false) were necessary but insufficient until it was
actually wired in.

- HostingExtensions: AddProfileService<ProfileService>() after
  AddAspNetIdentity<ApplicationUser>().
- ProfileService: emit name=user.UserName, email, roles for any user
  grant whose client has "profile" in AllowedScopes, independent of
  the caller (the old ParsedScopes gate only fired for the id-token
  caller, never the access-token caller).
- ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name,
  MapInboundClaims=false (keep short claim names as-emitted).
- FileSystemHelpers: EnsureDestinationDirectory uses GetUserName()
  (the "name" claim) instead of User.Identity.Name for the per-user
  storage root.

Tests:
- TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror
  production shape (fixes FileSystemApiTests upload 500).
- Remoting: PasswordToken_carries_name_claim_equal_to_login mints a
  real JWT via the IS token endpoint (password grant, openid profile)
  and asserts the access token carries name=login and GetUserName()
  resolves to it.
- WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the
  inverse of Duende); allow openid+profile on the test client; re-seed
  the openid/profile identity resources in ReseedAuthTestData (a
  ResetAndMigrateDatabase wipes the shared in-memory store and previously
  left them gone → invalid_scope).
- BillingServiceTests: [Collection("Yavsc Server")] to serialize with
  the host build that mutates the static BillingService/Config
  registries (pre-existing parallel race that the new test's timing
  surfaced).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
8d2d6754f6
PostIt: negotiate HTTP/3 (QUIC) for Yavsc API calls
Set DefaultRequestVersion = 3.0 and DefaultVersionPolicy =
RequestVersionOrLower on the shared YavscApiClient.Http instance, so all
Yavsc.Api.Client traffic prefers HTTP/3 against the nginx edge vhosts
(listen 443 quic ; http3 on ; Alt-Svc) and falls back to h2/h1.1 where
QUIC isn't available (WASM fetch, Android/desktop without libmsquic).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 19:12:47 +01:00
103 changed files with 864 additions and 159 deletions

View file

@ -50,9 +50,20 @@ jobs:
echo "🏗️ Building the solution..."
cd /src/_src && dotnet build --verbosity normal
- name: Test
artifacts:
name: test-results
path: test-results.html
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \
--report-xunit \
--report-xunit-html \
--report-xunit-html-filename "test-results.html"
TEST_EXIT_CODE=$?
echo "Test exit code: $TEST_EXIT_CODE"
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
exit $TEST_EXIT_CODE
else
echo "✅ All tests passed successfully."
fi

5
.gitignore vendored
View file

@ -45,7 +45,6 @@ DataDir/
# gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json
/test-report.html
/test-report.xml
# Exclude test reports (local test report, not for the repo)
/test-reports

8
.vscode/launch.json vendored
View file

@ -22,11 +22,9 @@
},
{
"name": "Android Debug",
"type": "mono",
"preLaunchTask": "run-debug-android",
"request": "attach",
"address": "localhost",
"port": 55555
"type": "maui",
"request": "launch",
"project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj"
},
{
"name": "Android Attach - Debug",

View file

@ -45,5 +45,6 @@
]
}
},
"dotnet.defaultSolution": "yavsc.sln"
"dotnet.defaultSolution": "yavsc.sln",
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln"
}

6
.vscode/tasks.json vendored
View file

@ -4,9 +4,13 @@
"tasks": [
{
"label": "run-debug-android",
"label": "build-debug-android",
"command": "dotnet",
"detail": "Build the PostIt Android project in debug mode",
"type": "shell",
"runOptions": {
},
"problemMatcher": ["$msCompile"],
"options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",

View file

@ -1,5 +1,101 @@
# Changelog
## [1.0.8-rc18] - unstable
Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
(arbre de stockage indexé par le login, pas par le `sub`), boutons
d'action des listes réparés (binding `$parent`), revendication `name`
enfin présente dans les access tokens (branchement du `ProfileService`),
et négociation HTTP/3 côté client PostIt.
### Added
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
desktop sans libmsquic).
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
(`CanExecute`) et déclenche l'appel API au clic.
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
mint un vrai JWT via le token endpoint IdentityServer (password grant,
`openid profile`) et asserte que l'access token porte `name`=login et
que `GetUserName()` résout vers ce login.
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
découple `sub` et login, téléverse sous le login, et asserte que
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
le `sub` n'existe pas).
### Changed
* [Yavsc.Org] Branchement du `ProfileService` custom
(`AddProfileService<ProfileService>()` après
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
IdentityServer8 retombait sur son profile service par défaut, qui
n'émet que les claims listés dans les `UserClaims` des API resources.
`name` n'en faisait jamais partie, donc tout access token était sans
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
`User.Identity.Name` injoignable. Le service émet désormais
`name`=`user.UserName`, `email` et les rôles pour tout client dont
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
courts de claims tels qu'émis).
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
la racine de stockage par utilisateur.
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
de test ; réensemencement des identity resources openid/profile dans
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
in-memory partagé et les laissait absentes → `invalid_scope`).
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
(défaut = subject) pour refléter la forme production où
`sub` (GUID) ≠ `name` (login).
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
sérialiser avec le build d'hôte qui mute les registres statiques
`BillingService`/`Config` (race parallèle pré-existante).
### Fixed
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
stockage perso est indexé par le login partout ailleurs
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
le endpoint de téléchargement et les avatars utilisent
`user.UserName`). Une fois l'upload corrigé, les fichiers
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
l'utilisateur en base et liste sous `user.UserName`.
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
interpole toujours par `uid`.
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
pas un contrôle — donc résolvait à `null` et le `Button` restait
désactivé (grisé) et inerte au clic. Corrigé en
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
pas sélectionnables/copiables : le texte était un `TextBlock` (non
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
renvoyait un 40x alors que le fichier était bien supprimé du disque.
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
du contrôleur transformait cette exception en 400, d'où le message
perçu « le fichier n'existe pas sur le serveur » tandis que la
suppression avait réussi. La taille est désormais capturée avant la
suppression.
* PostIt default settings
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
@ -509,6 +605,8 @@ the ACL now comes along with the article,
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7

View file

@ -21,7 +21,30 @@ src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
test:
ASPNETCORE_ENVIRONMENT=Development dotnet test
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "test-results.html"
test-postit:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "postit-test-results.html" \
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
Api-backend-test:
Blogs-backend-test:
Org-backend-test:
%-backend-test:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "$*-backend-test-results.html" \
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
watch:
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj

View file

@ -27,4 +27,14 @@
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
extension's injected HotReload agent (references Microsoft.Maui.*).
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
this file's item groups, so the removal must run in a target. -->
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
<ItemGroup>
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
</ItemGroup>
</Target>
</Project>

View file

@ -0,0 +1,208 @@
using System.Net.Http;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless UI coverage for the "Mes fichiers" page
/// (<see cref="MyFilesPage"/>). The user reported that once a file
/// is listed, "l'item est inerte au click, et ses boutons restent
/// grisés, désactivés" — neither the row nor its action buttons
/// (Télécharger / Supprimer) respond to a click.
///
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
/// the Avalonia headless platform with a stub
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
/// realised visual tree to find the row's action buttons and
/// assert the two things that would silently break and reproduce
/// the "grayed / inert" symptom:</para>
/// <list type="number">
/// <item><description>The <c>Command</c> binding
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
/// resolved to a non-null command — a null command leaves the
/// button disabled (grayed) and click-silent.</description></item>
/// <item><description>The command is <b>armed</b>
/// (<c>CanExecute</c> true) for the listed file — an
/// <c>AsyncRelayCommand</c> with
/// <c>AllowConcurrentExecutions=false</c> (the
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
/// default) disables itself while running; a button stuck
/// "running" is exactly the grayed-and-inert report.</description></item>
/// <item><description>Executing the command actually invokes the
/// file download / delete on the API client — the row
/// "activates" on click instead of being inert.</description></item>
/// </list>
/// <para>The OS save picker is absent under headless, so
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
/// download command's <c>catch</c> sets an error status — but the
/// API download is awaited <em>before</em> the picker, so the call
/// is recorded regardless. We do not <c>await</c> the command's
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
/// (platform-dependent) picker.</para>
/// </summary>
public class MyFilesPageHeadlessTests
{
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
{
var api = new StubFsApi();
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
var vm = new MyFilesViewModel(fsClient);
var page = new MyFilesPage { DataContext = vm };
return (page, vm, api);
}
/// <summary>
/// Mount the page the way the app does — pushed onto a
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
/// (a <see cref="ContentPage"/> realizes its content template via
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
/// listing a single file by awaiting
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
/// <see cref="MyFilesViewModel.Files"/> before the first layout
/// pass lets the <c>ItemsControl</c> realise its item templates
/// under headless.
/// </summary>
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
{
var (page, vm, api) = CreatePage();
await vm.InitializeAsync();
var nav = new NavigationPage();
nav.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Content = nav };
window.Show();
// Let bindings / the ItemsControl item templates apply.
await Task.Delay(50);
return (page, vm, api);
}
/// <summary>
/// Find the first action button in a file row whose content
/// matches <paramref name="content"/>. The file rows live in an
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
/// walk the visual tree.
/// </summary>
private static Button? FindRowButton(MyFilesPage page, string content)
=> page.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content is string s && s == content);
[AvaloniaFact]
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
// Sanity: the listing populated a file row.
Assert.NotEmpty(vm.Files);
Assert.NotNull(vm.Files[0].Id);
var downloadBtn = FindRowButton(page, "Télécharger");
Assert.NotNull(downloadBtn);
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
var param = downloadBtn.CommandParameter;
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
"Télécharger must be armed (CanExecute true) for a listed file.");
// Act: click → fire the command.
downloadBtn.Command.Execute(param);
// Assert: the row activated — the API download was invoked
// (it is awaited before the headless-absent save picker).
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
}
[AvaloniaFact]
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
Assert.NotEmpty(vm.Files);
var deleteBtn = FindRowButton(page, "Supprimer");
Assert.NotNull(deleteBtn);
Assert.NotNull(deleteBtn!.Command);
var param = deleteBtn.CommandParameter;
Assert.True(deleteBtn.Command!.CanExecute(param),
"Supprimer must be armed (CanExecute true) for a listed file.");
deleteBtn.Command.Execute(param);
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
}
private sealed class StubFsApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? 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 (typeof(T) == typeof(UserDirectoryDto))
{
var dir = new UserDirectoryDto
{
UserName = "alice",
Files = new[]
{
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
}
};
return Task.FromResult((T)(object)dir);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,35 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// The <see cref="StatusBar"/> surfaces the page's action status
/// (success / warning / error message). The user reported being
/// unable to copy these messages — the status text was a
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
/// now a <see cref="SelectableTextBlock"/> so the message can be
/// selected and copied (Ctrl+C / context menu). This test pins that:
/// if the message control regresses to a plain <c>TextBlock</c>,
/// selectability is lost and the test fails.
/// </summary>
public class StatusBarTests
{
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
var message = bar.GetVisualDescendants()
.OfType<SelectableTextBlock>()
.FirstOrDefault();
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
}

View file

@ -13,7 +13,10 @@
FontWeight="Bold"
Foreground="{Binding Foreground}"
VerticalAlignment="Top"/>
<TextBlock Text="{Binding Message}"
<!-- SelectableTextBlock so the user can select + copy the
status message (TextBlock is not selectable in
Avalonia). Keeps Text/TextWrapping/Foreground. -->
<SelectableTextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}"
TextWrapping="Wrap"
VerticalAlignment="Center"/>

View file

@ -49,6 +49,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_bearer = new BearerTokenHandler(this);
Http = new HttpClient(_bearer, disposeHandler: true);
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
// fetch, et Android si libmsquic n'est pas disponible. Tous les
// clients Yavsc.Api.Client partagent cette même instance `Http`.
Http.DefaultRequestVersion = new Version(3, 0);
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
_tokens = store.Load();
}

View file

@ -113,13 +113,13 @@
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DetachQueryFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
IsVisible="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).CanAttach}" />
</Grid>
</Border>
</DataTemplate>

View file

@ -167,11 +167,11 @@
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DownloadAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DetachFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>

View file

@ -15,6 +15,9 @@ using PostIt.Services;
using PostIt.ViewModels.Commands;
namespace PostIt.Views.Commands;
using Mapsui.Tiling; // Ensure you have this namespace
public partial class RdvPage : ContentPage
{
@ -44,6 +47,16 @@ public partial class RdvPage : ContentPage
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
InitializeComponent();
InitializeMap();
// ... inside your Map setup logic ...
string version = GetType().Assembly.GetName().Version?.ToString() ?? "1.1";
// 1. Create a unique user-agent string for your app (e.g., "AppName/Version (ContactEmail)")
string myCustomUserAgent = $"PostIt/{version} (paul@pschneider.fr)";
// 2. Pass your custom user agent into the OpenStreetMap helper
var osmLayer = OpenStreetMap.CreateTileLayer(myCustomUserAgent);
// 3. add your newly identified layer
_locationMap.Map.Layers.Add(osmLayer);
}
private void InitializeComponent()

View file

@ -3,6 +3,11 @@
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.SessionStatusBanner"
x:DataType="vm:SessionStatusViewModel">
<Design.DataContext>
<vm:SessionStatusViewModel />
</Design.DataContext>
<Border DockPanel.Dock="Top"
BorderBrush="#B0BEC5"
BorderThickness="0,0,0,1"

View file

@ -36,7 +36,7 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:DirEntryDto">
<Button Content="📁 {Binding Name}"
Command="{Binding $parent[vm:MyFilesViewModel].OpenDirCommand}"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).OpenDirCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"
Margin="0,0,0,4" />
@ -59,16 +59,16 @@
TextWrapping="NoWrap" />
<Button Grid.Column="1"
Content="Joindre"
Command="{Binding $parent[vm:MyFilesViewModel].PickFileCommand}"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).PickFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:MyFilesViewModel].IsPickerMode}" />
IsVisible="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).IsPickerMode}" />
<Button Grid.Column="3"
Content="Télécharger"
Command="{Binding $parent[vm:MyFilesViewModel].DownloadFileCommand}"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DownloadFileCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="5"
Content="Supprimer"
Command="{Binding $parent[vm:MyFilesViewModel].DeleteFileCommand}"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DeleteFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>

View file

@ -1,6 +1,6 @@
{
"Authentication": {
"Authority": "https://localhost:5001",
"Authority": "https://yavsc.pschneider.fr",
"ClientId": "postit",
"Scopes": [
"blogs",
@ -9,13 +9,8 @@
"RedirectUri": "postit://callback"
},
"DarkMode": true,
"BlogsApiUrl": "https://localhost:5003/api/v1/",
"ApiUrl": "https://localhost:5005/api/v1/",
"BlogsApiUrl": "https://blogs.pschneider.fr/api/v1/",
"ApiUrl": "https://api.pschneider.fr/api/v1/",
"SearchText": "",
"ProviderOngoingRequestsSortOption": "",
"Loaded": true,
"IsDirty": true,
"CanNavigateNext": false,
"CanNavigatePrevious": true,
"SaveCommand": {}
"ProviderOngoingRequestsSortOption": ""
}

View file

@ -54,7 +54,18 @@ namespace Yavsc.Blogs.Controllers
{
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
var uid = User.GetUserId();
var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir);
// The personal-storage tree is keyed by the user's login
// (UserName): EnsureDestinationDirectory writes under
// {Blog}/{UserName}, the download endpoint and avatar paths
// use user.UserName too. GetUserId() returns the "sub" claim
// (a GUID in production), so passing uid here made the listing
// look under {Blog}/{sub-GUID} — an empty tree, hence the
// "Mes fichiers" page listing nothing. Resolve the login and
// list under it. OwnerId on UploadedFile stays "sub" (uid),
// so EnrichWithFileIds still queries by uid.
var user = dbContext.Users.SingleOrDefault(u => u.Id == uid);
if (user == null) return NotFound();
var files = FileSystemHelpers.GetUserFiles(siteSettings, user.UserName, subdir);
EnrichWithFileIds(files, uid, subdir);
return Ok(files);
}

View file

@ -26,10 +26,10 @@
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5002"
"Url": "http://blogs.pschneider.fr"
},
"Https": {
"Url": "https://localhost:5003"
"Url": "https://blogs.pschneider.fr"
}
}
}

View file

@ -368,6 +368,17 @@ public static class HostingExtensions
})
.AddAspNetIdentity<ApplicationUser>()
// Register the custom ProfileService so IdentityServer actually
// calls it instead of the default UserProfileService. Without
// this, the class is dead code: IS8 falls back to the default,
// which only emits claims listed in each API resource's
// UserClaims (RequestedClaimTypes) — "name" is never among
// them, so every access token lacks "name" and the Blogs/Api
// bearer hosts get GetUserName() == null (the MyFiles upload
// 500). Our ProfileService emits name/email/roles for any
// user grant whose client is allowed "profile", independent
// of the caller, so the access token carries the login.
.AddProfileService<ProfileService>()
.AddClientStore<ClientStore>()
.AddClientConfigurationValidator<DefaultClientConfigurationValidator>()
.AddCorsPolicyService<CorsPolicyService>()

View file

@ -1,19 +1,17 @@
#nullable enable annotations
using System.Security.Claims;
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.Http;
using ImageMagick;
using Yavsc.Models;
using Yavsc.Models.Streaming;
using Yavsc.ViewModels;
using Microsoft.AspNetCore.Http;
using Yavsc.Exceptions;
using Yavsc.Abstract.Helpers;
using ImageMagick;
using Yavsc.Server.Models.FileSystem;
using Yavsc.ViewModels.UserFiles;
namespace Yavsc.Server.Helpers
{
public static class FileSystemHelpers
@ -46,7 +44,7 @@ namespace Yavsc.Server.Helpers
this ClaimsPrincipal user,
string subpath, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.Identity.Name);
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
if (!string.IsNullOrWhiteSpace(subpath))
{
if (!subpath.IsValidYavscPath())
@ -72,8 +70,12 @@ namespace Yavsc.Server.Helpers
var root = Path.Combine(siteSettings.Blog, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileName));
if (!fi.Exists) return;
// Capture the length BEFORE deleting: FileInfo.Length throws
// FileNotFoundException once the file is gone, which would
// surface as a 400 even though the delete already succeeded.
var length = fi.Length;
fi.Delete();
user.DiskUsage -= fi.Length;
user.DiskUsage -= length;
}
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
@ -87,8 +89,15 @@ namespace Yavsc.Server.Helpers
{
var fi = new FileInfo(Path.Combine(root, dirName));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "non existent" };
// Capture the length BEFORE deleting: FileInfo.Length
// throws FileNotFoundException once the file is gone, so
// reading it after fi.Delete() would throw — the caller's
// catch turns that into a 400 even though the file was
// already removed (the user sees "file does not exist"
// while the delete in fact succeeded).
var length = fi.Length;
fi.Delete();
user.DiskUsage -= fi.Length;
user.DiskUsage -= length;
}
else
{

View file

@ -1,5 +1,6 @@
#nullable enable annotations
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
@ -107,9 +108,23 @@ public static class ServiceExtensions
ValidateAudience = true,
ValidAudiences = audiences,
RoleClaimType = Constants.RoleClaimType,
NameClaimType = Constants.NameClaimType,
// IdentityServer's ProfileService emits the name claim as
// JwtClaimTypes.Name ("name", short form). MapInboundClaims
// (below) is disabled, so it stays "name" on the principal —
// NameClaimType must therefore be "name" too, or
// User.Identity.Name / GetUserName() resolve to null. The
// previous Constants.NameClaimType (the Microsoft long URI)
// never matched the remapped xmlsoap URI either, so the name
// was unreachable on every bearer host regardless of scope.
NameClaimType = JwtClaimTypes.Name,
};
options.MapInboundClaims = true;
// Keep JWT claim names as-emitted ("sub", "name", …). The default
// inbound map rewrites "name" → xmlsoap name URI and "sub" →
// nameid URI, which neither Constants.NameClaimType (microsoft
// URI) nor GetUserName()'s FindFirstValue("name") could match.
// Roles are already emitted as Constants.RoleClaimType (a long
// URI), so disabling the map leaves IsInMsRole() working.
options.MapInboundClaims = false;
options.ClaimsIssuer = authority;
options.Audience = audiences[0];

View file

@ -28,33 +28,38 @@ namespace Yavsc.Services
var claims = new List<Claim> {
new Claim(JwtClaimTypes.Subject,user.Id.ToString()),
};
List<string> claimAdds = new List<string>();
foreach (var scope in context.RequestedResources.ParsedScopes)
{
if (context.Client.AllowedScopes.Contains(scope.ParsedName))
{
claims.Add(new Claim(JwtClaimTypes.Scope, scope.ParsedName));
claimAdds.Add(scope.ParsedName);
}
}
if (claimAdds.Contains(JwtClaimTypes.Profile))
// name / email / roles are gated on the client being granted
// the "profile" identity scope. They MUST land in the *access
// token*: the Blogs/Api bearer resource hosts read the login
// via GetUserName() (FindFirstValue("name")) and roles via
// IsInMsRole() from the access token. But "profile" is an
// identity scope, which only appears in the id-token caller's
// ParsedScopes — never in the access-token caller's — so the
// previous gate (claimAdds.Contains("profile")) left name,
// email and roles out of every access token, and GetUserName()
// resolved to null on every bearer host (the MyFiles upload
// 500). Gate on the client's AllowedScopes instead, which is
// caller-independent, so both the access token and the id
// token carry these claims for any user grant whose client is
// allowed "profile".
if (context.Client.AllowedScopes.Contains(JwtClaimTypes.Profile))
{
claimAdds.Remove("profile");
claimAdds.Add(JwtClaimTypes.Name);
claimAdds.Add(JwtClaimTypes.Email);
claimAdds.Add(Constants.RoleClaimType);
}
if (claimAdds.Contains(JwtClaimTypes.Name))
claims.Add(new Claim(JwtClaimTypes.Name, user.FullName));
if (claimAdds.Contains(JwtClaimTypes.Email))
// "name" carries the login (UserName), not the display
// FullName: GetUserName() / User.Identity.Name feed
// filesystem paths (user files root, avatar file name),
// and FullName can contain spaces. No bearer-host
// consumer displays this claim as a human name today.
claims.Add(new Claim(JwtClaimTypes.Name, user.UserName));
claims.Add(new Claim(JwtClaimTypes.Email, user.Email));
if (claimAdds.Contains(Constants.RoleClaimType))
{
var roles = await this._userManager.GetRolesAsync(user);
if (roles.Count() > 0)
{

View file

@ -4,7 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Api.Test</RootNamespace>
<RootNamespace>Yavsc.Api.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
@ -21,10 +21,10 @@
<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.Api\Yavsc.Api.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Api\Yavsc.Api.csproj" />
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -44,7 +44,7 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice")
private HttpClient NewClient(string subject = "alice", string? name = null)
{
var handler = new HttpClientHandler
{
@ -54,8 +54,11 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
// Pass a distinct name to mirror production (sub = GUID, name =
// login); defaults to subject for the existing tests where the
// two are equal.
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, name: name));
return http;
}
@ -129,6 +132,75 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
"Expected the uploaded file on disk under the owner's personal root");
}
/// <summary>
/// Regression for the "Mes fichiers" page listing nothing: the
/// personal-storage tree is keyed by the user's <b>login</b>
/// (<c>UserName</c>) — <c>EnsureDestinationDirectory</c> writes
/// under <c>{Blog}/{name-claim}</c>, the download endpoint and
/// avatar paths use <c>user.UserName</c> — but <c>GET api/v1/fs</c>
/// passed <c>User.GetUserId()</c> (the <c>sub</c> claim, a GUID in
/// production) to <c>GetUserFiles</c>, so the listing looked under
/// <c>{Blog}/{sub-GUID}</c>, an empty tree. This test decouples
/// <c>sub</c> from the login (mirroring production) and asserts the
/// listing returns the file stored under the login.
/// </summary>
[Fact]
public async Task Get_fs_lists_files_stored_under_the_user_login()
{
_fixture.ResetDatabase();
// sub = "alice-sub" (GUID-like), login = "alice" — the shape
// ProfileService mints in production. DiskQuota non-zero so the
// upload persists the UploadedFile row (see Post_fs test).
_fixture.SeedUser("alice", u =>
{
u.Id = "alice-sub";
u.DiskQuota = 10_000_000;
});
var payload = System.Text.Encoding.UTF8.GetBytes("list me");
using var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(payload);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
// Upload with a token whose sub is the GUID-like id and whose
// name is the login — EnsureDestinationDirectory keys off "name".
using (var http = NewClient("alice-sub", name: "alice"))
{
var post = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
var postBody = await post.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(post.StatusCode == HttpStatusCode.OK,
$"Upload failed: {(int)post.StatusCode} ({post.StatusCode}): {postBody}");
}
// The file landed under the login, not the subject.
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
"Expected the uploaded file under {BlogFilesRoot}/alice/note.txt (the login)");
Assert.False(Directory.Exists(Path.Combine(_fixture.BlogFilesRoot, "alice-sub")),
"The subject-named directory must NOT exist — storage is keyed by the login");
// GET /api/v1/fs must list under the login and find the file.
using var get = NewClient("alice-sub", name: "alice");
var response = await get.GetAsync("/api/v1/fs", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"List failed: {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.TryGetProperty("files", out var filesEl),
$"Response has no 'files' property. Body: {body}");
var names = filesEl.EnumerateArray().Select(f => f.GetProperty("name").GetString()).ToArray();
Assert.Contains("note.txt", names);
// The UploadedFile row (OwnerId = sub) is enriched onto the
// entry, so the client can attach the file by reference.
var noteEntry = filesEl.EnumerateArray()
.First(f => f.GetProperty("name").GetString() == "note.txt");
Assert.True(noteEntry.TryGetProperty("id", out var idEl) && idEl.GetInt64() > 0,
"Expected the enriched UploadedFile id on the listed entry");
}
[Fact]
public async Task GetFile_owner_can_download_their_own_file()
{
@ -291,4 +363,35 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
/// <summary>
/// Regression for the "delete returns 40x yet the file is gone"
/// report: <c>DeleteUserDirOrFile</c> read <c>FileInfo.Length</c>
/// <b>after</b> <c>fi.Delete()</c>, and <c>FileInfo.Length</c>
/// throws <c>FileNotFoundException</c> once the file no longer
/// exists. The controller's <c>catch</c> turned that into a 400
/// even though the file had already been removed — the user saw
/// "le fichier n'existe pas sur le serveur" while the delete in
/// fact succeeded. This test asserts a clean 200 and that the
/// file is gone from disk.
/// </summary>
[Fact]
public async Task Delete_fs_removes_the_file_and_returns_200()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var bytes = System.Text.Encoding.UTF8.GetBytes("delete me");
WritePhysicalFile(_fixture, "alice", "doomed.txt", bytes);
using var http = NewClient("alice");
var response = await http.DeleteAsync(
"/api/v1/fs/doomed.txt", 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.False(File.Exists(PhysicalFile(_fixture, "alice", "doomed.txt")),
"The file must be gone from disk after a successful delete.");
}
}

View file

@ -23,10 +23,10 @@
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Api.Client\Yavsc.Api.Client.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Api.Client\Yavsc.Api.Client.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -1,7 +1,10 @@
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using IdentityModel.Client;
using Xunit;
using Yavsc.Server.Helpers;
namespace Yavsc.Org.Tests
{
@ -70,6 +73,72 @@ namespace Yavsc.Org.Tests
}
/// <summary>
/// Regression for the MyFiles upload 500 / null
/// <c>User.Identity.Name</c>: the access token minted by the
/// real IdentityServer (resource-owner password grant) must
/// carry a <c>name</c> claim whose value is the user's login
/// (<c>UserName</c>), and <see cref="UserHelpers.GetUserName"/>
/// — which reads <c>FindFirstValue("name")</c> — must resolve
/// to it on a principal built the way
/// <c>AddYavscJwtBearer</c> builds one
/// (<c>MapInboundClaims=false</c>, <c>NameClaimType="name"</c>).
///
/// <para>Prerequisites wired by <see cref="WebServerFixture"/>:
/// the test client's <c>AllowedScopes</c> include
/// <c>openid</c>+<c>profile</c>, and the standard identity
/// resources are seeded by the production pipeline. Requesting
/// <c>openid profile</c> makes <c>ProfileService</c> emit
/// <c>name</c> = <c>user.UserName</c>.</para>
/// </summary>
[Fact]
public async Task PasswordToken_carries_name_claim_equal_to_login()
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
var client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)),
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)),
// "openid profile" triggers ProfileService to emit the
// "name" claim (user.UserName). Without profile, the
// token carries only sub + scopes and GetUserName()
// resolves to null — the exact regression behind the
// MyFiles upload 500. "test" is the API scope, so a real
// JWT access token (not just an id_token) is issued.
Scope = "openid profile test"
}, cancellationToken);
Assert.False(response.IsError,
$"Token request failed: {response.Error} ({response.ErrorDescription})");
// Decode the access token straight from the payload so short
// claim names ("name", "sub") stay as-emitted — mirrors
// AddYavscJwtBearer with MapInboundClaims=false.
var jwt = new JwtSecurityToken(response.AccessToken);
var nameClaim = jwt.Claims.FirstOrDefault(c => c.Type == "name");
Assert.True(nameClaim is not null,
"access_token has no 'name' claim; ProfileService did not emit it " +
"(is the 'profile' scope in the client's AllowedScopes and requested?).");
Assert.Equal(_serverFixture.TestingUserName, nameClaim!.Value);
// Rebuild the principal the way a bearer consumer host does
// and exercise the real GetUserName() helper used by the
// file-system / avatar paths.
var identity = new ClaimsIdentity(
jwt.Claims, authenticationType: "Bearer",
nameType: "name", roleType: null);
var principal = new ClaimsPrincipal(identity);
Assert.Equal(_serverFixture.TestingUserName, principal.GetUserName());
}
[Fact]
public async Task GetSignin_returns_a_page()
{

View file

@ -8,6 +8,16 @@ using Xunit;
namespace Yavsc
{
// BillingService and Config expose static mutable registries that
// WorkflowHelpers.ConfigureBillingService() also mutates from the
// WebServerFixture host build (HostingExtensions.cs). Without a
// collection, xUnit runs this class in parallel with the
// "Yavsc Server" collection, and the host build's ConfigureBilling
// call can land between this test's two ConfigureBilling calls,
// breaking the idempotency count assertion (a flaky failure that
// surfaces whenever the fixture's timing shifts). Pinning the class
// to the same collection serializes it with the host build.
[Collection("Yavsc Server")]
[Trait("regression", "II")]
public class BillingServiceTests
{

View file

@ -1,4 +1,5 @@
using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.EntityFramework.Mappers;
using IdentityServer8.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
@ -238,6 +239,26 @@ public sealed class WebServerFixture : WebHostFixture
AddAuthorizedClient(scope, TestClientId, TestClientSecret);
}
// EnsureDefaultApplicationScopes seeds the standard identity
// resources (openid, profile) once at host startup, but a
// ResetAndMigrateDatabase call does EnsureDeleted on the shared
// in-memory store, which wipes them — and ReseedAuthTestData
// previously only re-added the client. Without openid/profile
// present, any token request asking for those scopes returns
// invalid_scope, so a password-grant token carrying the "name"
// claim (scope "openid profile") could not be minted after a
// reset. Re-seed them here to mirror the startup seed.
var identityResources = configDb.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>();
if (!identityResources.Any(r => r.Name == "openid"))
{
identityResources.Add(new IdentityResources.OpenId().ToEntity());
}
if (!identityResources.Any(r => r.Name == "profile"))
{
identityResources.Add(new IdentityResources.Profile().ToEntity());
}
configDb.SaveChanges();
_sharedTestClientId = TestClientId;
_sharedTestClientSecret = TestClientSecret;
_sharedTestingUserName = TestingUserName;
@ -284,7 +305,12 @@ public sealed class WebServerFixture : WebHostFixture
{
ClientId = testClientId,
AccessTokenLifetime = 3600000,
AccessTokenType = 1,
// AccessTokenType = 0 → Jwt in IdentityServer8 (1 = Reference,
// the inverse of Duende's enum). The real PostIt client issues
// JWT access tokens so the Blogs/Api bearer hosts can validate
// them with JwtBearer; mirror that here so the token can be
// decoded and its "name" claim asserted.
AccessTokenType = 0,
ClientName = "Testing client",
Enabled = true,
RequireClientSecret = true
@ -327,6 +353,23 @@ public sealed class WebServerFixture : WebHostFixture
ClientId = testingClient.Id,
Scope = "test"
});
// Allow the identity scopes so a resource-owner-password token
// can request "openid profile" and trigger ProfileService to
// emit the "name" claim (user.UserName). Without profile in the
// client's AllowedScopes, IdentityServer strips the scope from
// the request and the access_token carries no "name" — the
// regression that left GetUserName() / the MyFiles upload root
// null. Additive only: existing tests request "test" alone.
configDb.Set<ClientScope>().Add(new ClientScope
{
ClientId = testingClient.Id,
Scope = "openid"
});
configDb.Set<ClientScope>().Add(new ClientScope
{
ClientId = testingClient.Id,
Scope = "profile"
});
configDb.SaveChanges();
}

View file

@ -42,9 +42,9 @@
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -21,9 +21,9 @@
<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.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>

View file

@ -63,17 +63,32 @@ public static class TestTokenIssuer
/// <param name="scope">Value of the <c>scope</c> claim. The
/// production <c>BlogScope</c> policy requires
/// <c>RequireClaim("scope", "blogs")</c>.</param>
/// <param name="name">Value of the <c>name</c> claim. Defaults to
/// <paramref name="subject"/> (the test convention where the login
/// equals the subject). Pass a distinct login to mirror production,
/// where <c>sub</c> is a GUID and <c>name</c> is the user's
/// <c>UserName</c> — the file-system list/upload paths key the
/// personal-storage tree off the login, not the subject.</param>
/// <param name="extraClaims">Optional additional claims
/// (e.g. a role for an admin-bypass test).</param>
public static string Issue(
string subject,
string scope = "blogs",
string? name = null,
IEnumerable<Claim>? extraClaims = null)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new("sub", subject),
// Mirror production: IdentityServer's ProfileService emits the
// "name" claim as the user's login (UserName) when the profile
// scope is granted, and the file-system helpers resolve the
// per-user storage root via GetUserName() (FindFirstValue("name"))
// — not via User.Identity.Name. A test token without "name" would
// make ReceiveUserFile / EnsureDestinationDirectory compute a
// null owner root and 500 on upload, so stamp it here.
new("name", name ?? subject),
new("scope", scope),
};
if (extraClaims is not null) claims.AddRange(extraClaims);

Some files were not shown because too many files have changed in this diff Show more