diff --git a/Directory.Packages.props b/Directory.Packages.props index e4b09159..e7f71232 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,8 +9,6 @@ - - @@ -23,8 +21,6 @@ - - \ No newline at end of file diff --git a/contrib/bruno/Get Posts.bru b/contrib/bruno/Get Posts.bru deleted file mode 100644 index bafe95b1..00000000 --- a/contrib/bruno/Get Posts.bru +++ /dev/null @@ -1,16 +0,0 @@ -info: - name: Get Posts - type: http - seq: 1 - -http: - method: GET - url: https://jsonplaceholder.typicode.com/users - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 - -docs: This request retrieves a list of users from the JSONPlaceholder API. diff --git a/contrib/bruno/Untitled.bru b/contrib/bruno/Untitled.bru deleted file mode 100644 index 168811b2..00000000 --- a/contrib/bruno/Untitled.bru +++ /dev/null @@ -1,15 +0,0 @@ -info: - name: Untitled - type: http - seq: 1 - -http: - method: GET - url: "" - auth: inherit - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/contrib/bruno/blogs.yml b/contrib/bruno/blogs.yml deleted file mode 100644 index 2767b01d..00000000 --- a/contrib/bruno/blogs.yml +++ /dev/null @@ -1,15 +0,0 @@ -info: - name: blogs - type: http - seq: 1 - -http: - method: GET - url: "{{Blogs}}/api/v1/blog" - auth: inherit - -settings: - encodeUrl: true - timeout: 0 - followRedirects: true - maxRedirects: 5 diff --git a/contrib/bruno/environments/Development.yml b/contrib/bruno/environments/Development.yml deleted file mode 100644 index 0fd5430e..00000000 --- a/contrib/bruno/environments/Development.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: Development -variables: - - name: Blogs - value: https://localhost:5003 - - name: Authority - value: https://localhost:5001 diff --git a/contrib/bruno/environments/Production.yml b/contrib/bruno/environments/Production.yml deleted file mode 100644 index fda7173b..00000000 --- a/contrib/bruno/environments/Production.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: Production -variables: - - name: Authority - value: https://yavsc.pschneider.fr - - name: Blogs - value: https://blogs.pschneider.fr diff --git a/contrib/bruno/opencollection.yml b/contrib/bruno/opencollection.yml deleted file mode 100644 index 701cace5..00000000 --- a/contrib/bruno/opencollection.yml +++ /dev/null @@ -1,43 +0,0 @@ -opencollection: 1.0.0 - -info: - name: blogs -config: - proxy: - inherit: true - config: - protocol: http - hostname: "" - port: "" - auth: - username: "" - password: "" - bypassProxy: "" - -request: - auth: - type: oauth2 - flow: authorization_code - authorizationUrl: "{{Authority}}/connect/authorize" - accessTokenUrl: "{{Authority}}/connect/token" - refreshTokenUrl: https://yavsc.pschneider.fr/connect/token - callbackUrl: "{{Authority}}" - credentials: - clientId: postit - placement: basic_auth_header - scope: openid blogs - pkce: {} - tokenConfig: - id: credentials - placement: - header: Bearer - source: access_token - settings: - autoFetchToken: true - autoRefreshToken: true -bundled: false -extensions: - bruno: - ignore: - - node_modules - - .git diff --git a/doc/README.md b/doc/README.md index b189bb27..4afdf585 100644 --- a/doc/README.md +++ b/doc/README.md @@ -15,7 +15,6 @@ La racine de l'architecture est [Architecture.md](Architecture.md). | [architecture/dictionnaires-metier.md](architecture/dictionnaires-metier.md) | Dictionnaires métier, héritage en arbre, cycle de vie d'un terme | | [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter | | [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh | -| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | ## Roadmap & design exploration diff --git a/doc/architecture/decoupage-organisation.md b/doc/architecture/decoupage-organisation.md index 52e6ea97..9d4a0e4a 100644 --- a/doc/architecture/decoupage-organisation.md +++ b/doc/architecture/decoupage-organisation.md @@ -31,19 +31,7 @@ └────────────────┘ Clients externes : - - PostIt (Avalonia, code-base unique multi-cible) : - · PostIt — lib partagée (pages, VM, services) - · PostIt.Desktop — front-end Linux/Windows - · PostIt.Android — front-end APK - · PostIt.Browser — front-end WASM - Cf. postit.md et postit-oidc.md. - -Outils et tests : - - cli — outillage CLI - - Yavsc.Tests.Shared — helpers de tests partagés - - Yavsc.Org.Tests — tests du front web - - Yavsc.Blogs.Tests — tests du backend blogs - - PostIt.Tests — tests du client PostIt + - PostIt : client desktop Avalonia (cf. postit-oidc.md). ``` ## Par projet @@ -56,14 +44,6 @@ Outils et tests : | `Yavsc.Api` | ASP.NET Web | API REST JSON principale consommée par les clients externes (PostIt, …). JwtBearer auth. | | `Yavsc.Blogs` | ASP.NET Web | **Backend API headless** dédié aux blogs (uniquement `*ApiController` + services + modèles — aucune vue Razor). Destiné à être déployé sur un sous-domaine en production, séparé du front web hébergé par `Yavsc.Org`. | | `Yavsc.Org.Tests` | Test (xUnit) | Tests d'isolation du front web (`Yavsc.Org`) — fakes, controller tests. | -| `Yavsc.Blogs.Tests`| Test (xUnit) | Tests d'isolation du backend blogs (`Yavsc.Blogs`). | -| `Yavsc.Tests.Shared` | Library | Helpers de tests partagés (fixtures, fakes, builders) entre les projets de tests. | -| `PostIt` | Library | Code-base partagée du client PostIt (Avalonia) : pages, ViewModels, services, `ViewLocator` custo. Multi-cible — produit PostIt.Desktop / PostIt.Android / PostIt.Browser. | -| `PostIt.Desktop` | Avalonia.Desktop | Front-end Desktop Linux/Windows : `Program.Main`, `Platform.CreateBrowser` (CustomSchemeBrowser), custom URI scheme `postit://`. | -| `PostIt.Android` | Avalonia.Android | Front-end Android : `MainActivity` SingleTask, Chrome Custom Tabs, scheme `android://postit-signin`. | -| `PostIt.Browser` | Avalonia.Browser | Front-end WASM : pas de process distinct, IBrowser N/A. | -| `PostIt.Tests` | Test (xUnit) | Tests du client PostIt : settings, scopes Bearer, OIDC stub (`OidcStubAuthority`). | -| `cli` | exe / tool | Outillage CLI (build, packaging, génération de clés). | ## Pourquoi ce découpage diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md deleted file mode 100644 index 77d0a252..00000000 --- a/doc/architecture/postit.md +++ /dev/null @@ -1,255 +0,0 @@ -# PostIt — Topologie, navigation, DI - -> **Récapitulatif** : PostIt est le client Avalonia du projet -> Yavsc. C'est un code-base unique (`src/PostIt/PostIt/PostIt.csproj`) -> **multi-cible** vers trois front-ends distincts -> (`PostIt.Desktop`, `Postit.Android`, `PostIt.Browser`). Cette -> fiche couvre la topologie des projets, le DI, le `ViewLocator` -> custo et la navigation — c'est-à-dire tout ce que la fiche -> [postit-oidc.md](postit-oidc.md) ne détaille pas déjà (l'OIDC, -> le flow d'auth, la persistance des tokens). Détail dans cette -> page, racine de l'architecture : [Architecture.md](../Architecture.md). - -## Surface : un code-base, trois front-ends - -``` - ┌────────────────────────┐ - │ PostIt (lib) │ - │ src/PostIt/PostIt/ │ - │ Pages, ViewModels, │ - │ Services, ViewLocator │ - │ (aucun rendu natif) │ - └──────┬───┬─────┬───────┘ - │ │ │ - ┌───────────────┘ │ └────────────────┐ - │ │ │ - ┌──────────▼────────┐ ┌────────▼─────────┐ ┌──────────▼────────┐ - │ PostIt.Desktop │ │ PostIt.Android │ │ PostIt.Browser │ - │ Avalonia.Desktop │ │ Avalonia.Android │ │ Avalonia.Browser │ - │ Linux/Windows │ │ APK │ │ WASM │ - │ + custom scheme │ │ + Chrome Custom │ │ (no native proc) │ - │ postit:// │ │ Tabs │ │ │ - │ + IBrowser custo │ │ + IBrowser custo │ │ │ - └───────────────────┘ └──────────────────┘ └───────────────────┘ -``` - -Le code partagé vit dans `PostIt/`. Chaque front-end est un -**projet Satellite SDK** Avalonia qui ne contient que le -`Program.Main`, le `Platform.CreateBrowser`, et les manifestes -spécifiques (IntentFilter Android, `app.manifest` Desktop). -Toute la logique (VM, services, navigation, settings, OIDC) est -dans le code-base partagé. - -## ViewLocator custo - -Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un -`IDataTemplate` Avalonia **explicitement câblé sur le -`IServiceProvider`** : - -```csharp -public Control Build(object? data) => data switch -{ - MainPageViewModel => _services.GetRequiredService(), - Settings => _services.GetRequiredService(), - HomePageViewModel => _services.GetRequiredService(), - SignaturePageViewModel => _services.GetRequiredService(), - null => new TextBlock { Text = "No view for " }, - _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } -}; -public bool Match(object? data) => data is ViewModelBase; -``` - -**Pourquoi un custo, et pas le `ViewLocatorBase` par défaut -d'Avalonia.Mvvm ?** Pour deux raisons : - -1. **Sortie du `Activator.CreateInstance`** — les pages - PostIt sont enregistrées dans le DI et peuvent avoir des - dépendances (par construction, aujourd'hui aucune, mais - l'extension future est ouverte). Le `ViewLocatorBase` - historique fait `new View()`, ce qui rend impossible - l'injection et complique les tests. -2. **Filtrage par `ViewModelBase`** — `Match` n'accepte que les - types dérivés de `ViewModelBase`. Toute tentative d'afficher - un objet métier (par ex. un DTO de l'API Yavsc) tombe sur le - `TextBlock` "No view for X", pas sur un crash Avalonia. - -Le `ViewLocator` est ajouté aux `DataTemplates` de l'app dans -`App.OnFrameworkInitializationCompleted` : - -```csharp -DataTemplates.Clear(); -DataTemplates.Add(new ViewLocator(provider)); -``` - -**Conséquence pratique** : pour qu'une nouvelle page soit -affichée par un `ContentControl` qui binde un ViewModel, il -faut *deux* enregistrements : la page en `AddTransient` (ou -`AddSingleton`) dans le DI, **et** une case dans le `switch` -de `ViewLocator.Build`. Si l'un manque, l'app affiche -"No view for X" sans crash. - -## Composition root (`App.axaml.cs`) - -`App.OnFrameworkInitializationCompleted` est le seul endroit où -le DI est construit. Ordre, dans cet ordre : - -1. `new Settings()` + `settings.Load()` — lit - `~/.config/PostIt/postit-settings.json` (ou le fallback - embarqué dans `PostIt.dll`). -2. `new TokenStore(...)` + `new YavscApiClient(settings, tokenStore)`. -3. `new ServiceCollection()` + enregistrements en bloc. -4. `services.BuildServiceProvider()`. -5. `Settings.BindToServiceProvider(provider)` — pose le - singleton statique pour les helpers hors-DI - (`Settings.GetCurrent()`, `Settings.RequireCurrent()`). -6. `DataTemplates.Add(new ViewLocator(provider))`. -7. Branche `IClassicDesktopStyleApplicationLifetime` / - `ISingleViewApplicationLifetime` (Browser/Android). - -### Enregistrements DI - -| Service | Lifetime | Pourquoi | -|-------------------------------|------------|-------------------------------------------------------------------------------------------| -| `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. | -| `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. | -| `BlogApiClient` | Singleton | Mapper stateless, partagé. | -| `SettingsPage` | **Singleton** | Une seule instance pour la vie de l'app : le `DataContext` est câblé une fois au boot, le push est idempotent (cf. section *Garde anti-empilement* ci-dessous). | -| `MainPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. | -| `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. | -| `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). | - -> **Invariant** : `Settings` est **uniquement** un singleton. Un -> `AddTransient()` supplémentaire (qui réécrase le -> singleton dans le container) ferait que chaque push de -> `SettingsPage` crée une instance vide, casse les bindings -> Authority/ClientId, et perd toute édition. Si tu dois toucher -> à cette table, *ne pas* ajouter de registration pour -> `Settings` ailleurs que la ligne `AddSingleton(settings)`. - -## Navigation - -Le host de navigation est un `NavigationPage x:Name="NavRoot"` -posé sur `MainWindow.axaml`. La pile est gérée par les -événements du `SessionStatusViewModel` : - -| Événement | Effet | -|---------------------------------|------------------------------------------------------------------------| -| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. | -| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). | -| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. | - -### Garde anti-empilement - -`NavigationPage.PushAsync` n'est pas idempotent : pousser deux -fois la même instance l'empile deux fois, et l'utilisateur doit -taper **Retour** N fois pour sortir. Le handler -`OpenSettingsRequested` est gardé pour bloquer ce cas : - -```csharp -var settingsPage = provider.GetRequiredService(); -var stack = w.NavRoot.NavigationStack; -if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) -{ - return; // déjà au sommet, no-op silencieux -} -_ = w.NavRoot.PushAsync(settingsPage); -``` - -La comparaison est par référence, pas par type : on ne veut -empêcher qu'un push de *cette* instance particulière, pas -celui d'une éventuelle autre `SettingsPage` (il n'en existe -qu'une, mais l'invariant est plus clair comme ça). La garde -repose sur le fait que `SettingsPage` est un singleton ; si on -repassait en `Transient`, `ReferenceEquals` resterait correct -mais la pertinence de la garde s'évaporerait (chaque push -apporterait une nouvelle instance et l'anti-empilement -reposerait sur l'invariant « la même est déjà au sommet », -qui ne tiendrait plus). - -## ViewModels et invariants d'état - -- `Settings` est un objet-modèle exposé comme `DataContext` - des pages. Il n'hérite pas de `ViewModelBase` (c'est un - POCO `[ObservableProperty]`-généré par - `CommunityToolkit.Mvvm`). Le fait qu'il soit utilisé comme - DataContext est un raccourci de composition acceptable ici, - pas un pattern à généraliser. - -- `SessionStatusViewModel` est le seul VM avec une durée de vie - **process-entière** (singleton). Il survit à toutes les - navigations, expose `HasValidSession` en continu, et porte - les trois événements qui pilotent la navigation - (`LoginSucceeded`, `LogoutCompleted`, - `OpenSettingsRequested`). - -- `MainPageViewModel` / `HomePageViewModel` / - `SignaturePageViewModel` sont `Transient` — une nouvelle - instance est créée à chaque push, l'ancienne est libérée - quand la page est dépilée. Pas d'état partagé entre - occurrences ; pour passer une donnée d'une page à l'autre, - on passe par un singleton (souvent `YavscApiClient` ou - `Settings`). - -## Bindings XAML : conventions de nommage - -Pour les `[RelayCommand]` (cf. `CommunityToolkit.Mvvm`), le -binding XAML reprend **le nom exact de la méthode, sans -suffixe** : - -| Méthode C# | Binding XAML | -|-----------------------|-----------------------------| -| `Save()` | `{Binding Save}` | -| `SaveAsync()` | `{Binding SaveAsync}` | -| `LoginCommand()` | `{Binding LoginCommand}` (nom littéral, *pas* de suffixe ajouté) | -| `Clear()` | `{Binding Clear}` | -| `CaptureAsync()` | `{Binding CaptureAsync}` | - -**JAMAIS** `SaveCommand`, `SaveCmd`, `DoSave`, etc. Le source -generator `[RelayCommand]` émet une propriété `ICommand` du -même nom que la méthode. Un binding qui pointe vers une -propriété inexistante casse l'app au moment du câblage (le -bouton ne se câble pas, et selon la version ça peut faire -planter l'init de la page). - -Référence canonique : `AGENTS.md`, section -"Avalonia + CommunityToolkit.Mvvm : conventions de binding -pour `[RelayCommand]`". - -## Pages et leurs rôles - -| Page | DataContext | Rôle | -|----------------------------|--------------------------|-----------------------------------------------------------------------| -| `MainWindow` | `HomePageViewModel` (initial) | Host de la `NavigationPage`. | -| `SessionStatusBanner` | `SessionStatusViewModel` | Bandeau persistant en haut de la fenêtre, visible sur toutes les pages. Boutons Login / Logout / Paramètres. | -| `HomePage` | `HomePageViewModel` | Page d'accueil publique. | -| `MainPage` | `MainPageViewModel` | Éditeur de post de blog (après login). | -| `SignaturePage` | `SignaturePageViewModel` | Capture de signature (estimateur). | -| `SettingsPage` | `Settings` | Édition de Authority / ClientId / Scopes / URLs API / Dark mode. Sauver via `Save` (RelayCommand). | - -## Conséquences pratiques - -- **Ajouter une page** : créer la View + le ViewModel + - enregistrer les deux dans le DI **et** dans le `switch` de - `ViewLocator.Build`. Oublier le `ViewLocator` est silencieux - (juste un TextBlock "No view for X"), pas une exception. -- **Ajouter un événement global de navigation** (par ex. - "Push après payment success") : passer par un événement sur - un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`), - pas par une référence à `MainWindow` depuis le VM. Garder - les VMs découplés du `IClassicDesktopStyleApplicationLifetime`. -- **Modifier l'OIDC** : la fiche à lire est - [postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche - ne ré-explique ni le flow, ni le pipe, ni le custom scheme. -- **Modifier les `Settings`** : ne pas casser le singleton - (cf. invariant ci-dessus). Toute propriété présentationnelle - ajoutée (par ex. `ScopeListText`) doit porter `[JsonIgnore]` - pour ne pas polluer le format sur disque. - -## Voir aussi - -- [Architecture.md](../Architecture.md) — racine. -- [postit-oidc.md](postit-oidc.md) — flow OIDC, custom scheme, - silent refresh, persistance des tokens. -- [decoupage-organisation.md](decoupage-organisation.md) — - place de `PostIt` dans le découpage global des projets - .NET du repo. diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs deleted file mode 100644 index 68fa514e..00000000 --- a/src/PostIt.Tests/BearerScopeTests.cs +++ /dev/null @@ -1,288 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using PostIt.Services; -using Xunit; - -namespace PostIt.Tests; - -/// -/// Diagnostic coverage for the 401 we're seeing in production when -/// PostIt talks to Yavsc.Blogs. The hypothesis this file -/// isolates: "the access token sent on the wire is missing the -/// blogs scope that Yavsc.Blogs's BlogScope -/// policy requires". The policy lives in -/// Yavsc.Blogs/Program.cs as -/// RequireClaim(JwtClaimTypes.Scope, "blogs"). -/// -/// -/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or -/// any network listener. The test fakes a single -/// that captures the outbound -/// request, deserialises the bearer JWT, and asserts the -/// scope claim contains the segment the policy needs. This -/// pins the client side of the contract so a future regression in -/// or (e.g. a -/// silently dropped scope, a wrong merge order, a scope string -/// that no longer matches the server policy) trips the test before -/// it reaches production. -/// -/// -public class BearerScopeTests -{ - /// - /// Hard-coded blogs scope string. Mirrors the value in - /// Yavsc.Blogs/Program.cs's BlogScope policy; if - /// the server ever moves to "blog.read" or similar this - /// constant should be updated to match. - /// - private const string RequiredScope = "blogs"; - - [Fact] - public async Task GetPostsAsync_sends_bearer_with_blogs_scope_in_jwt() - { - // Build the exact scope list a user would have in - // postit-settings.json. MergeScopes (called inside - // YavscApiClient when issuing the authorize request) would - // have appended "openid profile offline_access", so the - // access token in real life carries all of them. The test - // pins that the scope the *server* needs survived the - // round trip from settings.json to the access_token. - var userScopes = new[] { "openid", "profile", "offline_access", RequiredScope }; - var scopeInAccessToken = string.Join(' ', userScopes); - - // Mint a fake access token whose only payload claim is - // "scope". No signature: the client never verifies, and the - // production server doesn't see this token (we mock the - // HttpMessageHandler, so the message never leaves the - // process). - var accessToken = MintUnsignedJwt(scopeInAccessToken); - - var settings = new PostIt.ViewModels.Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://example.invalid", - ClientId = "postit-tests", - Scopes = userScopes, - RedirectUri = "postit://callback", - }, - BusinessApiUrl = "https://example.invalid/api/v1/", - }; - - var tokensPath = Path.Combine( - Path.GetTempPath(), $"postit-bearer-scope-{Guid.NewGuid():N}.json"); - try - { - // Pre-seed the token store so YavscApiClient believes - // it has a valid session and CallAsync does not refuse - // to send. - var store = new TokenStore(tokensPath); - store.Save(new RefreshTokenRecord( - AccessToken: accessToken, - RefreshToken: "irrelevant-for-this-test", - AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddHours(1), - IdToken: null)); - - // CapturingHttpHandler is the assertion point. It - // records the first request's Authorization header and - // returns 200 with an empty array (BlogApiClient - // deserialises to List). - var captured = new CapturingHttpHandler(); - var client = new YavscApiClient( - settings, - store, - // Bypass OidcClient construction (it would try to - // resolve an Authority we don't have a real IdP - // for). The handler we inject below is what the - // bearer attaches the token to; refresh paths are - // not exercised in this test. - oidc: null!); - - // YavscApiClient builds its own HttpClient around a - // BearerTokenHandler(new HttpClientHandler()) in its - // constructor; the handler is not exposed for - // replacement. The seam we use: CallAsync is virtual, - // so a subclass that talks to a caller-supplied - // HttpMessageHandler lets us assert on the outbound - // request without standing up any server. - var subClient = new TestableYavscApiClient( - settings, store, captured, accessToken); - - // Resolve a BlogApiClient on top. We don't need real - // posts; we just need the outbound HTTP request to be - // the one we capture. - var blog = new BlogApiClient(subClient); - - await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); - - // The test only makes sense if we did capture - // something. If we got here with an empty capture, the - // BlogApiClient chose a non-HTTP path and this whole - // setup is wrong. - Assert.NotNull(captured.Authorization); - Assert.StartsWith("Bearer ", captured.Authorization); - - var jwt = captured.Authorization.Substring("Bearer ".Length).Trim(); - var scopes = ExtractScopes(jwt); - - Assert.Contains(RequiredScope, scopes); - } - finally - { - if (File.Exists(tokensPath)) File.Delete(tokensPath); - } - } - - // --- helpers ------------------------------------------------------- - - /// - /// Build an unsigned JWT carrying a single scope claim. - /// Mirrors the read-only fallback in - /// : base64url-decode - /// the middle segment, parse JSON, read the scope string. - /// The header and signature are placeholders — nobody in the - /// test path verifies the signature. - /// - private static string MintUnsignedJwt(string scope) - { - var header = Base64Url("""{"alg":"none","typ":"JWT"}"""); - var payload = Base64Url(JsonSerializer.Serialize(new - { - sub = "test-user", - iss = "https://example.invalid", - aud = "postit", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), - iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - scope, - })); - return $"{header}.{payload}."; - } - - private static string Base64Url(string s) - { - var bytes = Encoding.UTF8.GetBytes(s); - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - - /// - /// Pull the scope claim out of a (possibly unsigned) JWT - /// and split on whitespace, the canonical encoding per RFC 8693 - /// §4.2 and OpenID Connect Core 1.0 §5.1. - /// - private static IReadOnlyCollection ExtractScopes(string jwt) - { - var parts = jwt.Split('.'); - Assert.True(parts.Length >= 2, "JWT must have a payload segment"); - - var payload = parts[1].Replace('-', '+').Replace('_', '/'); - switch (payload.Length % 4) - { - case 2: payload += "=="; break; - case 3: payload += "="; break; - } - - using var doc = JsonDocument.Parse(Convert.FromBase64String(payload)); - if (!doc.RootElement.TryGetProperty("scope", out var scopeEl)) - { - return Array.Empty(); - } - var raw = scopeEl.GetString() ?? string.Empty; - return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries); - } - - /// - /// Minimal that records the - /// first request's Authorization header and replies 200 - /// with an empty JSON array. Anything beyond the first request - /// is a regression in the test setup, not the production code - /// path under test. - /// - private sealed class CapturingHttpHandler : HttpMessageHandler - { - public string? Authorization { get; private set; } - public Uri? RequestUri { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - Authorization = request.Headers.Authorization?.ToString(); - RequestUri = request.RequestUri; - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("[]", Encoding.UTF8, "application/json"), - }; - return Task.FromResult(response); - } - } - - /// - /// Subclass of that routes HTTP - /// traffic through a caller-supplied - /// . The base ctor wires - /// Http as new HttpClient(BearerTokenHandler(...)); - /// we don't replace that — we override the public call seam - /// - /// (declared virtual) and talk to our own HttpClient - /// from there. The EnsureFreshToken / 401-retry path - /// is intentionally not exercised here — that lives in - /// YavscApiClientTests; isolating the bearer - /// attachment is the whole point of this test. - /// - private sealed class TestableYavscApiClient : YavscApiClient - { - private readonly HttpClient _http; - private readonly string _accessToken; - - public TestableYavscApiClient( - PostIt.ViewModels.Settings settings, - TokenStore store, - HttpMessageHandler handler, - string accessToken) - : base(settings, store, oidc: null!) - { - _http = new HttpClient(handler, disposeHandler: false); - _accessToken = accessToken; - } - - public override Task CallAsync( - HttpMethod method, string path, object? body = null, - CancellationToken ct = default) - { - // Reproduce just enough of the production request - // shape: a real HttpRequestMessage with the bearer - // attached, so the assertion in the test is faithful. - // We skip the EnsureFreshToken/401-retry machinery on - // purpose — that path is already covered by - // YavscApiClientTests, and isolating the bearer - // attachment is exactly what this test exists for. - // - // The base YavscApiClient relies on HttpClient.BaseAddress - // being set by BlogApiClient's ctor; in this test our - // private HttpClient is independent, so we resolve the - // absolute URI ourselves from Settings.BusinessApiUrl — - // the same URL BlogApiClient would have set as BaseAddress. - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); - using var req = new HttpRequestMessage(method, absolute); - req.Headers.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); - using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); - resp.EnsureSuccessStatusCode(); - using var stream = resp.Content.ReadAsStream(); - var dto = JsonSerializer.Deserialize(stream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - return Task.FromResult(dto!); - } - } -} diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs deleted file mode 100644 index 9ec10f89..00000000 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ /dev/null @@ -1,65 +0,0 @@ -using PostIt.Models; -using PostIt.Services; -using PostIt.ViewModels; - -namespace PostIt.Tests; - -/// Per-call ledger shared between the test and the -/// recording fake, so the assertion can inspect what the VM -/// actually sent on the wire without coupling to the fake's -/// internals. -internal sealed class CallRecorder -{ - public (HttpMethod method, string path, object? body) FirstCall => - Calls[0]; - public List<(HttpMethod method, string path, object? body)> Calls { get; } = new(); -} - -/// Test fake that records every CallAsync invocation -/// and answers them with a canned sequence: the first call gets -/// a server-issued BlogPost (Id=42), the second call gets a -/// single-element list containing that post. Used by the ViewModel -/// tests and the headless UI test to capture exactly what the -/// Save button posts to the server. -internal sealed class RecordingYavscApiClient : YavscApiClient -{ - private readonly CallRecorder _recorder; - public RecordingYavscApiClient(CallRecorder recorder) - : base( - new Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://stub.invalid", - ClientId = "stub", - Scopes = new[] { "openid" }, - }, - }, - new TokenStore(System.IO.Path.GetTempFileName())) - { - _recorder = recorder; - } - - public override Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - _recorder.Calls.Add((method, path, body)); - // BlogPost? boxes to BlogPost at runtime, so we test the - // non-nullable type — typeof(BlogPost?) is a C# error - // (CS8639: "typeof cannot be used on a nullable reference - // type"). - if (typeof(T) == typeof(BlogPost)) - return Task.FromResult((T)(object)new BlogPost - { - Id = 42, - Title = "Mon premier billet", - AuthorId = "tester", - Article = "Contenu du billet de test.", - }); - if (typeof(T) == typeof(List)) - return Task.FromResult((T)(object)new List - { - new() { Id = 42, Title = "Mon premier billet" } - }); - return Task.FromResult(default(T)!); - } -} diff --git a/src/PostIt.Tests/LoginPageViewModelTests.cs b/src/PostIt.Tests/LoginPageViewModelTests.cs new file mode 100644 index 00000000..7a8b579f --- /dev/null +++ b/src/PostIt.Tests/LoginPageViewModelTests.cs @@ -0,0 +1,257 @@ +using System; +using System.Threading.Tasks; +using PostIt.ViewModels; +using Xunit; + +namespace PostIt.Tests; + +public class LoginPageViewModelTests +{ + [Fact] + public async Task LoginAsync_acquires_access_token_from_stubbed_yavsc_authority() + { + // Arrange: spin up a stub OIDC authority and a fake browser that + // short-circuits the system browser. The authority signs its + // access_token with RS256; the fake browser captures the redirect + // URI so the authority can complete the token exchange. + using var authority = await OIDCStubAuthority.StartAsync(); + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = authority.Issuer, + ClientId = "postit-tests" + }, + RedirectUri = authority.LoopbackRedirectUri, + Scopes = new[] { "openid", "profile", "blog" } + }; + + var vm = new LoginPageViewModel(settings, browser.CreateBrowser); + + // Act + await vm.LoginAsync(); + + // Assert: the ViewModel surfaced a token, not an error. + Assert.True( + !string.IsNullOrEmpty(vm.AccessToken), + $"Login did not produce a token. StatusMessage={vm.StatusMessage ?? ""}"); + Assert.False( + vm.StatusMessage?.StartsWith("Error") == true, + $"Login reported error: {vm.StatusMessage}"); + } + + [Fact] + public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty() + { + // Regression: when no user settings file exists and the embedded + // default somehow fails to load (e.g. resource stripped at publish + // time), the ViewModel must NOT hand a blank Authority to + // OidcClient — IdentityModel would build a bogus authorize URL + // like "http://127.0.0.1:1/" which the browser rejects with a + // confusing error. Surface a clear, actionable message instead. + // + // SettingsLoadOverride is set to a no-op so the test fixture's + // pre-loaded Settings object survives the call to LoginAsync. + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "", + ClientId = "postit-tests", + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" }, + }; + + var browserInvoked = false; + var vm = new LoginPageViewModel(settings, () => + { + browserInvoked = true; + return null; + }) + { + // Skip the disk / embedded read so the Authority stays empty. + SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask, + }; + + await vm.LoginAsync(); + + Assert.False( + browserInvoked, + "Browser factory was invoked even though Authority was empty."); + Assert.NotNull(vm.StatusMessage); + Assert.Contains("Configuration manquante", vm.StatusMessage); + Assert.Contains("postit-settings.json", vm.StatusMessage); + Assert.True(string.IsNullOrEmpty(vm.AccessToken)); + } + + [Fact] + public async Task LoginAsync_works_when_authority_has_trailing_slash() + { + // Regression: with Authority ending in "/" (the production + // postit-settings.json shape for https://yavsc.pschneider.fr/), + // the discovery URL OidcClient computes must NOT contain a + // double slash before /.well-known/openid-configuration. The + // stub advertises itself without the trailing slash; OidcClient + // must bridge. + using var authority = await OIDCStubAuthority.StartAsync(); + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = authority.Issuer + "/", + ClientId = "postit-tests" + }, + RedirectUri = authority.LoopbackRedirectUri, + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, browser.CreateBrowser); + + await vm.LoginAsync(); + + Assert.True( + !string.IsNullOrEmpty(vm.AccessToken), + $"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? ""}"); + } + + [Fact] + public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://yavsc.example.com/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings); + + // Trailing slash on Authority is normalised away. + Assert.Equal( + "https://yavsc.example.com/Account/Register", + vm.RegisterUrl); + Assert.Equal( + "https://yavsc.example.com/Account/ForgotPassword", + vm.ForgotPasswordUrl); + Assert.True(vm.HasRegisterUrl); + Assert.True(vm.HasForgotPasswordUrl); + } + + [Fact] + public void RegisterUrl_is_empty_when_authority_is_unset() + { + var vm = new LoginPageViewModel(new PostIt.Settings()); + Assert.Equal(string.Empty, vm.RegisterUrl); + Assert.Equal(string.Empty, vm.ForgotPasswordUrl); + Assert.False(vm.HasRegisterUrl); + Assert.False(vm.HasForgotPasswordUrl); + } + + [Fact] + public void ConfigMissing_is_true_when_authority_is_unset() + { + var vm = new LoginPageViewModel(new PostIt.Settings()); + Assert.True(vm.ConfigMissing); + Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage); + } + + [Fact] + public void ConfigMissing_is_false_when_authority_is_set() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://yavsc.example.com/", + ClientId = "postit-tests" + } + }; + var vm = new LoginPageViewModel(settings); + Assert.False(vm.ConfigMissing); + } + + [Theory] + [InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")] + [InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")] + [InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")] + public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected) + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings { Authority = authority } + }; + var vm = new LoginPageViewModel(settings); + Assert.Equal(expected, vm.DiscoveryUrl); + // ExternalUrl is the slash-normalised form of Authority. + Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl); + } + + [Fact] + public void DiscoveryUrl_is_empty_when_authority_is_unset() + { + var vm = new LoginPageViewModel(new PostIt.Settings()); + Assert.Equal(string.Empty, vm.DiscoveryUrl); + } + + [Fact] + public async Task LoginAsync_failure_message_includes_discovery_url() + { + // Arrange: settings point at an unreachable authority; the test + // browser throws synchronously to guarantee the catch branch runs. + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://does-not-exist.invalid/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom")); + + // Act + await vm.LoginAsync(); + + // Assert: the surfaced error mentions the canonical discovery URL, + // so it can be copy-pasted into a browser to diagnose reachability. + Assert.NotNull(vm.StatusMessage); + Assert.StartsWith("Error:", vm.StatusMessage); + Assert.Contains( + "https://does-not-exist.invalid/.well-known/openid-configuration", + vm.StatusMessage); + } + + [Fact] + public async Task LoginAsync_reports_discovery_url_when_no_browser_available() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://yavsc.example.com/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, () => null); + + await vm.LoginAsync(); + + Assert.Contains( + "https://yavsc.example.com/.well-known/openid-configuration", + vm.StatusMessage); + } +} diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs deleted file mode 100644 index c76115d7..00000000 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Avalonia.VisualTree; -using PostIt.Models; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; -namespace PostIt.Tests; - -/// -/// Headless UI tests for the "Save" flow in . -/// The pattern is the one SessionStatusBannerTests -/// established: [AvaloniaFact], a -/// hosting the page (via a because -/// MainPage is a ContentPage), then drive the -/// controls through their public surface and assert on what -/// saw go on the wire. -/// -/// The bug we are pinning: the title TextBox is -/// currently {Binding SelectedPost.Title, Mode=TwoWay}. -/// When SelectedPost is null (i.e. the user has not yet -/// clicked an item in the posts list — which is the only state -/// in which a brand-new post can be created), the binding has -/// no target and the user's keystrokes are silently dropped. -/// Clicking "Save" then routes to the VM branch -/// if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } } -/// which the controller rejects with 400 "The Title field is -/// required." This test fails on that branch today and will -/// pass once the VM owns a dedicated Title/Article -/// buffer that the XAML binds to and the Save command consumes. -/// -public class MainPageSaveTests -{ - [AvaloniaFact] - public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body() - { - // Arrange: VM with a recording API client, mounted in a - // headless window via a Frame (MainPage is a ContentPage, - // not a Control, so it needs a navigation host). - var recorder = new CallRecorder(); - var api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api); - var viewModel = new MainPageViewModel(blog); - - var page = new MainPage { DataContext = viewModel }; - // MainPage is a ContentPage (a Page, not a Control), so it - // must be hosted in a navigation surface. The production - // MainWindow.axaml uses NavigationPage, and the API is the - // same one App.axaml.cs drives at boot (PushAsync, fire- - // and-forget in prod because the page is the top of the - // stack immediately). - var nav = new NavigationPage(); - _ = nav.PushAsync(page); - var window = new Window { Content = nav }; - window.Show(); - - // Act: type a title into the editor's TextBox without - // first selecting a post in the list — the only state in - // which a new post can be created. Then click Save. - var titleBox = window.GetVisualDescendants() - .OfType() - .First(t => t.PlaceholderText == "Title"); - const string typed = "Mon premier billet"; - titleBox.Text = typed; - - var saveButton = window.GetVisualDescendants() - .OfType