Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a94a5a0497 |
|||
|
8c8ab70d38 |
|||
|
f0792a844f |
|||
|
26e4fde1e7 |
|||
|
70f8b7986e |
|||
|
1d12166611 |
|||
|
5104cbc3f1 |
|||
|
7844449c99 |
|||
|
f1a17cd8fe |
|||
|
4554886e33 |
|||
|
a307885310 |
|||
|
d8c13c81d7 |
|||
|
8d2d6754f6 |
103 changed files with 864 additions and 159 deletions
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
8
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -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
6
.vscode/tasks.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
98
CHANGELOG.md
98
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
25
Makefile
25
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
208
src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs
Normal file
208
src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/PostIt/PostIt.Tests/StatusBarTests.cs
Normal file
35
src/PostIt/PostIt.Tests/StatusBarTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Reference in a new issue