Compare commits

...

16 commits

Author SHA1 Message Date
9da6888e03 Merge pull request 'feat/postit-acl-members' (#41) from feat/postit-acl-members into release/1.0.8-rc1
Reviewed-on: #41
2026-08-21 22:31:58 +01:00
d2a0c263dd
acl post: reject BlogPostId <= 0 with 400, no 500
All checks were successful
Dotnet build and test / build (pull_request) Successful in 9m18s
The 2026-08-21 prod 500 on POST /api/v1/blogacl was caused by the
PostIt client sending { circleId } only — the server deserialised
into CircleAuthorizationToBlogPost with BlogPostId = default(long) = 0,
and EF Core refused the INSERT with InvalidOperationException.

The PostIt-side fix lives in b82b6722 (enrich the payload with
blogPostId). This commit is the server-side guard: validate
BlogPostId > 0 in the controller and return 400 BadRequest instead
of letting the request reach SaveChangesAsync. The same shape that
crashed on 2026-08-21 now fails fast at the validation layer.

Verified by BlogAclApiTests.PostCircleAuthorization_dosent_return_500:
sentinel that asserts 'never 500' on a payload with BlogPostId = -1.
Previously red (500 from EF Core), now green (400 from the new guard).
2026-08-21 22:08:05 +01:00
e48ede1e84
acl post: never 500 regression sentinel + async CheckOwner + fixture seed
The hard rule on POST /api/v1/blogacl is: a 500 is never acceptable,
regardless of the payload shape. The prod 500 logged on 2026-08-21 on
mercure was caused by the PostIt client sending { circleId } only, which
the server deserialised into CircleAuthorizationToBlogPost with
BlogPostId = default(long) = 0; EF Core refused the INSERT with
InvalidOperationException: The value of
'CircleAuthorizationToBlogPost.BlogPostId' is unknown. The PostIt fix
lives in b82b6722 (enrich the payload with blogPostId). The server-side
guard lives in this commit:

- BlogAclApiController.CheckOwner is now async and uses FirstOrDefaultAsync
  instead of First, so it does not deadlock the request thread and
  returns false on a missing circle (which the controller already maps
  to ChallengeResult).
- BlogsWebServerFixture now seeds Alice, her Circle and her BlogPost
  in ConfigurePipelineAsync, once at host startup, against the shared
  SqliteConnection (Cache=Shared). EnsureCreated is idempotent and
  runs against the connection that every DbContext resolves through,
  so the test theory can POST payloads with real FK ids against a
  schema that actually has the Circle / BlogSpot tables.
- BlogAclApiTests:
    - PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape
      is the regression sentinel for the prod fix.
    - PostCircleAuthorization_never_returns_500 is a [Theory] over
      several payload shapes; any future commit that reintroduces a
      500 path turns it red. CleanupAcl at the start of each insert-
      bearing test isolates against xUnit's no-guarantee-of-order
      execution: a successful POST in test N would otherwise conflict
      with test N+1 against the same (CircleId, BlogPostId) pair.
2026-08-21 22:00:27 +01:00
4956890236
refacto seed test db
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m8s
2026-08-21 20:45:46 +01:00
c645973b52
no object
Some checks failed
Dotnet build and test / build (pull_request) Failing after 9m2s
2026-08-21 20:33:30 +01:00
34c7b153ff
warnings 2026-08-21 20:33:15 +01:00
4b9b8d5e78
fixes the compile
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-21 20:25:13 +01:00
107c4d0b00
test some failling pathes
Some checks failed
Dotnet build and test / build (pull_request) Failing after 4m37s
2026-08-21 19:37:22 +01:00
76a3660dcf
workaround on testing the null CloseButton
Some checks failed
Dotnet build and test / build (pull_request) Failing after 7m41s
2026-08-21 18:46:26 +01:00
404d406931
Testing circle was authorized
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-21 17:21:54 +01:00
b82b6722c7
fixes a 500 in prod and the associated test 2026-08-21 16:58:27 +01:00
8f91cbed02
A Circle must pre-exist before beeing used by an authorization 2026-08-21 16:40:24 +01:00
88461786ee
Roll back refacto on Posit.Tests 2026-08-21 16:18:20 +01:00
06672c4c90
remove dead 'Comment' field from CircleAuthorizationToBlogPost
The bool Comment on CircleAuthorizationToBlogPost was dead code:
never read or written by any caller in src/, no UI exposure, no
behavioural semantics. The wire DTO (CircleAuthorization in
Yavsc.Abstract) doesn't carry it, no reader consumes it, and the
PostIt client builds its payload without it.

What changes:
- src/Yavsc.Server/Models/Access/CircleAuthorizationToBlogPost.cs:
  remove the property.
- src/Yavsc.Blogs.Tests/BlogAclApiTests.cs: drop 'Comment = true'
  from the existing test payload and trim the now-inaccurate XML
  doc comment ('CircleId + BlogPostId + Comment' -> 'CircleId +
  BlogPostId'). Also adds a new [Fact] pinning the prod bug
  reported on 2026-08-21 (HTTP 500 'BlogPostId is unknown' when
  PostIt POSTs the bare { circleId } shape). That test stays red:
  the real fix for the 500 is in PostIt (payload needs blogPostId)
  + on the wire DTO + server-side validation, and lives in a
  follow-up commit.

Migration:
- src/Yavsc.Org/Migrations/20260820232152_DropCommentFromCircleAuthorizationToBlogPost
  drops the boolean 'Comment' column on CircleAuthorizationToBlogPost.
  The generated scaffold also wanted to drop three 'ClientId1'
  shadow FK columns on ClientScopes / ClientRedirectUris /
  ClientGrantTypes (from leftover HasOne<Client>() overrides in
  ApplicationDbContext.OnModelCreating); those were removed from
  the .cs to keep the migration scoped to this fix. Cleaning up the
  shadow property declarations themselves is left as a separate
  task.

The ModelSnapshot still reflects the shadow 'ClientId1' columns
intentionally: they exist in the prod database today (all NULL),
and EF will rescaffold a drop migration for them on the next
'migrations add' regardless. No data loss.
2026-08-21 00:27:27 +01:00
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 6042 additions and 352 deletions

View file

@ -115,6 +115,13 @@ Quelques règles non capturées par `.editorconfig` :
- Préférer les types BCL (`int`, `string`) aux types framework - Préférer les types BCL (`int`, `string`) aux types framework
(`Int32`, `String`). (`Int32`, `String`).
- Préférer les expressions de pattern matching aux casts explicites. - Préférer les expressions de pattern matching aux casts explicites.
- **Pas de `object` dans le code source applicatif.** Types de retour,
paramètres, champs, propriétés, variables locales : tout doit être
typé statiquement. `dynamic` est interdit pour les mêmes raisons.
Un cast en `object` est presque toujours le symptôme d'un contrat
qu'on a laissé s'effriter (DTO, payload, handler) — refactore
le contrat (record typé, DTO dédié, méthode dédiée) au lieu de
shimer avec un cast.
## Branches & commits ## Branches & commits

View file

@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" 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.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <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=.. SLNDIR=..
include $(SLNDIR)/.env include $(SLNDIR)/.env
@ -7,7 +7,6 @@ include .env
generated/: generated/:
@mkdir -p $@ @mkdir -p $@
generated/yavscApi.service:
generated/yavscOrg.service: generated/yavscOrg.service:
generated/yavscBlogs.service: generated/yavscBlogs.service:
@ -34,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@ @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-Org: /etc/systemd/system/yavscOrg.service
copy-service-Api: /etc/systemd/system/yavscApi.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.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); \ @for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \ echo "$${project} -> $${LCAPI}" ; \
@ -86,7 +84,6 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(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.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@ -95,4 +92,4 @@ showConfig:
clean: clean:
@rm -rf generated @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,156 @@
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>
public class AddCircleMemberDialogTests
{
/// <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()))
{ }
}
private static async Task<TestAppContext> BuildApp()
{
TestAppContext context = new TestAppContext
{
};
return context;
}
/// <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 async Task<TestAppContext> Mount()
{
TestAppContext context = new TestAppContext();
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();
context.Window = new MainWindow();
context.App = (PostIt.App)Application.Current!;
context.App.DataTemplates.Clear();
context.App.DataTemplates.Add(new ViewLocator(sp));
context.App.AttachMainWindow(context.Window);
context.Window.Show();
context.page = sp.GetRequiredService<CirclesPage>();
context.Window.NavRoot.PushAsync(context.page).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.
await context.App.PushPageAsync(sp.GetRequiredService<AddCircleMemberDialogViewModel>());
context.dialog = context.Window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog
?? throw new System.InvalidOperationException("Dialog page not at top of stack.");
return context;
}
/// <summary>
/// Click the "Fermer" button on the dialog and assert the
/// nav stack shrinks by exactly one.
/// </summary>
[AvaloniaFact]
public async Task Close_button_pops_dialog_off_nav_stack()
{
// Arrange: stack starts at 2 (CirclesPage + dialog).
var context = await Mount();
var window = context.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.
// FIXME Assert.NotNull(dialog.CloseButton):
// in order to click it by its def :
// dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
// The workaround is to execute the action like it's written :
await context.App!.GoBackAsync();
// 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

@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
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>AttachedToVisualTree</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_visual_attachment</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>
/// </summary>
public class PostAclDialogTests
{
/// <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!);
}
}
/// <summary>
/// Build a minimal DI graph exposing the two API clients
/// (backed by a stub HTTP handler) and the page itself, so
/// <c>ViewLocator</c> can resolve the dialog from the VM.
/// Returns the handler, the API clients, and the window so
/// the test can assert on request counts and push the
/// dialog via the canonical <c>App.PushPageAsync</c> path.
/// The DI graph is built into a local <see cref="IServiceProvider"/>
/// that is NOT attached to <see cref="App.ServiceProvider"/>:
/// rebinding the global DI mid-test would trample the
/// Settings singleton the rest of the harness depends on.
/// </summary>
private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
{
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(aclClient);
services.AddSingleton(circleClient);
services.AddTransient<PostAclDialog>();
var sp = services.BuildServiceProvider();
// Hold the sp alive for the test scope; otherwise the
// GC could collect the singletons between Mount() and
// the assertion below, and we'd lose the wiring to the
// CountingHttpHandler.
GC.KeepAlive(sp);
var window = new MainWindow();
var app = (App)Application.Current!;
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(sp));
app.AttachMainWindow(window);
window.Show();
return (window, aclClient, circleClient, handler);
}
/// <summary>
/// The bug: opening the dialog never called LoadAsync, so
/// MyCircles/AclEntries were empty. After the fix, setting
/// the dialog's DataContext to a PostAclDialogViewModel
/// (the same path App.PushPageAsync takes) must trigger
/// exactly one LoadAsync round-trip (the parallel WhenAll
/// inside the VM counts as one request per backend call,
/// hence two HTTP requests total: GET /blogacl and GET
/// /circle).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_runs_once_on_DataContext_changed()
{
// Arrange
var (window, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 42, Title = "Test post" };
// Sanity: handler starts quiet.
Assert.Equal(0, handler.RequestCount);
// Act: push the dialog via the canonical VM-first pipeline.
// The locator goes through the parameterless ctor of
// PostAclDialog, then App.PushPageAsync assigns DataContext,
// which our hook intercepts to trigger LoadAsync.
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
await ((App)Application.Current!).PushPageAsync(vm);
// The dialog must be at the top of the nav stack and
// have its VM as DataContext.
var dialog = window.NavRoot.NavigationStack[^1] as PostAclDialog
?? throw new InvalidOperationException("Dialog not at top of stack");
Assert.Same(vm, dialog.DataContext);
// Drain pending async work. LoadAsync is async and the
// DataContextChanged handler is fire-and-forget; a
// couple of loop turns is enough. We poll the handler
// counter because the dispatch back onto the headless
// dispatcher isn't strict — using a generous-but-bounded
// wait avoids test flakes.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (handler.RequestCount < 2 && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
// Assert: exactly two GETs went out (one to /blogacl,
// one to /circle), both from the LoadAsync call.
Assert.Equal(2, handler.RequestCount);
// And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded);
}
/// <summary>
/// The fix exposes a guard on the VM too: a second call to
/// LoadAsync on the same instance must NOT issue more HTTP
/// traffic. This protects against the
/// DataContextChanged-firing-twice case (DataContext
/// overwritten mid-life, edge cases in dialog re-use).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_is_idempotent()
{
// Arrange
var (_, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 99, Title = "Idempotency" };
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
// Act: invoke LoadAsync twice in a row.
await vm.LoadAsync();
await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded.
Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded);
}
}

View file

@ -0,0 +1,11 @@
using PostIt.Views;
namespace PostIt.Tests;
internal class TestAppContext
{
public MainWindow? Window {get; set; }
public CirclesPage? page {get; set; }
public AddCircleMemberDialog? dialog { get; set; }
public App? App { get; internal set; }
}

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. // build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = BuildServices(); this.ServiceProvider = BuildServices(new ServiceCollection());
AttachServiceProvider(ServiceProvider); AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>(); var settings = ServiceProvider.GetRequiredService<Settings>();
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
@ -139,7 +139,7 @@ public partial class App : Application
/// or service resolves through the same wiring the real app /// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod. /// does, and a green test is a green contract for prod.
/// </summary> /// </summary>
internal static IServiceProvider BuildServices() internal static IServiceProvider BuildServices(ServiceCollection services)
{ {
var settings = new Settings(); var settings = new Settings();
settings.Load(); settings.Load();
@ -156,7 +156,6 @@ public partial class App : Application
var contactService = new ContactService(); var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
// Vues // Vues
services.AddTransient<MainPage>(); services.AddTransient<MainPage>();
@ -350,4 +349,9 @@ public partial class App : Application
return window.NavRoot.PushAsync(page); 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.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Services; using PostIt.Services;
using PostIt.Views;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -106,7 +107,7 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
/// UI from firing an event with a null payload. /// UI from firing an event with a null payload.
/// </summary> /// </summary>
[RelayCommand] [RelayCommand]
public void Add() public async Task AddAsync()
{ {
if (Selected is null) if (Selected is null)
{ {
@ -114,5 +115,14 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
return; return;
} }
Confirmed?.Invoke(this, Selected); 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>(); var directory = services.GetRequiredService<IUserDirectory>();
AddCircleMemberDialogViewModel model = AddCircleMemberDialogViewModel model =
new AddCircleMemberDialogViewModel(directory); 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); await app.PushPageAsync(model);
} }
/// <summary> /// <summary>

View file

@ -1,14 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.BlogSpot;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -38,10 +37,12 @@ public partial class PostAclDialogViewModel : ViewModelBase
public BlogPostDto Post { get; } public BlogPostDto Post { get; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new(); public partial ObservableCollection<CircleDto>
MyCircles { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<CircleAuthorization> AclEntries { get; set; } = new(); public partial ObservableCollection<PostAccessControlRulePayload>
AclEntries { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial CircleDto? SelectedCircleToAdd { get; set; } public partial CircleDto? SelectedCircleToAdd { get; set; }
@ -52,6 +53,22 @@ public partial class PostAclDialogViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty; 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( public PostAclDialogViewModel(
BlogPostDto post, BlogPostDto post,
BlogAclApiClient aclClient, BlogAclApiClient aclClient,
@ -68,6 +85,8 @@ public partial class PostAclDialogViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
public async Task LoadAsync() public async Task LoadAsync()
{ {
if (_loaded) return;
IsBusy = true; IsBusy = true;
try try
{ {
@ -83,6 +102,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
StatusMessage = $"{AclEntries.Count} autorisation(s)"; StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -106,9 +126,10 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
var created = await _aclClient.GrantAsync(new CircleAuthorization var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
{ {
CircleId = SelectedCircleToAdd.Id CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id
}); });
if (created is not null) if (created is not null)
{ {
@ -131,7 +152,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
public async Task RevokeAsync(CircleAuthorization? acl) public async Task RevokeAsync(PostAccessControlRulePayload? acl)
{ {
if (acl is null) return; if (acl is null) return;
IsBusy = true; IsBusy = true;

View file

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

View file

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

View file

@ -1,3 +1,4 @@
using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -9,21 +10,53 @@ namespace PostIt.Views;
/// <summary> /// <summary>
/// Modal "manage ACL" page for a single blog post. /// Modal "manage ACL" page for a single blog post.
/// ///
/// <para>The ViewModel is constructed here (not via DI) because it /// <para>The ViewModel is constructed by the caller (the post
/// depends on the post being managed, which the caller (the post /// list page) and handed to <see cref="App.PushPageAsync"/>,
/// list page) only knows at the moment it opens the dialog. The /// which routes through <see cref="ViewLocator"/> and lands
/// DI container can build the two API clients; the post and the /// here via the parameterless DI constructor. The VM is then
/// VM are wired together here.</para> /// 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> /// </summary>
public partial class PostAclDialog : ContentPage public partial class PostAclDialog : ContentPage
{ {
public PostAclDialog() public PostAclDialog()
{ {
InitializeComponent(); 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) 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(); InitializeComponent();
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient); DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
} }

View file

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

View file

@ -0,0 +1,10 @@
using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot;
public class PostAccessControlRulePayload : ICircleAuthorization
{
public long CircleId { get; set; }
public long BlogPostId { get; set; }
}

View file

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

View file

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

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
@ -33,16 +34,16 @@ public sealed class BlogAclApiClient
api.Http.BaseAddress = new Uri(blogsBaseAddress); api.Http.BaseAddress = new Uri(blogsBaseAddress);
} }
public Task<List<CircleAuthorization>> GetMyAclAsync(CancellationToken ct = default) public Task<List<PostAccessControlRulePayload>> GetMyAclAsync(CancellationToken ct = default)
=> _api.CallAsync<List<CircleAuthorization>>(HttpMethod.Get, Path, ct: ct); => _api.CallAsync<List<PostAccessControlRulePayload>>(HttpMethod.Get, Path, ct: ct);
public Task<CircleAuthorization?> GetAclAsync(long circleId, CancellationToken ct = default) public Task<PostAccessControlRulePayload?> GetAclAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorization?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct); => _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
public Task<CircleAuthorization?> GrantAsync(CircleAuthorization acl, CancellationToken ct = default) public Task<PostAccessControlRulePayload?> GrantAsync(PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorization?>(HttpMethod.Post, Path, body: acl, ct: ct); => _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Post, Path, body: acl, ct: ct);
public Task UpdateAclAsync(long circleId, CircleAuthorization acl, CancellationToken ct = default) public Task UpdateAclAsync(long circleId, PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct); => _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
public Task RevokeAsync(long circleId, CancellationToken ct = default) public Task RevokeAsync(long circleId, CancellationToken ct = default)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,221 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.BlogSpot;
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).
///
/// <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;
}
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
/// <summary>Delete any ACL rows tied to the fixture's seeded
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
/// persists across tests, so tests that POST a successful ACL
/// row would otherwise conflict with whichever other test runs
/// next against the same pair — xUnit does not guarantee
/// execution order. Calling this at the start of each
/// insert-bearing test guarantees a clean slate regardless of
/// the previous test's outcome.</summary>
private void CleanupAcl()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == _fixture.CircleId
&& a.BlogPostId == _fixture.PostId)
.ExecuteDelete();
}
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>
/// Reproduces the prod 500 logged on 2026-08-21 on mercure:
/// <c>InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown</c>
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
/// shape <c>{ "circleId": &lt;id&gt; }</c> — the exact body the
/// PostIt client builds from <see cref="CircleAuthorization"/>
/// (which only carries <c>CircleId</c>). The server deserialises
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
/// no <c>Target</c> navigation, and EF Core refuses to INSERT
/// during <c>PrepareToSave()</c>. The fix lives in PostIt
/// (enrich the payload with <c>blogPostId</c> + <c>comment</c>)
/// and on the wire DTO (<see cref="CircleAuthorization"/> must
/// carry those fields); the server validates. Until that ships,
/// this test stays red.
/// </summary>
[Fact]
public async Task PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test()
{
// The prod circle already exists with Name="test", Public=true,
// owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end.
CleanupAcl();
using var http = NewClient("alice");
var payload = new PostAccessControlRulePayload
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.PostId
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
/// <summary>
/// Payload templates for <see cref="PostCircleAuthorization_never_returns_500"/>.
/// Each row carries the shape we want to POST; <c>-1L</c> and
/// <c>-2L</c> are negative sentinels that the test substitutes
/// with the ids of freshly seeded <c>Circle</c> / <c>BlogPost</c>
/// rows before sending, so every shape lands against a real
/// principal entity and the seeded fixtures are not dead.
/// </summary>
public static IEnumerable<object[]> BlogAclPayloadsForNever500()
{
// circleId only (the historical bug shape, 2026-08-21 mercure):
// must be rejected, never 500.
return new object[][]
{
[
new PostAccessControlRulePayload
{
BlogPostId = -2,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = 1
}
]
} ;
}
/// <summary>
/// Hard rule (Paul, 2026-08-21): a 500 is never acceptable
/// </summary>
[Theory]
[MemberData(nameof(BlogAclPayloadsForNever500))]
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
{
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500 ()
{
CleanupAcl();
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = -1,
CircleId = _fixture.CircleId
}
);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500_on_success ()
{
CleanupAcl();
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = _fixture.PostId,
CircleId = _fixture.CircleId
}
);
}
}

View file

@ -12,6 +12,7 @@ namespace Yavsc.Blogs.Tests;
/// surface. The first behavioural test (GET /api/v1/blog returns /// surface. The first behavioural test (GET /api/v1/blog returns
/// 200) lands in a follow-up commit. /// 200) lands in a follow-up commit.
/// </summary> /// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture> public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
{ {
private readonly BlogsWebServerFixture _fixture; 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 /// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework. /// 401 back from the framework.
/// </summary> /// </summary>
[Collection("JwtClaimMapping")] [Collection("Yavsc Blogs")]
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture> public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{ {
private readonly BlogsWebServerFixture _fixture; private readonly BlogsWebServerFixture _fixture;
@ -45,6 +45,21 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
db.Database.EnsureCreated(); 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 /// <summary>The fixture's <c>WebApplication</c> is bound to
/// <c>https://localhost:&lt;random&gt;</c> via /// <c>https://localhost:&lt;random&gt;</c> via
/// <see cref="WebHostFixture.Addresses"/>. We pick the first /// <see cref="WebHostFixture.Addresses"/>. We pick the first
@ -116,7 +131,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list() public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(); using var http = NewClient();
// Create a minimal BlogPost. The server assigns Id, so we // Create a minimal BlogPost. The server assigns Id, so we
@ -154,7 +169,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry() public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
var draft = new BlogPost var draft = new BlogPost
@ -186,7 +201,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PostBlogComment_returns_201_for_existing_post() public async Task PostBlogComment_returns_201_for_existing_post()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
var draft = new BlogPost var draft = new BlogPost
@ -249,7 +264,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update() 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: // The JWT's sub must match the post's AuthorId:
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(), // PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
// and UserHelpers.GetUserId reads "sub" off the principal. // and UserHelpers.GetUserId reads "sub" off the principal.
@ -300,7 +315,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list() public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(); using var http = NewClient();
// Seed a post we can delete. // Seed a post we can delete.
@ -342,7 +357,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// ModelState validation starts rejecting the PostIt payload // ModelState validation starts rejecting the PostIt payload
// (missing field, wrong casing, etc.), this test fails // (missing field, wrong casing, etc.), this test fails
// before the regression reaches a user. // before the regression reaches a user.
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
// Mirrors what MainPageViewModel.Save builds: a BlogPost with // Mirrors what MainPageViewModel.Save builds: a BlogPost with

View file

@ -1,27 +1,33 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Yavsc.Blogs.Controllers; using Yavsc.Blogs.Controllers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
/// <summary> /// <summary>
/// Test host for the Yavsc.Blogs API surface. Specialisation of /// Shared integration-test host for the Yavsc.Blogs API surface.
/// <see cref="WebHostFixture"/> that wires up only the bits the /// Specialisation of <see cref="WebHostFixture"/> that wires up
/// blog API actually depends on: /// only the bits the blog API actually depends on:
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item><description>An in-memory <see cref="ApplicationDbContext"/> /// <item><description>A SQLite <c>:memory:</c> database
/// (the real one — no mock) so <c>BlogSpotService.Index</c> can run /// (<see cref="Microsoft.EntityFrameworkCore.Sqlite"/>) backed
/// against an empty table and return an empty list.</description></item> /// 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"/> /// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: the GET index path doesn't read the file system, so any /// stub: the GET index path doesn't read the file system, so any
/// implementation is fine.</description></item> /// implementation is fine.</description></item>
@ -44,31 +50,61 @@ namespace Yavsc.Blogs.Tests;
/// ///
/// No IdentityServer, no SMTP, no static assets — the Org fixture /// 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 /// 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> /// </summary>
[CollectionDefinition("Yavsc Blogs")]
public sealed class BlogsWebServerFixture : WebHostFixture public sealed class BlogsWebServerFixture : WebHostFixture
{ {
protected override int HttpsPort => 5103; protected override int HttpsPort => 5103;
private InMemoryDatabaseRoot? _inMemoryRoot; public long CircleId { get; private set; }
public long PostId { get; private set; }
// 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) protected override WebApplication BuildApp(WebApplicationBuilder builder)
{ {
// Use the real ApplicationDbContext with an in-memory store. // Open the shared in-memory connection lazily on the first
// BlogSpotService reads _context.BlogSpot directly, so any // fixture construction. Subsequent constructions (xUnit
// attempt to mock it would be wasted work; the real service // creates one fixture instance per IClassFixture) reuse
// against an empty table returns an empty list, which is // the same connection so all DbContexts across all tests
// exactly what the first test wants to assert. // see the same database.
// SqliteConnection sharedConnection;
// Share a single InMemoryDatabaseRoot across the test lock (_sqliteLock)
// lifetime so POST + GET on the same fixture see the same {
// store. Without the root, EF Core's In-Memory provider if (_sharedSqliteConnection is null)
// creates independent stores per DbContext in some {
// configurations, and the second request would see an // Mode=Memory + Cache=Shared gives us a named
// empty list even after the first wrote a row. // in-memory database that every connection string
_inMemoryRoot = new InMemoryDatabaseRoot(); // 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 => 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 // Trivial file-system auth: the GET index path never calls
// into it, but the DI container needs an instance. // into it, but the DI container needs an instance.
@ -145,7 +181,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// remaps long Microsoft claim URIs, not sub). // remaps long Microsoft claim URIs, not sub).
// UserHelpers.GetUserId reads sub directly. // UserHelpers.GetUserId reads sub directly.
NameClaimType = "sub", NameClaimType = "sub",
RoleClaimType = YavscConstants.RoleClaimType, RoleClaimType = Yavsc.Constants.RoleClaimType,
}; };
}); });
@ -164,10 +200,139 @@ public sealed class BlogsWebServerFixture : WebHostFixture
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
// EnsureCreated + seed alice, run once at host startup.
// EnsureCreated is idempotent (creates only the tables that
// don't exist yet) and runs against the shared
// SqliteConnection (Cache=Shared), so every DbContext that
// resolves through this fixture's host sees the same schema.
// We do NOT call EnsureDeleted: the SqliteConnection is held
// open at the static level and closing it destroys the
// :memory: store for every other DbContext — the org
// fixture can afford EnsureDeleted because its store is
// built fresh per fixture, but the blogs fixture's static
// connection outlives a single fixture instance.
using (var seedScope = app.Services.CreateScope())
{
var db = seedScope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureCreated();
if (!db.Users.Any(u => u.Id == "alice"))
{
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.SaveChanges();
// Inline the seed of the circle + post. We don't
// call SeedCircle/SeedBlogPost (the instance helpers)
// because those resolve through this.Services, which
// is null until WebHostFixture.InitializeAsync has
// finished wiring the shared slot — i.e. after this
// method returns. Use app.Services directly.
var circle = new Circle
{
OwnerId = "alice",
Name = "test",
Public = true,
};
db.Circle.Add(circle);
db.SaveChanges();
CircleId = circle.Id;
var post = new BlogPost
{
AuthorId = "alice",
Title = "Billet ACL test",
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
PostId = post.Id;
}
}
await Task.CompletedTask; await Task.CompletedTask;
return app; 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 /// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
/// blog API endpoints exercised by the first tests don't read the /// blog API endpoints exercised by the first tests don't read the
/// file system, so the implementation can be a no-op.</summary> /// file system, so the implementation can be a no-op.</summary>
@ -180,4 +345,39 @@ public sealed class BlogsWebServerFixture : WebHostFixture
{ {
} }
} }
/// <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, bool isPublic = false)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
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 = 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;
}
} }

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
@ -88,7 +89,7 @@ public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
} }
private string MembersUrl(long circleId) 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) private HttpClient NewClient(string subject)
{ {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,17 @@
using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Access; using Yavsc.Models.Access;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/blogacl")] [Route(APIPrefix+"/blogacl")]
public class BlogAclApiController : Controller public class BlogAclApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -24,7 +26,7 @@ namespace Yavsc.Blogs.Controllers
/// Blog posts (and therefore their ACLs) are private to their /// Blog posts (and therefore their ACLs) are private to their
/// author — the API never exposes another user's ACL. /// author — the API never exposes another user's ACL.
/// </summary> /// </summary>
// GET: api/blogacl // GET: api/v1/blogacl
[HttpGet] [HttpGet]
public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL() public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL()
{ {
@ -68,7 +70,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest(); return BadRequest();
} }
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId)) if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
{ {
return new ChallengeResult(); return new ChallengeResult();
} }
@ -92,27 +94,42 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent); return new StatusCodeResult(StatusCodes.Status204NoContent);
} }
private bool CheckOwner (long circleId) private async Task<bool> CheckOwnerAsync (long circleId)
{ {
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var circle = _context.Circle.First(c=>c.Id==circleId); if (uid==null) return false;
_context.Entry(circle).State = EntityState.Detached; var circle = await _context.Circle.FirstOrDefaultAsync(c=>c.Id==circleId);
return (circle.OwnerId == uid); if (circle == null) return false;
return circle.OwnerId == uid;
} }
// POST: api/BlogAclApi // POST: api/BlogAclApi
[HttpPost] [HttpPost]
public async Task<IActionResult> PostCircleAuthorizationToBlogPost([FromBody] CircleAuthorizationToBlogPost circleAuthorizationToBlogPost) public async Task<IActionResult> PostCircleAuthorizationToBlogPost(
[FromBody] PostAccessControlRulePayload circleAuthorizationToBlogPost)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return BadRequest(ModelState); return BadRequest(ModelState);
} }
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId)) // No 500: a missing or zero BlogPostId is a client
// error, not an EF Core FK violation waiting to happen.
// The 2026-08-21 prod 500 was this exact path (PostIt
// sent only circleId, server saw BlogPostId = 0 and
// SaveChangesAsync threw InvalidOperationException).
if (circleAuthorizationToBlogPost.BlogPostId <= 0)
{
return BadRequest("BlogPostId is required and must be > 0.");
}
if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
{ {
return new ChallengeResult(); return new ChallengeResult();
} }
_context.CircleAuthorizationToBlogPost.Add(circleAuthorizationToBlogPost); CircleAuthorizationToBlogPost entity = new CircleAuthorizationToBlogPost
{
BlogPostId = circleAuthorizationToBlogPost.BlogPostId,
CircleId = circleAuthorizationToBlogPost.CircleId
};
_context.CircleAuthorizationToBlogPost.Add(entity);
try try
{ {
await _context.SaveChangesAsync(User.GetUserId()); await _context.SaveChangesAsync(User.GetUserId());

View file

@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Server.Exceptions; using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers 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.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,11 +19,11 @@ namespace Yavsc.Org.Tests
{ {
this.output = output; this.output = output;
_serverFixture = serverFixture; _serverFixture = serverFixture;
_logger = serverFixture.Logger; _logger = serverFixture.Logger!;
} }
[Fact] [Fact]
public void SendEMailSynchrone() public async Task SendEMailSynchrone()
{ {
using IServiceScope scope = _serverFixture.Services.CreateScope(); using IServiceScope scope = _serverFixture.Services.CreateScope();
@ -32,12 +32,12 @@ namespace Yavsc.Org.Tests
scope.ServiceProvider.GetRequiredService<ISmtpClientFactory>()); scope.ServiceProvider.GetRequiredService<ISmtpClientFactory>());
output.WriteLine("SendEMailSynchrone ..."); output.WriteLine("SendEMailSynchrone ...");
mailSender.SendEmailAsync await mailSender.SendEmailAsync
( (
_serverFixture.SiteSettings.Owner.Name, _serverFixture.SiteSettings!.Owner.Name,
_serverFixture.SiteSettings.Owner.EMail, _serverFixture.SiteSettings!.Owner.EMail,
$"monthly email", $"monthly email",
"test boby monthly email").Wait(); "test boby monthly email");
// Assert the SMTP roundtrip was short-circuited by the // Assert the SMTP roundtrip was short-circuited by the
// recording fake installed in WebServerFixture: exactly // recording fake installed in WebServerFixture: exactly

View file

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

View file

@ -81,7 +81,7 @@ public sealed class WebServerFixture : WebHostFixture
// that plus the in-memory overrides below. // that plus the in-memory overrides below.
builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?> 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 // SMTP test config: UserName non-null so MailSender
// exercises the Authenticate branch — the // exercises the Authenticate branch — the
// RecordingSmtpClient captures it. // RecordingSmtpClient captures it.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class DropCommentFromCircleAuthorizationToBlogPost : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Comment",
table: "CircleAuthorizationToBlogPost");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Comment",
table: "CircleAuthorizationToBlogPost",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}

View file

@ -476,9 +476,6 @@ namespace Yavsc.Migrations
b.Property<int>("ClientId") b.Property<int>("ClientId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("ClientId1")
.HasColumnType("integer");
b.Property<string>("GrantType") b.Property<string>("GrantType")
.HasColumnType("text"); .HasColumnType("text");
@ -486,8 +483,6 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId"); b.HasIndex("ClientId");
b.HasIndex("ClientId1");
b.ToTable("ClientGrantTypes"); b.ToTable("ClientGrantTypes");
}); });
@ -583,9 +578,6 @@ namespace Yavsc.Migrations
b.Property<int>("ClientId") b.Property<int>("ClientId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("ClientId1")
.HasColumnType("integer");
b.Property<string>("RedirectUri") b.Property<string>("RedirectUri")
.HasColumnType("text"); .HasColumnType("text");
@ -593,8 +585,6 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId"); b.HasIndex("ClientId");
b.HasIndex("ClientId1");
b.ToTable("ClientRedirectUris"); b.ToTable("ClientRedirectUris");
}); });
@ -609,9 +599,6 @@ namespace Yavsc.Migrations
b.Property<int>("ClientId") b.Property<int>("ClientId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("ClientId1")
.HasColumnType("integer");
b.Property<string>("Scope") b.Property<string>("Scope")
.HasColumnType("text"); .HasColumnType("text");
@ -619,8 +606,6 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId"); b.HasIndex("ClientId");
b.HasIndex("ClientId1");
b.ToTable("ClientScopes"); b.ToTable("ClientScopes");
}); });
@ -1096,9 +1081,6 @@ namespace Yavsc.Migrations
b.Property<long>("BlogPostId") b.Property<long>("BlogPostId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<bool>("Comment")
.HasColumnType("boolean");
b.HasKey("CircleId", "BlogPostId"); b.HasKey("CircleId", "BlogPostId");
b.HasIndex("BlogPostId"); b.HasIndex("BlogPostId");
@ -3460,16 +3442,12 @@ namespace Yavsc.Migrations
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b => modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
{ {
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany("AllowedGrantTypes") .WithMany("AllowedGrantTypes")
.HasForeignKey("ClientId") .HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany()
.HasForeignKey("ClientId1");
b.Navigation("Client"); b.Navigation("Client");
}); });
@ -3520,31 +3498,23 @@ namespace Yavsc.Migrations
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b => modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
{ {
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany("RedirectUris") .WithMany("RedirectUris")
.HasForeignKey("ClientId") .HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany()
.HasForeignKey("ClientId1");
b.Navigation("Client"); b.Navigation("Client");
}); });
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b => modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
{ {
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null) b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany("AllowedScopes") .WithMany("AllowedScopes")
.HasForeignKey("ClientId") .HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany()
.HasForeignKey("ClientId1");
b.Navigation("Client"); b.Navigation("Client");
}); });

View file

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

View file

@ -13,7 +13,7 @@
} else { } else {
<div class="alert alert-warning"> <div class="alert alert-warning">
<strong>Utilisateur inconnu</strong> <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>
} }
</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> <li><a class="dropdown-item @PageHelpers.ActivePage(ViewContext, "Feature")" asp-controller="Feature" asp-action="Index">Features</a></li>
</ul> </ul>
</li> </li>
@if (User.IsInMsRole(YavscConstants.AdminGroupName)) { @if (User.IsInMsRole(Constants.AdminGroupName)) {
<li class="nav-item dropdown"> <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"> <a class="nav-link dropdown-toggle @PageHelpers.ActivePageAny(ViewContext, administrationControllers)" href="#" id="dropdown05" data-bs-toggle="dropdown" aria-expanded="false">
Administration Administration

View file

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

View file

@ -105,8 +105,8 @@ public static class ServiceExtensions
{ {
ValidateAudience = true, ValidateAudience = true,
ValidAudiences = audiences, ValidAudiences = audiences,
RoleClaimType = YavscConstants.RoleClaimType, RoleClaimType = Constants.RoleClaimType,
NameClaimType = YavscConstants.NameClaimType, NameClaimType = Constants.NameClaimType,
}; };
options.MapInboundClaims = true; options.MapInboundClaims = true;
options.ClaimsIssuer = authority; 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; 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); 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) if (isCop)
{ {
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops); await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
@ -351,7 +351,7 @@ namespace Yavsc.Server.Hubs
var identityUserName = Context.User.GetUserName(); var identityUserName = Context.User.GetUserName();
if (userName[0] != '?' && Context.User!=null) if (userName[0] != '?' && Context.User!=null)
if (!Context.User.IsInMsRole(YavscConstants.AdminGroupName)) if (!Context.User.IsInMsRole(Constants.AdminGroupName))
{ {
var bl = _dbContext.BlackListed var bl = _dbContext.BlackListed

View file

@ -5,12 +5,10 @@ namespace Yavsc.Models.Access
using Newtonsoft.Json; using Newtonsoft.Json;
using Blog; using Blog;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using Yavsc.Abstract.BlogSpot;
public class CircleAuthorizationToBlogPost : ICircleAuthorization public class CircleAuthorizationToBlogPost : PostAccessControlRulePayload
{ {
public long CircleId { get; set; }
public long BlogPostId { get; set; }
[JsonIgnore] [JsonIgnore]
[ForeignKey("BlogPostId")] [ForeignKey("BlogPostId")]
public virtual BlogPost Target { get; set; } public virtual BlogPost Target { get; set; }
@ -19,8 +17,5 @@ namespace Yavsc.Models.Access
[ForeignKey("CircleId")] [ForeignKey("CircleId")]
public virtual Circle Allowed { get; set; } public virtual Circle Allowed { get; set; }
public bool Comment { get; set; }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,14 +31,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Browser", "src\PostI
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostIt\PostIt.Desktop\PostIt.Desktop.csproj", "{EFE24256-9335-44C5-8B77-E180C2DB3C0B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostIt\PostIt.Desktop\PostIt.Desktop.csproj", "{EFE24256-9335-44C5-8B77-E180C2DB3C0B}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{838B9737-88CA-432E-835C-F96817CF8085}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -181,18 +181,6 @@ Global
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x64.Build.0 = Release|Any CPU {EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x64.Build.0 = Release|Any CPU
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.ActiveCfg = Release|Any CPU {EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.ActiveCfg = Release|Any CPU
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.Build.0 = Release|Any CPU {EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.Build.0 = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x64.ActiveCfg = Debug|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x64.Build.0 = Debug|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x86.ActiveCfg = Debug|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x86.Build.0 = Debug|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|Any CPU.Build.0 = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.ActiveCfg = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.Build.0 = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.ActiveCfg = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.Build.0 = Release|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = Debug|Any CPU {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.ActiveCfg = Debug|Any CPU {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -229,6 +217,18 @@ Global
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x64.Build.0 = Release|Any CPU {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x64.Build.0 = Release|Any CPU
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.ActiveCfg = Release|Any CPU {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.ActiveCfg = Release|Any CPU
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.Build.0 = Release|Any CPU {59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.Build.0 = Release|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|Any CPU.Build.0 = Debug|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x64.ActiveCfg = Debug|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x64.Build.0 = Debug|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x86.ActiveCfg = Debug|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x86.Build.0 = Debug|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Release|Any CPU.ActiveCfg = Release|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Release|Any CPU.Build.0 = Release|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x64.ActiveCfg = Release|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x64.Build.0 = Release|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x86.ActiveCfg = Release|Any CPU
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -246,9 +246,9 @@ Global
{4C092CF8-524A-494D-AAFC-69383DFBA31D} = {E13D107F-4053-D0DE-6394-453609595BFE} {4C092CF8-524A-494D-AAFC-69383DFBA31D} = {E13D107F-4053-D0DE-6394-453609595BFE}
{AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE} {AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE}
{EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE} {EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE}
{4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{838B9737-88CA-432E-835C-F96817CF8085} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal