Compare commits

...

2 commits

Author SHA1 Message Date
a44c04ad77
feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user
Bundled end-of-branch commit on feat/postit-acl-members.

PostIt UI for circles + per-post ACL
- Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders
  (Bearer/OIDC scope tests vs. blog API fakes live where they
  belong) and introduces PostItHeadlessCollection so the
  Avalonia.Headless tests share a single xUnit collection
  instead of contending with the EF-Core test host.
- Adds BlogAclApiTests (a brand-new behavioural layer over
  POST /api/v1/blogacl) and the fakes it relies on
  (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember
  DialogTests); pulls UserId-through-OIDC-sub path into
  BearerScopeTests / FakeAuthorizingBrowser /
  OidcStubAuthority.
- App.axaml.cs gets a small PushPageAsync touch-up the new
  tests rely on.
- Drops UnitTest1.cs (xUnit scaffold, never used).

Yavsc.Blogs.Tests — SQLite instead of InMemory
- Bumps Yavsc.Blogs.Tests.csproj on
  Microsoft.EntityFrameworkCore.Sqlite and rewrites
  BlogsWebServerFixture to hold a single shared
  SqliteConnection (Cache=Shared) for the fixture lifetime,
  with a sync Dispose close to dodge async teardown hangs.
  Reason: the EF Core InMemory provider silently ignores FKs,
  which masked the kind of bug we are about to pin in the
  ACL tests. SQLite enforces them, so any future INSERT that
  forgets to seed its parent rows fails loudly here instead
  of passing the test and breaking prod.
- PublishEndpointTests and BlogApiSmokeTests get a one-line
  tweak to follow the new connection lifecycle.

Foreign-key fallout: seed the default user in the fixture
- Adds BlogsWebServerFixture.SeedUser(userName). Now that
  SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every
  test that POST/PUT/DELETE a BlogPost and sends AuthorId=
  'tester' in the payload needs an AspNetUsers row to satisfy
  the FK or it returns 500 with SQLite Error 19.
- BlogApiTests wraps the existing ResetDatabase with a
  ResetAndSeedDefaultUser helper for the six mutating tests;
  the four GET-only and ModelState-only tests keep the bare
  ResetDatabase.
- Side benefit: every test in Yavsc.Blogs.Tests now finishes
  cleanly instead of hanging at teardown — previously a stuck
  test held the shared SqliteConnection open and the next
  tests waited indefinitely.

Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25
green from a clean run, no fixture teardown hang.
2026-08-20 23:59:21 +01:00
6825f74308
refacto API prefix + nav.back 2026-08-20 20:50:52 +01:00
95 changed files with 1337 additions and 443 deletions

View file

@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />

View file

@ -1,4 +1,4 @@
APP_PROJECT_NAMES=Api Org Blogs
APP_PROJECT_NAMES=Org Blogs
SLNDIR=..
include $(SLNDIR)/.env
@ -7,7 +7,6 @@ include .env
generated/:
@mkdir -p $@
generated/yavscApi.service:
generated/yavscOrg.service:
generated/yavscBlogs.service:
@ -34,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@
copy-services: copy-service-Org copy-service-Api copy-service-Blogs
copy-services: copy-service-Org copy-service-Blogs
copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Api: /etc/systemd/system/yavscApi.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-services
copy-binaries: build_publish_Org build_publish_Blogs stop-services
@for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \
@ -86,7 +84,6 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@ -95,4 +92,4 @@ showConfig:
clean:
@rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean

View file

@ -0,0 +1,145 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless coverage for the two interactive buttons of the
/// "add a circle member" modal: "Ajouter" and "Fermer".
///
/// <para>The dialog is pushed on top of <see cref="CirclesPage"/>
/// via the canonical <c>App.PushPageAsync</c> pipeline (the
/// same path <c>CirclesPageViewModel.OpenAddMemberAsync</c>
/// uses). The test asserts on <c>NavRoot.NavigationStack</c>
/// size before and after each click — the user's bug was "I
/// click and nothing happens", so the failure mode is a stack
/// that doesn't shrink for "Fermer", and a "Confirmer" event
/// that the host doesn't pick up for "Ajouter" (the dialog
/// stays up = stack doesn't shrink either).</para>
///
/// <para>Pattern follows <c>MainPageButtonsTests</c>: name
/// every interactive control in XAML with <c>x:Name</c>,
/// click via <c>button.Command?.Execute(...)</c> + flush
/// any async command before asserting.</para>
/// </summary>
[Collection("PostIt Headless")]
public class AddCircleMemberDialogTests
{
private PostItHeadlessCollection fixture;
public AddCircleMemberDialogTests(PostItHeadlessCollection fixture, ITestOutputHelper output)
{
this.fixture = fixture;
}
/// <summary>
/// Stand-in <see cref="IUserDirectory"/> that returns an
/// empty list. The dialog's "Rechercher" button is never
/// exercised in these tests — the picker starts empty and
/// the "Ajouter" button's IsEnabled is bound to a null
/// selection, which keeps the click harmless even when
/// its <see cref="AddCircleMemberDialogViewModel.Add"/>
/// command does fire.
/// </summary>
private sealed class StubUserDirectory : IUserDirectory
{
public Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<UserSummary>>(new List<UserSummary>());
}
private sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}
/// <summary>
/// Mount a real <see cref="MainWindow"/>, build a minimal
/// DI graph, push <see cref="CirclesPage"/> then the
/// <see cref="AddCircleMemberDialog"/> on top of it.
/// Returns the stack size so the test can pin the delta.
/// The graph exposes <c>IUserDirectory</c> (so the dialog
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
/// (so <c>ViewLocator</c> can resolve it from the VM).
/// </summary>
private static (MainWindow window,
CirclesPage page,
AddCircleMemberDialog dialog)
Mount()
{
var api = new ThrowingApi();
var circleClient = new CircleApiClient(api, "http://localhost/");
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton<IUserDirectory>(new StubUserDirectory());
services.AddSingleton(circleClient);
services.AddTransient<CirclesPage>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<AddCircleMemberDialog>();
services.AddTransient<AddCircleMemberDialogViewModel>();
var sp = services.BuildServiceProvider();
var window = new MainWindow();
var app = (PostIt.App)Application.Current!;
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(sp));
app.AttachMainWindow(window);
window.Show();
var circlesPage = sp.GetRequiredService<CirclesPage>();
window.NavRoot.PushAsync(circlesPage).GetAwaiter().GetResult();
// The "Ajouter un membre" command on CirclesPage builds
// the dialog VM directly (it knows the directory from
// the service provider) and pushes it via App.PushPage.
var dialogVm = new AddCircleMemberDialogViewModel(sp.GetRequiredService<IUserDirectory>());
((App)Application.Current!).PushPageAsync(dialogVm).GetAwaiter().GetResult();
var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog
?? throw new System.InvalidOperationException("Dialog page not at top of stack.");
return (window, circlesPage, dialog);
}
/// <summary>
/// Click the "Fermer" button on the dialog and assert the
/// nav stack shrinks by exactly one.
/// </summary>
[AvaloniaFact]
public void Close_button_pops_dialog_off_nav_stack()
{
// Arrange: stack starts at 2 (CirclesPage + dialog).
var window = fixture.Window;
var stackBefore = window.NavRoot.NavigationStack.Count;
Assert.Equal(2, stackBefore);
// Act
var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog ?? throw new System.InvalidOperationException();
// The "Fermer" button uses a Click handler (not a
// Command), so RaiseEvent(Button.ClickEvent) is the
// right way to fire it from headless code. Executing
// Command would no-op because no Command is bound.
dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
// Assert: stack -1, the top is the CirclesPage again.
Assert.True(window.NavRoot.NavigationStack.Count == stackBefore - 1,
$"Click on 'Fermer' must shrink the nav stack by one. Before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
Assert.IsType<CirclesPage>(window.NavRoot.NavigationStack[^1]);
}
}

View file

@ -64,3 +64,29 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
return Task.FromResult(default(T)!);
}
}
/// <summary>
/// <see cref="YavscApiClient"/> stand-in whose constructor
/// points at <c>https://stub.invalid</c> so any HTTP traffic
/// that escapes a test (misconfigured command, missing fake
/// handler) raises a clear <see cref="System.Net.Http.HttpRequestException"/>
/// instead of silently hitting a real endpoint. Used by tests
/// that don't actually exercise the API client (they click a
/// button, assert on the nav stack, end of story) but whose
/// VMs require one in their constructor.
/// </summary>
internal sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}

View file

@ -1,6 +1,4 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
@ -50,43 +48,43 @@ namespace PostIt.Tests;
/// <item>"[DEV] Signature" — click pushes a page onto the
/// stack.</item>
/// </list>
///
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
/// owns the <see cref="MainWindow"/> and the production DI graph.
/// Each test builds a local <see cref="ServiceCollection"/> with
/// the fake <see cref="YavscApiClient"/> + the page VMs and
/// registers the destination pages, then swaps it in via
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
/// fixture re-attaches the ViewLocator and the MainWindow so
/// subsequent <see cref="App.PushPageAsync"/> calls route through
/// the overridden graph.</para>
/// </summary>
public class MainPageButtonsTests
[Collection("PostIt Headless")]
public sealed class MainPageButtonsTests
{
/// <summary>
/// Fake <see cref="YavscApiClient"/> that throws on any
/// wire call. These tests never invoke a command that hits
/// the API — only the click → nav side of the pipeline is
/// asserted.
/// </summary>
private sealed class ThrowingApi : YavscApiClient
private readonly PostItHeadlessCollection _host;
public MainPageButtonsTests(PostItHeadlessCollection host)
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
_host = host;
}
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
/// <summary>
/// Build the test DI graph: <see cref="ThrowingApi"/> for
/// the API clients (the click tests never hit the wire;
/// any traffic would be a wiring bug), the real
/// <see cref="BlogApiClient"/> / <see cref="CircleApiClient"/>
/// / <see cref="BlogAclApiClient"/> that the page VM
/// resolves, and the page + dialog + VM registrations the
/// <see cref="ViewLocator"/> needs to resolve the three
/// push targets.
/// </summary>
private MainPageViewModel BuildViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves
// when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this
// suite to avoid side effects from App.BuildServices() (real
// token-store wiring).
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton(circle);
@ -96,51 +94,31 @@ public class MainPageButtonsTests
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
var sp = services.BuildServiceProvider();
var vm = new MainPageViewModel(blog, services: sp);
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
/// <summary>
/// Mount a real <see cref="MainWindow"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its
/// named buttons. The window is shown so the visual tree is
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// Push a <see cref="MainPage"/> with the given VM onto
/// the shared <see cref="MainWindow"/>'s nav stack. Clears
/// any pages the previous test left behind (the fixture's
/// MainWindow is shared across every test class). Returns
/// the live page so the test can access its named buttons.
/// </summary>
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
private MainPage MountAsync(MainPageViewModel vm)
{
var window = new MainWindow();
var page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
if (vm.Services is not null)
{
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(vm.Services));
}
app.AttachMainWindow(window);
window.Show();
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page);
_host.PushAsync(page);
return page;
}
/// <summary>
/// Click a button by focusing it and pressing Enter — the
/// supported headless pattern (cf. CalculatorTests in the
/// Avalonia.Samples repo). Returns the nav-stack count
/// before the click so the caller can assert on the delta.
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
/// itself — it is the <see cref="TopLevel"/> that owns the
/// headless implementation, and routing the key through any
/// descendant TopLevel (e.g. one obtained via
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
/// <c>NullReferenceException</c> from the headless impl
/// because the descendant does not carry the
/// <c>PlatformHandle</c> the harness expects.
/// Click a button by executing its <see cref="Button.Command"/>
/// and draining any <see cref="IAsyncRelayCommand"/> so the
/// caller can assert on the resulting nav stack immediately.
/// </summary>
private static int ClickAndCapture(MainWindow window, Button button)
{
@ -165,8 +143,8 @@ public class MainPageButtonsTests
Title = "An existing post",
AuthorId = "u-alice"
};
var vm = MakeViewModel(post);
var (window, page) = MountMainPage(vm);
var vm = BuildViewModel(post);
var page = MountAsync(vm);
// Sanity: the button's command is bound and CanExecute
// is true. If this fails, the bug is upstream (XAML
@ -176,12 +154,12 @@ public class MainPageButtonsTests
Assert.True(aclButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, aclButton);
var stackBefore = ClickAndCapture(_host.Window, aclButton);
// Assert γ + sniff léger: stack grew, new top is a Page.
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {_host.Window.NavRoot.NavigationStack.Count}.");
var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
@ -191,19 +169,19 @@ public class MainPageButtonsTests
{
// Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var vm = BuildViewModel();
var page = MountAsync(vm);
var circlesButton = page.OpenCirclesButton;
Assert.NotNull(circlesButton.Command);
// Act
var stackBefore = ClickAndCapture(window, circlesButton);
var stackBefore = ClickAndCapture(_host.Window, circlesButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
"Click on 'Mes cercles' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
@ -214,25 +192,25 @@ public class MainPageButtonsTests
// Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via
// DI and call App.PushPage; the ViewLocator
// then maps SignaturePageViewModel -> SignaturePage and
// the binding pushes the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
// The ServiceCollection registered in BuildViewModel
// provides SignaturePageViewModel so the command can
// resolve it via DI and call App.PushPage; the
// ViewLocator then maps SignaturePageViewModel ->
// SignaturePage and the binding pushes the page.
var vm = BuildViewModel();
var page = MountAsync(vm);
var signatureButton = page.OpenSignatureDevButton;
Assert.NotNull(signatureButton.Command);
Assert.True(signatureButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, signatureButton);
var stackBefore = ClickAndCapture(_host.Window, signatureButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}

View file

@ -1,22 +1,22 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
/// The pattern is the one <c>SessionStatusBannerTests</c>
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// hosting the page (via a <see cref="Frame"/> because
/// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
/// controls through their public surface and assert on what
/// <see cref="RecordingYavscApiClient"/> saw go on the wire.
/// Uses the shared <see cref="PostItHeadlessFixture"/> (a real
/// <see cref="MainWindow"/> with the production DI graph attached
/// to <see cref="App"/>) plus a local
/// <see cref="ServiceCollection"/> that swaps
/// <see cref="YavscApiClient"/> for the recording fake.
///
/// <para>The bug we are pinning: the title <c>TextBox</c> is
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
@ -31,41 +31,38 @@ namespace PostIt.Tests;
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
/// buffer that the XAML binds to and the Save command consumes.</para>
/// </summary>
public class MainPageSaveTests
[Collection("PostIt Headless")]
public sealed 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, "http://localhost/");
var viewModel = new MainPageViewModel(blog);
private readonly PostItHeadlessCollection _host;
public MainPageSaveTests(PostItHeadlessCollection host)
{
_host = host;
}
[AvaloniaFact]
public void Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
{
// Arrange: VM with a recording API client, mounted on
// the shared MainWindow's nav stack.
var recorder = new CallRecorder();
var blog = _host.Services.GetRequiredService<BlogApiClient>();
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();
_host.PushAsync(page);
// 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()
// first selecting a post in the list — the only state
// in which a new post can be created. Then click Save.
var titleBox = _host.Window.GetVisualDescendants()
.OfType<TextBox>()
.First(t => t.PlaceholderText == "Title");
const string typed = "Mon premier billet";
titleBox.Text = typed;
var saveButton = window.GetVisualDescendants()
var saveButton = _host.Window.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Save");
saveButton.Command!.Execute(null);
@ -75,7 +72,11 @@ public class MainPageSaveTests
// task on the dispatcher. Give the dispatcher a chance to
// run so the awaited CallAsync has actually fired before
// we inspect the recorder.
await Task.Delay(200);
var deadline = DateTime.UtcNow.AddSeconds(2);
while (recorder.Calls.Count == 0 && DateTime.UtcNow < deadline)
{
Task.Delay(20).GetAwaiter().GetResult();
}
// Assert: the first POST to "blog" carried a BlogPostDto
// whose Title is exactly what the user typed. The bug

View file

@ -0,0 +1,124 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the user-reported bug:
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
/// dialog opened (the dropdown showed "Choisir un cercle..." and
/// the list was blank, with no error to hint at why).
///
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
/// to trigger <c>LoadAsync</c> on the first
/// <c>DataContextChanged</c>, and the VM guards re-entry via
/// <c>_loaded</c>. Two tests pin that contract:</para>
/// <list type="bullet">
/// <item><c>LoadAsync_runs_once_on_DataContext_changed</c>: HTTP
/// traffic shows up after the dialog is mounted.</item>
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
/// once (the <c>_loaded</c> gate).</item>
/// </list>
///
/// <para>HTTP is stubbed with a counter
/// <see cref="HttpMessageHandler"/> that returns canned JSON
/// <c>[]</c> for every request. The handler counts calls so the
/// tests can assert "exactly one round-trip on mount" and
/// "exactly one round-trip after two calls to LoadAsync". This
/// is the same shape used by <c>BearerScopeTests</c>: real
/// <see cref="YavscApiClient"/> subclass, real
/// <see cref="HttpClient"/> with an injected handler, real
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
/// talking to it.</para>
///
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
/// provides the <see cref="MainWindow"/> already wired to
/// <see cref="App"/>. Each test builds its own DI graph with
/// the counting HTTP handler and swaps it in via
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
/// graph exposes <c>PostAclDialog</c> so the
/// <see cref="ViewLocator"/> resolves it from
/// <see cref="PostAclDialogViewModel"/>.</para>
/// </summary>
[Collection("PostIt Headless")]
public sealed class PostAclDialogTests
{
private readonly PostItHeadlessCollection _host;
public PostAclDialogTests(PostItHeadlessCollection host)
{
_host = host;
}
/// <summary>
/// <see cref="HttpMessageHandler"/> that replies 200 with
/// <c>[]</c> (a valid JSON empty array, which both
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
/// deserialize) and counts the number of requests.
/// </summary>
private sealed class CountingHttpHandler : HttpMessageHandler
{
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. Same recipe as
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
/// override <c>CallAsync{T}</c> to talk to our own
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
/// because the load-on-attach bug has nothing to do with
/// token refresh.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
public TestableYavscApiClient(
Settings settings,
TokenStore store,
HttpMessageHandler handler)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
}

View file

@ -0,0 +1,177 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// xUnit collection grouping every headless UI test in
/// <c>PostIt.Tests</c>. The Avalonia headless harness instantiates
/// a single <see cref="PostItHeadlessFixture"/> per test class
/// (<c>IClassFixture&lt;PostItHeadlessFixture&gt;</c>); the
/// collection marker here exists for two reasons:
///
/// <list type="bullet">
/// <item><description>It documents the shared lifecycle
/// contract: every test class that opts in gets the same
/// <see cref="MainWindow"/>, the same DI service provider,
/// the same <see cref="ViewLocator"/> on
/// <see cref="Application.DataTemplates"/>, and the same
/// <see cref="PostIt.App.MainWindow"/> attachment that
/// <c>App.PushPageAsync</c> relies on.</description></item>
/// <item><description>It disables parallelisation across the
/// whole collection. The Avalonia headless platform is
/// process-global (one <see cref="Application.Current"/> per
/// process, one dispatcher per thread), so two collection
/// members running in parallel would race on the same
/// static state and produce flaky failures with no useful
/// diagnostic. Same rationale as
/// <c>JwtClaimMappingCollection</c>.</description></item>
/// </list>
///
/// Mirrors the convention used by
/// <c>Yavsc.Org.Tests.WebServerFixture</c> (collection
/// <c>"Yavsc Server"</c>) and
/// <c>Yavsc.Blogs.Tests.JwtClaimMappingCollection</c>.
/// </summary>
[CollectionDefinition("PostIt Headless")]
public sealed class PostItHeadlessCollection: IDisposable
{
/// <summary>The DI service provider the fixture booted
/// (production graph from <see cref="App.BuildServices"/>).
/// Identical across every <see cref="PostItHeadlessFixture"/>
/// instance — see class remarks.</summary>
public IServiceProvider Services { get; private set; }
/// <summary>The headless <see cref="MainWindow"/> for this
/// fixture instance. Already <see cref="WindowBase.Show"/>n,
/// so its visual tree is realised and
/// <see cref="Button.Command"/> bindings have been
/// evaluated. The window is per-instance, not
/// process-shared, so each test class gets a clean nav
/// stack out of the box.</summary>
public MainWindow Window { get; private set; }
/// <summary>The <see cref="App"/> instance the Avalonia
/// headless harness set as <see cref="Application.Current"/>.
/// Convenience accessor for tests that need to call
/// <c>App.PushPageAsync</c> directly.</summary>
public App App { get; private set; }
/// <summary>The navigation surface the
/// <see cref="MainWindow"/> hosts. Tests can read
/// <c>NavigationStack</c> directly or call
/// <see cref="PushAsync"/> to push onto it.</summary>
public NavigationPage NavRoot { get; private set; }
public PostItHeadlessCollection()
{
// First fixture to construct in this process:
// build the production DI container and attach
// it to the App.
App = (App)Application.Current!;
var testingServices = new ServiceCollection();
var api = new RecordingYavscApiClient(new CallRecorder());
var blog = new BlogApiClient(api, "http://localhost/");
// The recording fake is sufficient on its own — no
// production wiring needed. Swap it in via the fixture
// so the App.PushPageAsync path resolves the same way
// it would in production (minus the token store).
testingServices.AddSingleton(api);
testingServices.AddSingleton(blog);
Services = App.BuildServices(testingServices);
App.AttachServiceProvider(Services);
// The ViewLocator is the single entry point
// App.PushPageAsync uses to map a VM to a Page.
App.DataTemplates.Clear();
App.DataTemplates.Add(new ViewLocator(Services));
// Per-instance window: each test class gets its own.
Window = new MainWindow();
App.AttachMainWindow(Window);
Window.Show();
}
/// <summary>
/// Push a view model or page onto <see cref="NavRoot"/>.
/// Awaits the push asynchronously so the headless
/// dispatcher can pump frames while the push is in flight;
/// the caller can then assert on the resulting stack
/// (<c>NavRoot.NavigationStack[^1]</c>).
/// </summary>
/// <remarks>
/// Tests that want a clean stack (most of them, since
/// the fixture's <see cref="MainWindow"/> is shared
/// across every test class) should call
/// <see cref="ClearNavigationStack"/> before pushing, or
/// use <see cref="MountAsync"/> which clears by default.
/// </remarks>
/// <returns>The page that was pushed, so the caller can
/// assert on its type or bind a <c>DataContext</c>.</returns>
public Page PushAsync(object vmOrPage)
{
if (vmOrPage is null) throw new ArgumentNullException(nameof(vmOrPage));
// Synchronous push: the Avalonia headless
// NavigationPage.PushAsync returns a Task that
// completes once the transition animation finishes,
// and in headless that animation is driven by the
// dispatcher pump. We block on the Task with
// GetAwaiter().GetResult() rather than awaiting it
// because the test body is itself running on the
// dispatcher thread (the [AvaloniaFact] attribute
// schedules the test there); an await would capture
// the dispatcher as the continuation target and
// deadlock waiting for the push to complete on a
// thread that's already busy running the test.
if (vmOrPage is Page page)
{
Window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
// Pump the dispatcher once so the pushed page
// is actually on NavigationStack (the Awaiter
// above unblocks before the stack is updated).
Dispatcher.UIThread.RunJobs();
return page;
}
// VM push: route through the production
// App.PushPageAsync pipeline.
var app = (App)Application.Current!;
var vm = (ViewModelBase)vmOrPage;
app.PushPageAsync(vm).GetAwaiter().GetResult();
Dispatcher.UIThread.RunJobs();
return Window.NavRoot.NavigationStack[^1];
}
public void Dispose()
{
// Last fixture out: tear the shared state down so
// the next test run starts clean. We don't shut
// down the Avalonia headless platform — that's
// owned by the [AvaloniaTestApplication] attribute
// on TestAppBuilder and gets torn down when the
// process exits.
try
{
var app = (App)Application.Current!;
app.DataTemplates.Clear();
}
catch { /* best effort */ }
Services = null;
}
}

View file

@ -1,46 +1,54 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// a real <see cref="MainWindow"/> via the headless Avalonia
/// platform declared in <c>TestApp.cs</c>.
/// UI tests for <see cref="SessionStatusBanner"/>. The shared
/// <see cref="PostItHeadlessFixture"/> provides the headless
/// <see cref="MainWindow"/> already attached to <see cref="App"/>
/// and shown, so each test only has to wire its
/// <see cref="SessionStatusViewModel"/> onto
/// <c>MainWindow.SessionBanner</c> and assert on the rendered
/// tree.
///
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
/// established: a test attribute <c>[AvaloniaFact]</c> (from
/// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
/// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
/// attribute schedules the test body inside a dispatcher, which
/// is the precondition for the headless Window's
/// <para>The session banner's <c>DataContext</c> is not wired
/// by <see cref="App.OnFrameworkInitializationCompleted"/> in
/// these tests: production wires it at composition time, but a
/// unit test runs against a freshly-built <see cref="App"/> so
/// we set the <c>DataContext</c> on the banner directly. The
/// production code path is exercised end-to-end by the manual
/// launch, not here.</para>
///
/// <para>Pattern: <c>[AvaloniaFact]</c> (from
/// <c>Avalonia.Headless.XUnit</c>) instead of plain
/// <c>[Fact]</c> because the AvaloniaFact attribute schedules
/// the test body inside a dispatcher, which is the precondition
/// for the headless Window's
/// <c>PlatformManager.CreateWindow()</c> to find a registered
/// service. A plain <c>[Fact]</c> test that calls
/// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para>
///
/// <para>The session banner's <c>DataContext</c> is not wired in
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// end-to-end by the manual launch, not here.</para>
/// <c>new Window().Show()</c> throws because the harness has
/// not been initialised for that thread.</para>
/// </summary>
public class SessionStatusBannerTests
[Collection("PostIt Headless")]
public sealed class SessionStatusBannerTests
{
private readonly PostItHeadlessCollection _host;
public SessionStatusBannerTests(PostItHeadlessCollection host)
{
_host = host;
}
[AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var banner = _host.Window.SessionBanner;
banner.DataContext = new SessionStatusViewModel();
var buttons = window.SessionBanner.GetVisualDescendants()
var buttons = banner.GetVisualDescendants()
.OfType<Button>()
.ToList();
@ -57,31 +65,30 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out()
{
var window = new MainWindow();
var banner = _host.Window.SessionBanner;
var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
banner.DataContext = vm;
var login = window.SessionBanner.GetVisualDescendants()
var login = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se connecter");
// The XAML binds IsVisible to IsLoggedOut. After Show,
// the binding has been evaluated.
// The XAML binds IsVisible to IsLoggedOut. After the
// banner is on the realised visual tree, the binding
// has been evaluated.
Assert.True(login.IsVisible);
}
[AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out()
{
var window = new MainWindow();
var banner = _host.Window.SessionBanner;
var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
banner.DataContext = vm;
var logout = window.SessionBanner.GetVisualDescendants()
var logout = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter");
@ -91,11 +98,10 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var banner = _host.Window.SessionBanner;
banner.DataContext = new SessionStatusViewModel();
var settings = window.SessionBanner.GetVisualDescendants()
var settings = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Paramètres");
@ -108,11 +114,10 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_session_label_reflects_DataContext()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var banner = _host.Window.SessionBanner;
banner.DataContext = new SessionStatusViewModel();
var label = window.SessionBanner.GetVisualDescendants()
var label = banner.GetVisualDescendants()
.OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");

View file

@ -1,16 +0,0 @@
using Avalonia.Headless.XUnit;
using Avalonia.Controls;
using PostIt.Views;
namespace PostIt.Tests;
public class MainPageTests
{
[AvaloniaFact]
public void MainPage_Should_Load()
{
var window = new MainWindow();
window.Show();
Assert.NotNull(window);
}
}

94
src/PostIt.Tests/pslist Normal file
View file

@ -0,0 +1,94 @@
UID PID PPID C STIME TTY TIME CMD
paul 1155 1 0 13:18 ? 00:00:00 /usr/lib/systemd/systemd --user
paul 1168 1155 0 13:18 ? 00:00:00 (sd-pam)
paul 1361 1155 0 13:18 ? 00:00:00 /usr/bin/dbus-daemon --session --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
paul 1364 1155 1 13:18 ? 00:01:19 /home/paul/.nvm/versions/node/v22.23.0/bin/node /home/paul/.nvm/versions/node/v22.23.0/lib/node_modules/openclaw/dist/index.js gateway --port 18789
paul 1367 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire
paul 1372 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire -c filter-chain.conf
paul 1373 1155 0 13:18 ? 00:00:00 /usr/bin/wireplumber
paul 1374 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire-pulse
paul 1444 1155 0 13:18 ? 00:00:00 /usr/bin/mpris-proxy
paul 2593 1155 0 13:19 ? 00:00:00 /usr/bin/gnome-keyring-daemon --foreground --components=pkcs11,secrets --control-directory=/run/user/1000/keyring
paul 2608 2487 0 13:19 tty2 00:00:00 /usr/libexec/gdm-x-session --run-script /usr/bin/gnome-session
paul 2617 2608 1 13:19 tty2 00:01:12 /usr/lib/xorg/Xorg vt2 -displayfd 3 -auth /run/user/1000/gdm/Xauthority -nolisten tcp -background none -noreset -keeptty -novtswitch -verbose 3
paul 2647 2608 0 13:19 tty2 00:00:00 /usr/libexec/gnome-session-binary
paul 2785 1155 0 13:19 ? 00:00:00 /usr/libexec/at-spi-bus-launcher
paul 2792 2785 0 13:19 ? 00:00:00 /usr/bin/dbus-daemon --config-file=/usr/share/defaults/at-spi2/accessibility.conf --nofork --print-address 11 --address=unix:path=/run/user/1000/at-spi/bus_1
paul 2802 1155 0 13:19 ? 00:00:00 /usr/libexec/gcr-ssh-agent --base-dir /run/user/1000/gcr
paul 2803 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-session-ctl --monitor
paul 2804 1155 0 13:19 ? 00:00:00 /usr/bin/ssh-agent -D
paul 2814 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfsd
paul 2828 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfsd-fuse /run/user/1000/gvfs -f
paul 2838 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-session-binary --systemd-service --session=gnome
paul 2874 1155 3 13:19 ? 00:02:13 /usr/bin/gnome-shell
paul 2896 2874 0 13:19 ? 00:00:01 /usr/libexec/mutter-x11-frames
paul 2902 1155 0 13:19 ? 00:00:00 /usr/libexec/at-spi2-registryd --use-gnome-session
paul 2918 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-desktop-portal
paul 2933 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-permission-store
paul 2938 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-document-portal
paul 2971 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-shell-calendar-server
paul 2976 1155 0 13:19 ? 00:00:00 /usr/libexec/dconf-service
paul 2992 1155 0 13:19 ? 00:00:00 /usr/libexec/evolution-source-registry
paul 2994 1155 0 13:19 ? 00:00:00 /usr/bin/gjs -m /usr/share/gnome-shell/org.gnome.Shell.Notifications
paul 3012 1155 0 13:19 ? 00:00:12 /usr/bin/ibus-daemon --panel disable --xim
paul 3013 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-a11y-settings
paul 3014 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-color
paul 3015 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-datetime
paul 3016 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-housekeeping
paul 3018 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-keyboard
paul 3024 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-media-keys
paul 3025 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-power
paul 3027 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-print-notifications
paul 3029 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-rfkill
paul 3030 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-screensaver-proxy
paul 3035 2838 0 13:19 ? 00:00:05 /usr/bin/gnome-software --gapplication-service
paul 3037 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-sharing
paul 3042 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-smartcard
paul 3048 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-sound
paul 3054 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-usb-protection
paul 3057 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-wacom
paul 3058 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-xsettings
paul 3059 2838 0 13:19 ? 00:00:00 /usr/libexec/evolution-data-server/evolution-alarm-notify
paul 3064 2838 0 13:19 ? 00:00:00 /usr/bin/kalendarac
paul 3070 2838 0 13:19 ? 00:00:00 /usr/libexec/gsd-disk-utility-notify
paul 3088 2838 0 13:19 ? 00:00:00 /usr/bin/kdeconnectd
paul 3168 1155 0 13:19 ? 00:00:00 /usr/bin/gjs -m /usr/share/gnome-shell/org.gnome.ScreenSaver
paul 3172 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-printer
paul 3207 3012 0 13:19 ? 00:00:00 /usr/libexec/ibus-memconf
paul 3208 3012 0 13:19 ? 00:00:06 /usr/libexec/ibus-extension-gtk3
paul 3214 1155 0 13:19 ? 00:00:00 /usr/libexec/ibus-x11 --kill-daemon
paul 3216 1155 0 13:19 ? 00:00:00 /usr/libexec/ibus-portal
paul 3218 1155 0 13:19 ? 00:00:00 /usr/libexec/localsearch-3
paul 3219 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-desktop-portal-gnome
paul 3241 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-udisks2-volume-monitor
paul 3251 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-mtp-volume-monitor
paul 3259 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-gphoto2-volume-monitor
paul 3265 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfs-goa-volume-monitor
paul 3271 1155 0 13:20 ? 00:00:00 /usr/libexec/goa-daemon
paul 3280 1155 0 13:20 ? 00:00:00 /usr/libexec/goa-identity-service
paul 3287 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfs-afc-volume-monitor
paul 3303 3012 0 13:20 ? 00:00:02 /usr/libexec/ibus-engine-simple
paul 3372 1155 0 13:20 ? 00:00:00 /usr/libexec/xdg-desktop-portal-gtk
paul 3441 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfsd-metadata
paul 3453 1155 0 13:20 ? 00:00:00 /usr/libexec/evolution-calendar-factory
paul 3495 1155 0 13:20 ? 00:00:00 /usr/libexec/evolution-addressbook-factory
paul 4798 1155 0 13:26 ? 00:00:09 /usr/libexec/gnome-terminal-server
paul 4810 4798 0 13:26 pts/0 00:00:00 bash
paul 8614 1155 0 13:29 ? 00:00:01 /usr/bin/speech-dispatcher -s -t 0
paul 8656 8614 0 13:29 ? 00:00:00 [sd_espeak-ng-mb] <defunct>
paul 8709 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_espeak-ng /etc/speech-dispatcher/modules/espeak-ng.conf
paul 8785 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_dummy /etc/speech-dispatcher/modules/dummy.conf
paul 8799 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_espeak-ng /etc/speech-dispatcher/modules/
paul 10028 1155 0 13:31 ? 00:00:00 adb -L tcp:5037 fork-server server --reply-fd 4
paul 69578 2814 0 13:53 ? 00:00:00 /usr/libexec/gvfsd-http --spawner :1.22 /org/gtk/gvfs/exec_spaw/0
paul 108341 1155 3 14:06 ? 00:00:48 /home/paul/.nvm/versions/node/v22.23.0/bin/node /home/paul/.nvm/versions/node/v22.23.0/lib/node_modules/acpx/dist/cli.js __queue-owner
paul 108416 108341 0 14:06 ? 00:00:00 openclaw
paul 108458 108416 2 14:06 ? 00:00:37 openclaw-acp
paul 143553 1155 0 14:19 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpI2JxLw.tmp
paul 149205 1155 0 14:21 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpitRyQG.tmp
paul 151724 1155 0 14:22 ? 00:00:04 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpJEsOZV.tmp
paul 157447 1155 1 14:24 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpyM92DV.tmp
paul 165231 1155 0 14:26 ? 00:00:01 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmp5CKC19.tmp
paul 168472 1155 4 14:27 ? 00:00:09 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpuRJsnQ.tmp
paul 172147 1155 4 14:29 pts/0 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpxT8nje.tmp
paul 172435 4810 99 14:31 pts/0 00:00:00 ps -fu paul

View file

@ -49,7 +49,7 @@ public partial class App : Application
// build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = BuildServices();
this.ServiceProvider = BuildServices(new ServiceCollection());
AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>();
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
@ -139,7 +139,7 @@ public partial class App : Application
/// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod.
/// </summary>
internal static IServiceProvider BuildServices()
internal static IServiceProvider BuildServices(ServiceCollection services)
{
var settings = new Settings();
settings.Load();
@ -156,7 +156,6 @@ public partial class App : Application
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();
@ -350,4 +349,9 @@ public partial class App : Application
return window.NavRoot.PushAsync(page);
}
internal async Task GoBackAsync()
{
await window.NavRoot.PopAsync();
}
}

View file

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
@ -106,7 +107,7 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
/// UI from firing an event with a null payload.
/// </summary>
[RelayCommand]
public void Add()
public async Task AddAsync()
{
if (Selected is null)
{
@ -114,5 +115,14 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
return;
}
Confirmed?.Invoke(this, Selected);
var app = App.Current as App;
await app.GoBackAsync();
}
[RelayCommand]
public async Task CloseAsync()
{
var app = App.Current as App;
await app.GoBackAsync();
}
}

View file

@ -119,6 +119,17 @@ public partial class CirclesPageViewModel : ViewModelBase
var directory = services.GetRequiredService<IUserDirectory>();
AddCircleMemberDialogViewModel model =
new AddCircleMemberDialogViewModel(directory);
// Wire the dialog's Confirmed event to OnAddMemberConfirmedAsync.
// Without this, the dialog's "Ajouter" button fires the event
// into the void: no subscriber, the picked user is silently
// dropped, and nothing is added to the circle. The dialog
// stays open until the user uses the back gesture — which is
// how the user noticed the button was a no-op.
// Async-void is intentional here: Confirmed is an
// EventHandler<T> (returns void), and bridging to the
// async Task OnAddMemberConfirmedAsync requires it.
model.Confirmed += async (_, picked) =>
await OnAddMemberConfirmedAsync(_, picked);
await app.PushPageAsync(model);
}
/// <summary>

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -52,6 +51,22 @@ public partial class PostAclDialogViewModel : ViewModelBase
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Idempotency gate for <see cref="LoadAsync"/>: the dialog
/// attaches the load trigger in <c>DataContextChanged</c>,
/// which can fire more than once if the page is detached
/// and re-attached (dialog re-use, navigation edge cases)
/// with a different VM. Without this guard, the second load
/// would race against the first and could overwrite
/// <see cref="AclEntries"/> mid-edit. Pattern copied from
/// <c>Settings.Load</c>.
/// </summary>
private bool _loaded;
/// <summary>True once <see cref="LoadAsync"/> has run at least
/// once. Exposed for tests; do not bind from XAML.</summary>
public bool Loaded => _loaded;
public PostAclDialogViewModel(
BlogPostDto post,
BlogAclApiClient aclClient,
@ -68,6 +83,8 @@ public partial class PostAclDialogViewModel : ViewModelBase
[RelayCommand]
public async Task LoadAsync()
{
if (_loaded) return;
IsBusy = true;
try
{
@ -83,6 +100,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
}
catch (Exception ex)
{

View file

@ -6,6 +6,7 @@
xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel"
>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<!-- Search box + button -->
@ -29,7 +30,9 @@
<!-- Search results -->
<ListBox Grid.Row="2"
ItemsSource="{Binding Results}"
SelectedItem="{Binding Selected, Mode=TwoWay}">
SelectedItem="{Binding Selected, Mode=TwoWay}"
MinHeight="20"
>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="services:UserSummary">
<StackPanel Spacing="2">
@ -47,11 +50,13 @@
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Ajouter"
Command="{Binding Add}"
x:Name="AddButton"
Command="{Binding AddAsync}"
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fermer"
Click="OnCloseClicked"/>
x:Name="CloseButton"
Command="{Binding CloseAsync}"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,6 +1,7 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
@ -41,8 +42,8 @@ public partial class AddCircleMemberDialog : ContentPage
private void OnCloseClicked(object? sender, RoutedEventArgs e)
{
// Same light-modal pattern as PostAclDialog: rely on
// the system back gesture or the navigation host's
// "pop" — the ContentPage doesn't own the back stack.
var nav = this.FindAncestorOfType<NavigationPage>();
if (nav is not null)
_ = nav.PopAsync();
}
}

View file

@ -1,3 +1,4 @@
using System;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PostIt.ViewModels;
@ -9,21 +10,53 @@ namespace PostIt.Views;
/// <summary>
/// Modal "manage ACL" page for a single blog post.
///
/// <para>The ViewModel is constructed here (not via DI) because it
/// depends on the post being managed, which the caller (the post
/// list page) only knows at the moment it opens the dialog. The
/// DI container can build the two API clients; the post and the
/// VM are wired together here.</para>
/// <para>The ViewModel is constructed by the caller (the post
/// list page) and handed to <see cref="App.PushPageAsync"/>,
/// which routes through <see cref="ViewLocator"/> and lands
/// here via the parameterless DI constructor. The VM is then
/// assigned to <see cref="ContentPage.DataContext"/> by
/// <c>App.PushPageAsync</c> — we listen for that one-shot
/// assignment and trigger <c>LoadAsync</c> right after, so the
/// dropdown's <c>MyCircles</c> and the list's <c>AclEntries</c>
/// are populated when the dialog appears. The VM is idempotent
/// under repeated loads.</para>
/// </summary>
public partial class PostAclDialog : ContentPage
{
public PostAclDialog()
{
InitializeComponent();
// App.PushPageAsync wires the VM via DataContext after
// building the page. We subscribe once to fire LoadAsync
// the moment the VM is attached. Using DataContextChanged
// (rather than AttachedToVisualTree) is what makes this
// work in the headless test harness too: the load is
// tied to the VM being available, not to the visual tree
// being realised (which is a separate concern).
EventHandler? handler = null;
handler = (_, _) =>
{
if (DataContext is PostAclDialogViewModel vm)
{
this.DataContextChanged -= handler;
_ = vm.LoadAsync();
}
};
this.DataContextChanged += handler;
}
public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient)
{
// This overload is not used by the production path —
// MainPageViewModel pushes the VM via App.PushPageAsync
// and App routes through ViewLocator, which resolves this
// page via the parameterless ctor. It is kept so test
// scaffolding that wants to bypass the nav pipeline can
// still wire a VM directly without losing the load
// trigger: the constructor sets DataContext before the
// DataContextChanged subscription fires, so the load
// is guaranteed to run in either case.
InitializeComponent();
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
}

View file

@ -8,8 +8,8 @@ namespace Yavsc.ViewModels.Account
public class RegisterModel
{
[StringLength(YavscConstants.MaxUserNameLength)]
[RegularExpression(YavscConstants.UserNameRegExp)]
[StringLength(Constants.MaxUserNameLength)]
[RegularExpression(Constants.UserNameRegExp)]
[DataType(DataType.Text)]
[Display(Name = "UserName", Description = "User name")]
public string UserName { get; set; }

View file

@ -3,8 +3,10 @@ using Yavsc.Models.Auth;
namespace Yavsc
{
public static class YavscConstants
public static class Constants
{
public const string APIPrefix = "api/v1";
public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" },
new Scope { Id = "book" , Description ="Your booking interface"},

View file

@ -19,7 +19,7 @@ namespace Yavsc.Abstract.Identity
/// </summary>
/// <remarks>
/// Le path retourné est aligné sur
/// <see cref="YavscConstants.AvatarsPath"/> (minuscule).
/// <see cref="Constants.AvatarsPath"/> (minuscule).
/// Les anciens display templates utilisaient "/Avatars/"
/// avec un S majuscule, en désaccord avec le path statique
/// servi par le middleware de fichiers — les images ne
@ -29,8 +29,8 @@ namespace Yavsc.Abstract.Identity
public static string AvatarSrc(IApplicationUser? user)
{
if (user==null || string.IsNullOrWhiteSpace(user?.UserName))
return YavscConstants.DefaultAvatar;
return $"{YavscConstants.AvatarsPath}/{user!.UserName}.s.png";
return Constants.DefaultAvatar;
return $"{Constants.AvatarsPath}/{user!.UserName}.s.png";
}
}
}

View file

@ -14,7 +14,7 @@ using Yavsc.Models.Workflow;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/activity")]
[Route(Constants.APIPrefix + "/activity")]
public class ActivityApiController : Controller
{
private ApplicationDbContext _context;

View file

@ -19,7 +19,7 @@ namespace Yavsc.ApiControllers
using Yavsc.ViewModels.Auth;
using Yavsc.Server.Helpers;
[Route("api/bill"), Authorize]
[Route(Constants.APIPrefix + "/bill"), Authorize]
public class BillingController : Controller
{
readonly ApplicationDbContext dbContext;

View file

@ -18,7 +18,7 @@ namespace Yavsc.Controllers
using Yavsc.Server.Helpers;
[Produces("application/json")]
[Route("api/bookquery"), Authorize("Performer")]
[Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
public class BookQueryApiController : Controller
{
private ApplicationDbContext _context;

View file

@ -15,7 +15,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/estimate"), Authorize]
[Route(Constants.APIPrefix + "/estimate"), Authorize]
public class EstimateApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -27,12 +27,12 @@ namespace Yavsc.Controllers
}
bool UserIsAdminOrThis(string uid)
{
if (User.IsInRole(YavscConstants.AdminGroupName)) return true;
if (User.IsInRole(Constants.AdminGroupName)) return true;
return uid == User.GetUserId();
}
bool UserIsAdminOrInThese(string oid, string uid)
{
if (User.IsInRole(YavscConstants.AdminGroupName)) return true;
if (User.IsInRole(Constants.AdminGroupName)) return true;
var cuid = User.GetUserId();
return cuid == uid || cuid == oid;
}
@ -82,7 +82,7 @@ namespace Yavsc.Controllers
return BadRequest();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
{
@ -118,7 +118,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimate.OwnerId == null) estimate.OwnerId = uid;
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
{
@ -187,7 +187,7 @@ namespace Yavsc.Controllers
return NotFound();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
{

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/EstimateTemplatesApi")]
[Route(Constants.APIPrefix + "/EstimateTemplatesApi")]
public class EstimateTemplatesApiController : Controller
{
private ApplicationDbContext _context;
@ -62,7 +62,7 @@ namespace Yavsc.Controllers
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.Entry(estimateTemplate).State = EntityState.Modified;
@ -132,7 +132,7 @@ namespace Yavsc.Controllers
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.EstimateTemplates.Remove(estimateTemplate);

View file

@ -8,7 +8,7 @@ using Yavsc.ViewModels.FrontOffice;
namespace Yavsc.ApiControllers
{
[Route("api/front")]
[Route(Constants.APIPrefix + "/front")]
public class FrontOfficeApiController : Controller
{
ApplicationDbContext dbContext;

View file

@ -6,7 +6,7 @@ using Yavsc.Models;
namespace Yavsc.ApiControllers
{
[Route("api/payment")]
[Route(Constants.APIPrefix + "/payment")]
public class PaymentApiController : Controller
{
private readonly ApplicationDbContext dbContext;

View file

@ -11,7 +11,7 @@ namespace Yavsc.Controllers
using Yavsc.Services;
[Produces("application/json")]
[Route("api/performers")]
[Route(Constants.APIPrefix + "/performers")]
public class PerformersApiController : Controller
{
ApplicationDbContext dbContext;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/ProductApi")]
[Route(Constants.APIPrefix + "/ProductApi")]
public class ProductApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
}
// PUT: api/ProductApi/5
[HttpPut("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpPut("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PutProduct(long id, [FromBody] Product product)
{
if (!ModelState.IsValid)
@ -81,7 +81,7 @@ namespace Yavsc.Controllers
}
// POST: api/ProductApi
[HttpPost,Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpPost,Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PostProduct([FromBody] Product product)
{
if (!ModelState.IsValid)
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
}
// DELETE: api/ProductApi/5
[HttpDelete("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpDelete("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult DeleteProduct(long id)
{
if (!ModelState.IsValid)

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/bursherprofiles")]
[Route(Constants.APIPrefix + "/bursherprofiles")]
public class BursherProfilesApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -24,7 +24,7 @@ namespace Yavsc.ApiControllers
using Microsoft.AspNetCore.Authorization;
using Yavsc.Server.Helpers;
[Route("api/haircut")][Authorize]
[Route(Constants.APIPrefix + "/haircut")][Authorize]
public class HairCutController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -6,7 +6,7 @@ using Yavsc.Models.Relationship;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/hyperlink")]
[Route(Constants.APIPrefix + "/hyperlink")]
public class HyperLinkApiController : Controller
{
private ApplicationDbContext _context;

View file

@ -7,7 +7,7 @@ using Yavsc.Server.Models.IT.SourceCode;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/GitRefsApi")]
[Route(Constants.APIPrefix + "/GitRefsApi")]
[Authorize("AdministratorOnly")]
public class GitRefsApiController : Controller
{

View file

@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Yavsc.ApiControllers
{
[Route("api/mailtemplate")]
[Route(Constants.APIPrefix + "/mailtemplate")]
public class MailTemplatingApiController: Controller
{

View file

@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/mailing")]
[Route(Constants.APIPrefix + "/mailing")]
[Authorize("AdministratorOnly")]
public class MailingTemplateApiController : Controller
{

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/museprefs")]
[Route(Constants.APIPrefix + "/museprefs")]
public class MusicalPreferencesApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/MusicalTendenciesApi")]
[Route(Constants.APIPrefix + "/MusicalTendenciesApi")]
public class MusicalTendenciesApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -37,7 +37,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (blogpost.AuthorId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
return BadRequest();
_context.SaveChanges(User.GetUserId());

View file

@ -7,7 +7,7 @@ namespace Yavsc.ApiControllers
/// <summary>
/// Base class for managing performers profiles
/// </summary>
[Produces("application/json"),Route("api/profile")]
[Produces("application/json"),Route(Constants.APIPrefix + "/profile")]
public abstract class ProfileApiController<T> : Controller
{ public ProfileApiController()
{

View file

@ -10,7 +10,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/blacklist"), Authorize]
[Route(Constants.APIPrefix + "/blacklist"), Authorize]
public class BlackListApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -50,8 +50,8 @@ namespace Yavsc.Controllers
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != blackListed.OwnerId)
if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(YavscConstants.FrontOfficeGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.FrontOfficeGroupName))
return false;
return true;
}

View file

@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore;
namespace Yavsc.Controllers
{
[Route("api/chat")]
[Route(Constants.APIPrefix + "/chat")]
public class ChatApiController : Controller
{
readonly ApplicationDbContext dbContext;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/ChatRoomAccessApi")]
[Route(Constants.APIPrefix + "/ChatRoomAccessApi")]
public class ChatRoomAccessApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != chatRoomAccess.UserId && uid != chatRoomAccess.Room.OwnerId
&& ! User.IsInMsRole(YavscConstants.AdminGroupName))
&& ! User.IsInMsRole(Constants.AdminGroupName))
{
ModelState.AddModelError("UserId","get refused");
@ -72,7 +72,7 @@ namespace Yavsc.Controllers
}
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (uid != room.OwnerId && ! User.IsInMsRole(YavscConstants.AdminGroupName))
if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName))
{
ModelState.AddModelError("ChannelName", "access put refused");
return BadRequest(ModelState);
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(YavscConstants.AdminGroupName)))
if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName)))
{
ModelState.AddModelError("ChannelName", "access post refused");
return BadRequest(ModelState);
@ -154,7 +154,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(YavscConstants.AdminGroupName)))
if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminGroupName)))
{
ModelState.AddModelError("UserId", "access drop refused");
return BadRequest(ModelState);

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/ChatRoomApi")]
[Route(Constants.APIPrefix + "/ChatRoomApi")]
public class ChatRoomApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -137,7 +137,7 @@ namespace Yavsc.Controllers
if (User.GetUserId() != chatRoom.OwnerId )
{
if (!User.IsInMsRole(YavscConstants.AdminGroupName))
if (!User.IsInMsRole(Constants.AdminGroupName))
return BadRequest(new {error = "OwnerId"});
}

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/ContactsApi")]
[Route(Constants.APIPrefix + "/ContactsApi")]
public class ContactsApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/ServiceApi")]
[Route(Constants.APIPrefix + "/ServiceApi")]
public class ServiceApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
}
// PUT: api/ServiceApi/5
[HttpPut("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpPut("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PutService(long id, [FromBody] Service service)
{
if (!ModelState.IsValid)
@ -81,7 +81,7 @@ namespace Yavsc.Controllers
}
// POST: api/ServiceApi
[HttpPost,Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpPost,Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PostService([FromBody] Service service)
{
if (!ModelState.IsValid)
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
}
// DELETE: api/ServiceApi/5
[HttpDelete("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)]
[HttpDelete("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult DeleteService(long id)
{
if (!ModelState.IsValid)

View file

@ -13,7 +13,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json"),Authorize("AdministratorOnly")]
[Route("api/users")]
[Route(Constants.APIPrefix + "/users")]
public class ApplicationUserApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -0,0 +1,161 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
/// <c>POST /api/v1/blogacl</c> with a JSON body of
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId + Comment).
///
/// <para>Same fixture as <see cref="CircleMembersApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides a SQLite
/// <c>:memory:</c> <c>ApplicationDbContext</c> (so FKs are
/// enforced the way a real relational engine would) and JWT
/// bearer auth via <c>TestTokenIssuer</c>. No mocks — the real
/// DbContext receives the real INSERT attempt.</para>
///
/// <para>The bug being pinned by these tests: the POST endpoint
/// calls <c>_context.CircleAuthorizationToBlogPost.Add(...)</c>
/// then <c>SaveChangesAsync</c>. The entity has a composite
/// key (CircleId + BlogPostId) and two FKs; EF Core refuses
/// the INSERT with
/// <c>System.InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown when
/// attempting to save changes</c> when the principal entities
/// (the existing <c>BlogPost</c> and <c>Circle</c>) are not
/// attached to the DbContext in the same change-tracker graph.</para>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database and seed <c>alice</c>.
/// The shared SQLite <c>:memory:</c> store persists across
/// requests, so each test starts from a clean slate.</summary>
private void ResetDatabaseWithAlice()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.SaveChanges();
}
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedCircle(string ownerId, string name)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
/// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedBlogPost(string authorId, string title)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
}
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
// The Blogs fixture disables JwtSecurityTokenHandler's
// inbound claim-type remap, so the JWT's "sub" stays "sub"
// rather than being rewritten to ClaimTypes.NameIdentifier.
// The controller, however, reads the user id via
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
// an explicit nameid claim to keep the legacy lookup happy.
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
TestTokenIssuer.Issue(
subject,
extraClaims: new[]
{
new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier,
subject),
}));
return http;
}
/// <summary>PostIt sends only the FK ids (<c>CircleId</c> +
/// <c>BlogPostId</c>) plus scalar fields, never the navigation
/// properties <c>Target</c> / <c>Allowed</c>. The controller
/// must accept that shape and persist the ACL row.</summary>
[Fact]
public async Task PostCircleAuthorization_returns_201_when_adding_existing_circle_to_existing_post()
{
ResetDatabaseWithAlice();
var circleId = SeedCircle("alice", "Famille");
var postId = SeedBlogPost("alice", "Billet de test");
using var http = NewClient("alice");
// Mirror PostIt's payload: scalar FK ids only, no nav props.
var payload = new CircleAuthorizationToBlogPost
{
CircleId = circleId,
BlogPostId = postId,
Comment = true,
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload);
// Expected: 201 Created (per controller line 133: return
// CreatedAtRoute("GetCircleAuthorizationToBlogPost", ...)).
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}

View file

@ -12,6 +12,7 @@ namespace Yavsc.Blogs.Tests;
/// surface. The first behavioural test (GET /api/v1/blog returns
/// 200) lands in a follow-up commit.
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;

View file

@ -22,7 +22,7 @@ namespace Yavsc.Blogs.Tests;
/// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework.
/// </summary>
[Collection("JwtClaimMapping")]
[Collection("Yavsc Blogs")]
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
@ -45,6 +45,21 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
db.Database.EnsureCreated();
}
/// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required
/// for any test that POST/PUT/DELETE a <c>BlogPost</c>:
/// <c>BlogPost.AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>, and SQLite (unlike the EF Core
/// InMemory provider) enforces it. Without the seed, the
/// POST handler hits
/// <c>SQLite Error 19: 'FOREIGN KEY constraint failed'</c>
/// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser()
{
ResetDatabase();
_fixture.SeedUser("tester");
}
/// <summary>The fixture's <c>WebApplication</c> is bound to
/// <c>https://localhost:&lt;random&gt;</c> via
/// <see cref="WebHostFixture.Addresses"/>. We pick the first
@ -116,7 +131,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient();
// Create a minimal BlogPost. The server assigns Id, so we
@ -154,7 +169,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
@ -186,7 +201,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PostBlogComment_returns_201_for_existing_post()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
@ -249,7 +264,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update()
{
ResetDatabase();
ResetAndSeedDefaultUser();
// The JWT's sub must match the post's AuthorId:
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
// and UserHelpers.GetUserId reads "sub" off the principal.
@ -300,7 +315,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient();
// Seed a post we can delete.
@ -342,7 +357,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// ModelState validation starts rejecting the PostIt payload
// (missing field, wrong casing, etc.), this test fails
// before the regression reaches a user.
ResetDatabase();
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
// Mirrors what MainPageViewModel.Save builds: a BlogPost with

View file

@ -2,8 +2,8 @@ using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Blogs.Controllers;
@ -14,14 +14,20 @@ using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Test host for the Yavsc.Blogs API surface. Specialisation of
/// <see cref="WebHostFixture"/> that wires up only the bits the
/// blog API actually depends on:
/// Shared integration-test host for the Yavsc.Blogs API surface.
/// Specialisation of <see cref="WebHostFixture"/> that wires up
/// only the bits the blog API actually depends on:
///
/// <list type="bullet">
/// <item><description>An in-memory <see cref="ApplicationDbContext"/>
/// (the real one — no mock) so <c>BlogSpotService.Index</c> can run
/// against an empty table and return an empty list.</description></item>
/// <item><description>A SQLite <c>:memory:</c> database
/// (<see cref="Microsoft.EntityFrameworkCore.Sqlite"/>) backed
/// by a single shared <see cref="SqliteConnection"/> held open
/// for the lifetime of the host. SQLite enforces real foreign
/// keys and real transactional semantics, so the tests see the
/// same INSERT-time FK validation a production Postgres host
/// would — unlike the EF Core InMemory provider, which silently
/// ignores FKs and masks bugs that surface only against a real
/// relational engine.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: the GET index path doesn't read the file system, so any
/// implementation is fine.</description></item>
@ -44,31 +50,58 @@ namespace Yavsc.Blogs.Tests;
///
/// No IdentityServer, no SMTP, no static assets — the Org fixture
/// owns all of that and we don't need any of it for blog integration
/// tests.
/// tests. Marked <see cref="CollectionDefinitionAttribute"/> so the
/// host is shared across every <c>[Collection("Yavsc Blogs")]</c>
/// test class: one host, one SQLite DB, one Kestrel port.
/// </summary>
[CollectionDefinition("Yavsc Blogs")]
public sealed class BlogsWebServerFixture : WebHostFixture
{
protected override int HttpsPort => 5103;
private InMemoryDatabaseRoot? _inMemoryRoot;
// A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
// shared configuration into static slots. Closing the
// connection destroys the in-memory database — so we close
// it only when the last fixture instance is disposed (see
// Dispose below), exactly when WebHostFixture tears down the
// host.
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
// Use the real ApplicationDbContext with an in-memory store.
// BlogSpotService reads _context.BlogSpot directly, so any
// attempt to mock it would be wasted work; the real service
// against an empty table returns an empty list, which is
// exactly what the first test wants to assert.
//
// Share a single InMemoryDatabaseRoot across the test
// lifetime so POST + GET on the same fixture see the same
// store. Without the root, EF Core's In-Memory provider
// creates independent stores per DbContext in some
// configurations, and the second request would see an
// empty list even after the first wrote a row.
_inMemoryRoot = new InMemoryDatabaseRoot();
// Open the shared in-memory connection lazily on the first
// fixture construction. Subsequent constructions (xUnit
// creates one fixture instance per IClassFixture) reuse
// the same connection so all DbContexts across all tests
// see the same database.
SqliteConnection sharedConnection;
lock (_sqliteLock)
{
if (_sharedSqliteConnection is null)
{
// Mode=Memory + Cache=Shared gives us a named
// in-memory database that every connection string
// referencing "File:YavscBlogsTests?mode=memory&cache=shared"
// will resolve to the same backing store, as long
// as at least one SqliteConnection stays open
// against it.
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscBlogsTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
}
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot));
// UseSqlite(DbConnection) keeps the connection we just
// opened alive for the DbContext's lifetime, instead of
// letting EF open and close its own. Without this,
// each DbContext would get a fresh connection pointing
// at an empty :memory: store and nothing would persist
// across requests.
opt.UseSqlite(sharedConnection));
// Trivial file-system auth: the GET index path never calls
// into it, but the DI container needs an instance.
@ -145,7 +178,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// remaps long Microsoft claim URIs, not sub).
// UserHelpers.GetUserId reads sub directly.
NameClaimType = "sub",
RoleClaimType = YavscConstants.RoleClaimType,
RoleClaimType = Yavsc.Constants.RoleClaimType,
};
});
@ -168,6 +201,75 @@ public sealed class BlogsWebServerFixture : WebHostFixture
return app;
}
public override void Dispose()
{
try
{
base.Dispose();
}
finally
{
// Close the shared SQLite connection only when the
// last fixture instance goes away, matching the
// lifetime contract of WebHostFixture.Dispose. We
// rely on base.Dispose's _instanceCount decrement
// having run, so we close only if the host is gone
// (base already nulled _app when count==0).
lock (_sqliteLock)
{
if (_sharedSqliteConnection is not null)
{
// Synchronous close: SQLite's Close() is
// documented as safe to call from a sync
// context and avoids the GetAwaiter().GetResult()
// pattern that's historically caused teardown
// hangs in this repo's async pipeline.
_sharedSqliteConnection.Close();
_sharedSqliteConnection.Dispose();
_sharedSqliteConnection = null;
}
}
}
}
/// <summary>Seed an <see cref="ApplicationUser"/> in the shared
/// SQLite store, so tests that POST/PUT/DELETE a
/// <c>BlogPost</c> (whose <c>AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>) don't trip the FK constraint that
/// SQLite enforces but the EF Core InMemory provider silently
/// ignored. Idempotent on <paramref name="userName"/>: a
/// second call for the same id is a no-op (the user already
/// exists).</summary>
/// <param name="userName">Both the PK id and the login name.
/// The JWT subject in tests is this same string, so seeding
/// this id is enough to make the FK from a
/// <c>BlogPost.AuthorId</c> resolve.</param>
/// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var existing = db.Users.SingleOrDefault(u => u.Id == userName);
if (existing != null) return existing;
// Email is an alternate key on ApplicationUser; seeding
// it explicitly avoids the InMemory provider's null-claim
// tracking quirk (cf. PublishEndpointTests.ResetDatabase)
// and keeps the column shape realistic for prod.
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return user;
}
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
/// blog API endpoints exercised by the first tests don't read the
/// file system, so the implementation can be a no-op.</summary>

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
@ -88,7 +89,7 @@ public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
}
private string MembersUrl(long circleId)
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{Constants.APIPrefix}/circle/{circleId}/members";
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/circle/{circleId}/members";
private HttpClient NewClient(string subject)
{

View file

@ -65,8 +65,8 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey,
RoleClaimType = YavscConstants.RoleClaimType,
NameClaimType = YavscConstants.NameClaimType,
RoleClaimType = Yavsc.Constants.RoleClaimType,
NameClaimType = Yavsc.Constants.NameClaimType,
};
});

View file

@ -25,7 +25,7 @@ namespace Yavsc.Blogs.Tests;
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
/// via <see cref="TestTokenIssuer"/>.</para>
/// </summary>
[Collection("JwtClaimMapping")]
[Collection("Yavsc Blogs")]
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;

View file

@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />

View file

@ -5,6 +5,4 @@ public static class Constants
public const string AdminRole = "Admin";
public const string ModeratorRole = "Moderator";
public const string UserRole = "User";
public const string APIPrefix = "api/v1";
}

View file

@ -1,15 +1,16 @@
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Server.Helpers;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{
[Produces("application/json")]
[Route("api/blogacl")]
[Route(APIPrefix+"/blogacl")]
public class BlogAclApiController : Controller
{
private readonly ApplicationDbContext _context;
@ -24,7 +25,7 @@ namespace Yavsc.Blogs.Controllers
/// Blog posts (and therefore their ACLs) are private to their
/// author — the API never exposes another user's ACL.
/// </summary>
// GET: api/blogacl
// GET: api/v1/blogacl
[HttpGet]
public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL()
{

View file

@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Yavsc.Blogspot;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{

View file

@ -1,12 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Blog;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{
[Produces("application/json")]

View file

@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{

View file

@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{

View file

@ -2,7 +2,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{

View file

@ -8,7 +8,7 @@ using Yavsc.Models.Messaging;
using Yavsc.Services;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
using Yavsc.Server.Hubs;
namespace Yavsc.Blogs.Controllers

View file

@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{

View file

@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Yavsc.Models;
using static Yavsc.Blogs.Constants;
using static Yavsc.Constants;
namespace Yavsc.Controllers
{

View file

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers
{
@ -26,7 +27,7 @@ namespace Yavsc.Blogs.Controllers
/// exposing it.</para>
/// </summary>
[Produces("application/json")]
[Route( Constants.APIPrefix + "/user-search")]
[Route(APIPrefix + "/user-search")]
[Authorize]
public class UserSearchApiController : Controller
{
@ -66,8 +67,9 @@ namespace Yavsc.Blogs.Controllers
// book callers already know the email they're
// searching for and we don't want to surface a
// long tail of partial matches.
var normalised = e.Trim();
query = query.Where(u => u.Email != null && u.Email.ToLower() == normalised.ToLower());
var normalized = e.Trim();
query = query.Where(u => u.Email != null &&
string.Compare(u.Email, normalized, true) ==0);
}
if (!string.IsNullOrWhiteSpace(q))

View file

@ -51,7 +51,7 @@ internal class Program
// DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString(
YavscConstants.YavscConnectionStringName)));
Yavsc.Constants.YavscConnectionStringName)));
// other services
services

View file

@ -14,7 +14,7 @@ namespace Yavsc.Org.Tests.NonRegression;
/// ne voit rien — juste un 500 muet.
///
/// Le fix passe par <see cref="UserDisplayHelpers.AvatarSrc"/> qui
/// retourne <see cref="YavscConstants.DefaultAvatar"/> pour toute
/// retourne <see cref="Yavsc.Constants.DefaultAvatar"/> pour toute
/// donnée partielle. Ces tests couvrent les trois formes de
/// "donnée absente" : user null, UserName vide, UserName whitespace.
/// </summary>
@ -23,21 +23,21 @@ public class UserDisplayHelpersTests
[Fact]
public void AvatarSrc_null_user_returns_default_avatar()
{
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(null));
Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(null));
}
[Fact]
public void AvatarSrc_user_with_empty_UserName_returns_default_avatar()
{
var user = new FakeUser { UserName = "" };
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
}
[Fact]
public void AvatarSrc_user_with_whitespace_UserName_returns_default_avatar()
{
var user = new FakeUser { UserName = " " };
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
}
[Fact]
@ -47,7 +47,7 @@ public class UserDisplayHelpersTests
// Le path doit matcher YavscConstants.AvatarsPath (minuscule),
// pas un /Avatars/ avec S majuscule qui ne résout pas
// dans le middleware de fichiers statiques.
var expected = $"{YavscConstants.AvatarsPath}/alice.s.png";
var expected = $"{Yavsc.Constants.AvatarsPath}/alice.s.png";
Assert.Equal(expected, UserDisplayHelpers.AvatarSrc(user));
}

View file

@ -80,8 +80,8 @@ public sealed class WebServerFixture : WebHostFixture
// can resolve it. The AddConfiguration extension takes care of
// that plus the in-memory overrides below.
builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory",
{
[$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = "InMemory",
// SMTP test config: UserName non-null so MailSender
// exercises the Authenticate branch — the
// RecordingSmtpClient captures it.

View file

@ -198,7 +198,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
/// <summary>
/// Entry point into the login workflow
/// </summary>
[HttpGet(YavscConstants.SigninPath)]
[HttpGet(Constants.SigninPath)]
public async Task<IActionResult> Signin(SignInModel model)
{
// build a model so we know what to show on the login page
@ -217,7 +217,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
/// Handle postback from username/password login
/// </summary>
///
[HttpPost(YavscConstants.SigninPath)]
[HttpPost(Constants.SigninPath)]
[ValidateAntiForgeryToken]
[AllowAnonymous]
@ -659,7 +659,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
}
//
// POST: /Account/LogOff
[HttpPost(YavscConstants.LogoutPath)]
[HttpPost(Constants.LogoutPath)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogOff(string returnUrl = null)
{
@ -829,7 +829,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
bool result = false;
try
{
result = await _userManager.VerifyTwoFactorTokenAsync(user, YavscConstants.DefaultFactor, code);
result = await _userManager.VerifyTwoFactorTokenAsync(user, Constants.DefaultFactor, code);
_dbContext.SaveChanges(userId);
}
catch (Exception ex)
@ -1024,12 +1024,12 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
}
// Generate the token and send it
if (model.SelectedProvider == YavscConstants.MobileAppFactor)
if (model.SelectedProvider == Constants.MobileAppFactor)
{
return View("Error", new Exception("No mobile app service was activated"));
}
else
if (model.SelectedProvider == YavscConstants.SMSFactor)
if (model.SelectedProvider == Constants.SMSFactor)
{
return View("Error", new Exception("No SMS service was activated"));
// await _smsSender.SendSmsAsync(_twilioSettings, await _userManager.GetPhoneNumberAsync(user), message);

View file

@ -50,12 +50,12 @@ namespace Yavsc.Controllers
{
// ensure all roles existence
foreach (string roleName in new string[] {
YavscConstants.AdminGroupName,
YavscConstants.StarGroupName,
YavscConstants.PerformerGroupName,
YavscConstants.FrontOfficeGroupName,
YavscConstants.StarHunterGroupName,
YavscConstants.BlogModeratorGroupName
Constants.AdminGroupName,
Constants.StarGroupName,
Constants.PerformerGroupName,
Constants.FrontOfficeGroupName,
Constants.StarHunterGroupName,
Constants.BlogModeratorGroupName
})
if (!await _roleManager.RoleExistsAsync(roleName))
{
@ -80,11 +80,11 @@ namespace Yavsc.Controllers
public async Task<IActionResult> Take()
{
// If some amdin already exists, make this method disapear
var admins = await _userManager.GetUsersInRoleAsync(YavscConstants.AdminGroupName);
var admins = await _userManager.GetUsersInRoleAsync(Constants.AdminGroupName);
if (admins != null && admins.Count > 0)
{
// All is ok, nothing to do here.
if (User.IsInMsRole(YavscConstants.AdminGroupName))
if (User.IsInMsRole(Constants.AdminGroupName))
{
return Ok(new { message = "you already got it." });
@ -100,7 +100,7 @@ namespace Yavsc.Controllers
return new BadRequestObjectResult(ModelState);
}
var addToRoleResult = await _userManager.AddToRoleAsync(user, YavscConstants.AdminGroupName);
var addToRoleResult = await _userManager.AddToRoleAsync(user, Constants.AdminGroupName);
if (!addToRoleResult.Succeeded)
{
AddErrors(addToRoleResult);
@ -114,11 +114,11 @@ namespace Yavsc.Controllers
public async Task<IActionResult> Index()
{
var adminCount = await _userManager.GetUsersInRoleAsync(
YavscConstants.AdminGroupName);
Constants.AdminGroupName);
var userCount = await _dbContext.Users.CountAsync();
var youAreAdmin = await _userManager.IsInRoleAsync(
await _userManager.FindByIdAsync(User.GetUserId()),
YavscConstants.AdminGroupName);
Constants.AdminGroupName);
var roles = await _roleManager.Roles.Select(x => new RoleInfo
{

View file

@ -1,13 +1,13 @@
using IdentityServer8.EntityFramework.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Server.Helpers;
using static Yavsc.Constants;
namespace Yavsc.Org.Controllers.Administration
{
[Route("api/[controller]")]
[Route(APIPrefix + "/[controller]")]
[ApiController]
public class ApiScopesApiController : ControllerBase
{

View file

@ -59,8 +59,8 @@ namespace Yavsc.Controllers
}
private async Task SetupView(Announce announce)
{
ViewBag.IsAdmin = User.IsInMsRole(YavscConstants.AdminGroupName);
ViewBag.IsPerformer = User.IsInMsRole(YavscConstants.PerformerGroupName);
ViewBag.IsAdmin = User.IsInMsRole(Constants.AdminGroupName);
ViewBag.IsPerformer = User.IsInMsRole(Constants.PerformerGroupName);
ViewBag.AllowEdit = announce==null || announce.Id<=0 || !_authorizationService.AuthorizeAsync(User,announce,new EditPermission()).IsFaulted;
List<SelectListItem> dl = new List<SelectListItem>();
var rnames = System.Enum.GetNames(typeof(Reason));
@ -82,14 +82,14 @@ namespace Yavsc.Controllers
if (ModelState.IsValid)
{
// Only allow admin to create corporate annonces
if (announce.For == Reason.Corporate && ! User.IsInMsRole(YavscConstants.AdminGroupName))
if (announce.For == Reason.Corporate && ! User.IsInMsRole(Constants.AdminGroupName))
{
ModelState.AddModelError("For", _localizer["YourNotAdmin"]);
return View(announce);
}
// Only allow performers to create ServiceProposal
if (announce.For == Reason.ServiceProposal && ! User.IsInMsRole(YavscConstants.PerformerGroupName))
if (announce.For == Reason.ServiceProposal && ! User.IsInMsRole(Constants.PerformerGroupName))
{
ModelState.AddModelError("For", _localizer["YourNotAPerformer"]);
return View(announce);

View file

@ -72,7 +72,7 @@ namespace Yavsc.Org.Controllers
{
var blog = await blogSpotService.Details(User, id.Value);
ViewBag.apicmtctlr = "/api/v1/blogcomments";
ViewBag.moderatoFlag = User.IsInMsRole(YavscConstants.BlogModeratorGroupName);
ViewBag.moderatoFlag = User.IsInMsRole(Yavsc.Constants.BlogModeratorGroupName);
return View(blog);

View file

@ -42,7 +42,7 @@ namespace Yavsc.Controllers
Value = pt.FullName,
Selected = currentCode == pt.FullName
}).ToList();
items.Add(new SelectListItem { Text = SR[YavscConstants.NoneCode], Value = YavscConstants.NoneCode, Selected = currentCode == null});
items.Add(new SelectListItem { Text = SR[Constants.NoneCode], Value = Constants.NoneCode, Selected = currentCode == null});
ViewBag.SettingsClassName = items;
}
@ -58,7 +58,7 @@ namespace Yavsc.Controllers
Text = a.Name,
Value = a.Code
}).ToList();
var nullItem = new SelectListItem { Text = SR[YavscConstants.NoneCode], Value = YavscConstants.NoneCode };
var nullItem = new SelectListItem { Text = SR[Constants.NoneCode], Value = Constants.NoneCode };
acts.Add(nullItem);
if (code == null) return acts;
var existing = _context.Activities.Include(a => a.Children).FirstOrDefault(a => a.Code == code);
@ -123,9 +123,9 @@ namespace Yavsc.Controllers
[ValidateAntiForgeryToken]
public IActionResult Create(Activity activity)
{
if (activity.ParentCode==YavscConstants.NoneCode)
if (activity.ParentCode==Constants.NoneCode)
activity.ParentCode=null;
if (activity.SettingsClassName==YavscConstants.NoneCode)
if (activity.SettingsClassName==Constants.NoneCode)
activity.SettingsClassName=null;
if (ModelState.IsValid)
@ -161,9 +161,9 @@ namespace Yavsc.Controllers
[ValidateAntiForgeryToken]
public IActionResult Edit(Activity activity)
{
if (activity.ParentCode==YavscConstants.NoneCode)
if (activity.ParentCode==Constants.NoneCode)
activity.ParentCode=null;
if (activity.SettingsClassName==YavscConstants.NoneCode)
if (activity.SettingsClassName==Constants.NoneCode)
activity.SettingsClassName=null;
if (ModelState.IsValid)
{

View file

@ -10,7 +10,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/v1/dimiss")]
[Route(Constants.APIPrefix + "/v1/dimiss")]
public class DimissClicksApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -37,9 +37,9 @@ namespace Yavsc.Controllers
public async Task<IActionResult> Index(string id)
{
ViewBag.IsFromSecureProx = Request.Headers.ContainsKey(YavscConstants.SshHeaderKey) && Request.Headers[YavscConstants.SshHeaderKey] == "on";
ViewBag.IsFromSecureProx = Request.Headers.ContainsKey(Constants.SshHeaderKey) && Request.Headers[Constants.SshHeaderKey] == "on";
ViewBag.SecureHomeUrl = "https://" + Request.Headers["X-Forwarded-Host"];
ViewBag.SshHeaderKey = Request.Headers[YavscConstants.SshHeaderKey];
ViewBag.SshHeaderKey = Request.Headers[Constants.SshHeaderKey];
var uid = User.GetUserId();
long[] clicked = null;
if (uid == null)

View file

@ -64,7 +64,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (ModelState.IsValid)
{
if (model.UserId != uid) if (!User.IsInMsRole(YavscConstants.AdminGroupName))
if (model.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
return new ChallengeResult();
_context.Instrumentation.Add(model);
@ -82,7 +82,7 @@ namespace Yavsc.Controllers
{
return NotFound();
}
if (id != uid) if (!User.IsInMsRole(YavscConstants.AdminGroupName))
if (id != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
return new ChallengeResult();
Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id);
if (musicianSettings == null)
@ -98,7 +98,7 @@ namespace Yavsc.Controllers
public async Task<IActionResult> Edit(Instrumentation musicianSettings)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(YavscConstants.AdminGroupName))
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
return new ChallengeResult();
if (ModelState.IsValid)
{
@ -124,7 +124,7 @@ namespace Yavsc.Controllers
return NotFound();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(YavscConstants.AdminGroupName))
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
return new ChallengeResult();
return View(musicianSettings);
}
@ -137,7 +137,7 @@ namespace Yavsc.Controllers
Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id);
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(YavscConstants.AdminGroupName))
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
return new ChallengeResult();

View file

@ -169,7 +169,7 @@ public static class HostingExtensions
public static IdentityBuilder AddIdentityDBAndStores(this WebApplicationBuilder builder)
{
IServiceCollection services = builder.Services;
var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName);
var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName);
services.AddDbContext<ApplicationDbContext>(options =>
{
@ -197,7 +197,7 @@ public static class HostingExtensions
options.SignIn.RequireConfirmedAccount = builder.Environment.IsEnvironment(
builder.Environment.EnvironmentName);
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.PreferredUserName;
options.ClaimsIdentity.RoleClaimType = YavscConstants.RoleClaimType;
options.ClaimsIdentity.RoleClaimType = Constants.RoleClaimType;
}
)
.AddEntityFrameworkStores<ApplicationDbContext>();
@ -239,18 +239,18 @@ public static class HostingExtensions
{
policy
.RequireAuthenticatedUser()
.RequireClaim(YavscConstants.RoleClaimType,
new string[] { YavscConstants.PerformerGroupName, YavscConstants.AdminGroupName })
.RequireClaim(Constants.RoleClaimType,
new string[] { Constants.PerformerGroupName, Constants.AdminGroupName })
;
});
options.AddPolicy("AdministratorOnly", policy =>
{
_ = policy
.RequireAuthenticatedUser()
.RequireClaim(YavscConstants.RoleClaimType, YavscConstants.AdminGroupName);
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
});
options.AddPolicy("FrontOffice", policy => policy.RequireRole(YavscConstants.FrontOfficeGroupName));
options.AddPolicy("FrontOffice", policy => policy.RequireRole(Constants.FrontOfficeGroupName));
// options.AddPolicy("EmployeeId", policy => policy.RequireClaim("EmployeeId", "123", "456"));
// options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
@ -314,10 +314,10 @@ public static class HostingExtensions
{
options.ClaimsIdentity.UserIdClaimType = JwtClaimTypes.Subject;
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.Name;
options.ClaimsIdentity.RoleClaimType = YavscConstants.RoleClaimType;
options.ClaimsIdentity.RoleClaimType = Constants.RoleClaimType;
});
var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name;
var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName);
var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName);
string sqliteConnectionString = $"Data Source={Path.Combine(Path.GetTempPath(), "yavsc_test.db")}";
@ -1220,7 +1220,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
Config.UserFilesOptions = new FileServerOptions()
{
FileProvider = new PhysicalFileProvider(AbstractFileSystemHelpers.UserFilesDirName),
RequestPath = PathString.FromUriComponent(YavscConstants.UserFilesPath),
RequestPath = PathString.FromUriComponent(Constants.UserFilesPath),
EnableDirectoryBrowsing = enableDirectoryBrowsing,
};
Config.UserFilesOptions.EnableDefaultFiles = true;
@ -1233,7 +1233,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
Config.AvatarsOptions = new FileServerOptions()
{
FileProvider = new PhysicalFileProvider(Config.AvatarsDirName),
RequestPath = PathString.FromUriComponent(YavscConstants.AvatarsPath),
RequestPath = PathString.FromUriComponent(Constants.AvatarsPath),
EnableDirectoryBrowsing = enableDirectoryBrowsing
};
@ -1244,7 +1244,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
Config.GitOptions = new FileServerOptions()
{
FileProvider = new PhysicalFileProvider(Config.GitDirName),
RequestPath = PathString.FromUriComponent(YavscConstants.GitPath),
RequestPath = PathString.FromUriComponent(Constants.GitPath),
EnableDirectoryBrowsing = enableDirectoryBrowsing,
};
Config.GitOptions.DefaultFilesOptions.DefaultFileNames.Add("index.md");

View file

@ -7,7 +7,7 @@ namespace Yavsc.ViewModels.Manage
public class SetUserNameViewModel
{
[Required]
[Display(Name = "User name"),RegularExpression(YavscConstants.UserNameRegExp)]
[Display(Name = "User name"),RegularExpression(Constants.UserNameRegExp)]
public string UserName { get; set; }
}

View file

@ -13,7 +13,7 @@
} else {
<div class="alert alert-warning">
<strong>Utilisateur inconnu</strong>
<img src="@YavscConstants.DefaultAvatar" class="smalltofhol" alt="Utilisateur inconnu" title="Utilisateur inconnu" />
<img src="@Constants.DefaultAvatar" class="smalltofhol" alt="Utilisateur inconnu" title="Utilisateur inconnu" />
</div>
}
</div>

View file

@ -16,7 +16,7 @@
<li><a class="dropdown-item @PageHelpers.ActivePage(ViewContext, "Feature")" asp-controller="Feature" asp-action="Index">Features</a></li>
</ul>
</li>
@if (User.IsInMsRole(YavscConstants.AdminGroupName)) {
@if (User.IsInMsRole(Constants.AdminGroupName)) {
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle @PageHelpers.ActivePageAny(ViewContext, administrationControllers)" href="#" id="dropdown05" data-bs-toggle="dropdown" aria-expanded="false">
Administration

View file

@ -14,7 +14,7 @@ namespace Yavsc.Helpers
public static string ToAbsolute(this HttpRequest request, string url)
{
var host = request.Host;
var isSecure = request.Headers[YavscConstants.SshHeaderKey] == "on";
var isSecure = request.Headers[Constants.SshHeaderKey] == "on";
return (isSecure ? "https" : "http") + $"://{host}/{url}";
}
}

View file

@ -105,8 +105,8 @@ public static class ServiceExtensions
{
ValidateAudience = true,
ValidAudiences = audiences,
RoleClaimType = YavscConstants.RoleClaimType,
NameClaimType = YavscConstants.NameClaimType,
RoleClaimType = Constants.RoleClaimType,
NameClaimType = Constants.NameClaimType,
};
options.MapInboundClaims = true;
options.ClaimsIssuer = authority;

View file

@ -84,7 +84,7 @@ namespace Yavsc.Server.Hubs
var userId = _dbContext.Users.First(u => u.UserName == Context.User.Identity.Name).Id;
await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.Connected, userName, null);
isCop = Context.User.IsInMsRole(YavscConstants.AdminGroupName) ;
isCop = Context.User.IsInMsRole(Constants.AdminGroupName) ;
if (isCop)
{
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
@ -351,7 +351,7 @@ namespace Yavsc.Server.Hubs
var identityUserName = Context.User.GetUserName();
if (userName[0] != '?' && Context.User!=null)
if (!Context.User.IsInMsRole(YavscConstants.AdminGroupName))
if (!Context.User.IsInMsRole(Constants.AdminGroupName))
{
var bl = _dbContext.BlackListed

View file

@ -92,8 +92,8 @@ namespace Yavsc.Models
builder.Entity<ApplicationUser>().Property(u => u.FullName).IsRequired(false);
builder.Entity<ApplicationUser>().Property(u => u.DedicatedGoogleCalendar).IsRequired(false);
builder.Entity<ApplicationUser>().HasMany<ChatConnection>(c => c.Connections);
builder.Entity<ApplicationUser>().Property(u => u.Avatar).HasDefaultValue(YavscConstants.DefaultAvatar);
builder.Entity<ApplicationUser>().Property(u => u.DiskQuota).HasDefaultValue(YavscConstants.DefaultFSQ);
builder.Entity<ApplicationUser>().Property(u => u.Avatar).HasDefaultValue(Constants.DefaultAvatar);
builder.Entity<ApplicationUser>().Property(u => u.DiskQuota).HasDefaultValue(Constants.DefaultFSQ);
builder.Entity<ApplicationUser>().HasAlternateKey(u => u.Email);
builder.Entity<BlackListed>().HasOne<ApplicationUser>(bl => bl.User);
builder.Entity<BlackListed>().HasOne<ApplicationUser>(bl => bl.Owner);

View file

@ -61,7 +61,7 @@ namespace Yavsc.Services
// TODO: Handle the socket here.
// Find receivers: others in the chat room
// send them the flow
var buffer = new byte[YavscConstants.WebSocketsMaxBufLen];
var buffer = new byte[Constants.WebSocketsMaxBufLen];
var sBuffer = new ArraySegment<byte>(buffer);
_logger.LogInformation("Receiving bytes...");
@ -109,7 +109,7 @@ namespace Yavsc.Services
{
_logger.LogInformation("try and receive new bytes");
buffer = new byte[YavscConstants.WebSocketsMaxBufLen];
buffer = new byte[Constants.WebSocketsMaxBufLen];
received = await liveHandler.Socket.ReceiveAsync(sBuffer, liveHandler.TokenSource.Token);
_logger.LogInformation($"Received bytes : {received.Count}");

View file

@ -43,7 +43,7 @@ namespace Yavsc.Services
claimAdds.Remove("profile");
claimAdds.Add(JwtClaimTypes.Name);
claimAdds.Add(JwtClaimTypes.Email);
claimAdds.Add(YavscConstants.RoleClaimType);
claimAdds.Add(Constants.RoleClaimType);
}
if (claimAdds.Contains(JwtClaimTypes.Name))
@ -52,12 +52,12 @@ namespace Yavsc.Services
if (claimAdds.Contains(JwtClaimTypes.Email))
claims.Add(new Claim(JwtClaimTypes.Email, user.Email));
if (claimAdds.Contains(YavscConstants.RoleClaimType))
if (claimAdds.Contains(Constants.RoleClaimType))
{
var roles = await this._userManager.GetRolesAsync(user);
if (roles.Count()>0)
{
claims.AddRange(roles.Select(r => new Claim(YavscConstants.RoleClaimType, r)));
claims.AddRange(roles.Select(r => new Claim(Constants.RoleClaimType, r)));
}
}
return claims;

View file

@ -8,8 +8,8 @@ namespace Yavsc.ViewModels.Account
public class ExternalLoginConfirmationViewModel
{
[Required]
[YaStringLength(2,YavscConstants.MaxUserNameLength)]
[YaRegularExpression(YavscConstants.UserNameRegExp)]
[YaStringLength(2,Constants.MaxUserNameLength)]
[YaRegularExpression(Constants.UserNameRegExp)]
public string Name { get; set; }
[Required]

View file

@ -80,7 +80,7 @@ namespace cli {
_logger.LogInformation("Connecting to " + url);
await _client.ConnectAsync(new Uri(url), _tokenSource.Token);
_logger.LogInformation("Connected");
const int bufLen = Yavsc.YavscConstants.WebSocketsMaxBufLen;
const int bufLen = Yavsc.Constants.WebSocketsMaxBufLen;
byte [] buffer = new byte[bufLen];
const int offset=0;
int read;
@ -90,7 +90,7 @@ namespace cli {
do
{
read = await stream.ReadAsync(buffer, offset, bufLen);
lastFrame = read < Yavsc.YavscConstants.WebSocketsMaxBufLen;
lastFrame = read < Yavsc.Constants.WebSocketsMaxBufLen;
ArraySegment<byte> segment = new ArraySegment<byte>(buffer, offset, read);
await _client.SendAsync(segment, pckType, lastFrame, _tokenSource.Token);
_logger.LogInformation($"sent {segment.Count} ");

View file

@ -40,8 +40,8 @@ namespace cli
[NotMapped]
[JsonIgnore]
public string StreamingUrl { get {
return Port==0 ? $"ws://{Authority}"+YavscConstants.StreamingPath:
$"ws://{Authority}:{Port}"+YavscConstants.StreamingPath;
return Port==0 ? $"ws://{Authority}"+Constants.StreamingPath:
$"ws://{Authority}:{Port}"+Constants.StreamingPath;
} }
}