feat(postit): persist call IP privacy preference
Store HideIpAddressInCalls on user accounts, expose it in the profile API and PostIt editor, and preserve preferences for older clients. Add the EF migration, regression tests and architecture documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
df32ec11bf
commit
d8200cb98d
14 changed files with 5263 additions and 12 deletions
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
* [PostIt/API] Persist the account-level `HideIpAddressInCalls` preference (default false), expose it through the authenticated profile API and PostIt profile editor, and preserve it when older clients omit it. Prepare future TURN-only calls without enabling media transport yet.
|
||||
* [PostIt] Show a local live capture preview on Android and Desktop without opening a second camera or reading back uploaded segments. Share Android's oriented EGL frames with an aspect-preserving preview surface; duplicate Desktop LibVLC output without local microphone playback.
|
||||
* [Podcasts] Prepare authenticated, seekable MP4 replays for new and existing Completed broadcasts using server-side FFmpeg stream copy and faststart. Retain MPEG-TS, account for both copies in the owner quota, recover preparation after restarts, and expose owner-only retries plus GET/HEAD byte-range playback.
|
||||
* [PostIt] Add Android/Desktop native video overlays for MP4 replay position, duration, seeking and play/pause, with three-second auto-hide. Use bounded authenticated range input with token refresh; preserve MPEG-TS live/interrupted playback.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
## Statut et périmètre
|
||||
|
||||
Ce document décrit un **plan d'implémentation**, pas une fonctionnalité déjà
|
||||
disponible. Il complète la [topologie de PostIt](postit.md) et les
|
||||
Ce document décrit un **plan d'implémentation** : les appels ne sont pas encore
|
||||
disponibles. La préférence de confidentialité est persistée et éditable dans
|
||||
le profil, mais ne pilote aucun transport média à ce stade.
|
||||
Il complète la [topologie de PostIt](postit.md) et les
|
||||
[mécanismes de notification existants](notifications.md).
|
||||
|
||||
La cible couvre :
|
||||
|
|
@ -82,12 +84,17 @@ simplicité d'exploitation prime sur le coût serveur.
|
|||
|
||||
### Contrat fonctionnel
|
||||
|
||||
Prévoir un booléen de compte, nommé provisoirement `HideIpAddressInCalls`,
|
||||
persisté côté serveur et accessible en lecture/écriture uniquement par
|
||||
l'utilisateur concerné via la gestion de son profil. Le réglage est partagé
|
||||
entre ses appareils ; une préférence locale seule ne suffit pas.
|
||||
Le booléen de compte `HideIpAddressInCalls` est persisté côté serveur et
|
||||
accessible en lecture/écriture uniquement par
|
||||
l'utilisateur concerné via `GET /api/v1/account/me` et
|
||||
`PUT /api/v1/account/me`, ainsi que dans « Mon profil » de PostIt.
|
||||
Le réglage est partagé entre ses appareils ; une préférence locale seule
|
||||
ne suffit pas.
|
||||
|
||||
Valeur initiale proposée : `false`, à confirmer avant implémentation.
|
||||
Valeur initiale : `false`, pour les nouveaux comptes et les comptes existants
|
||||
après migration. Le champ de mise à jour est nullable : son absence ou `null`
|
||||
conserve la valeur existante, notamment pour les anciens clients ; `false`
|
||||
explicite désactive la préférence.
|
||||
`false` autorise le chemin direct mais n'interdit pas TURN lorsqu'il est
|
||||
nécessaire à la connectivité. `true` impose un transport relayé.
|
||||
|
||||
|
|
@ -96,6 +103,12 @@ en précisant le surcoût potentiel en latence et en bande passante serveur.
|
|||
Il ne procure pas l'anonymat : les services contactés, notamment l'API, le
|
||||
serveur de signalisation et TURN, peuvent toujours connaître l'adresse IP.
|
||||
|
||||
La migration EF Core `HideIpAddressInCalls`, dans le projet Yavsc.Org, ajoute
|
||||
la colonne booléenne non nullable à `AspNetUsers`. Elle doit être déployée
|
||||
avant de servir les nouvelles lectures et écritures de profil. Cette étape
|
||||
n'installe ni TURN ni SFU et n'implémente pas encore les règles de négociation
|
||||
décrites ci-dessous.
|
||||
|
||||
### Application à la négociation
|
||||
|
||||
- Charger la préférence avant de créer le transport média. Si sa lecture
|
||||
|
|
|
|||
|
|
@ -180,6 +180,60 @@ public class AdministrationAccessTests
|
|||
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task My_profile_loads_and_includes_the_call_privacy_preference_in_the_save_payload(bool initial)
|
||||
{
|
||||
await using var api = new ProfileTransport("Performer") { HideIpAddressInCalls = initial };
|
||||
var vm = new UserProfilePageViewModel(api);
|
||||
await vm.RefreshAsync();
|
||||
Assert.Equal(initial, vm.HideIpAddressInCalls);
|
||||
vm.HideIpAddressInCalls = !initial;
|
||||
Assert.Equal(!initial, vm.BuildProfileUpdateRequest().HideIpAddressInCalls);
|
||||
Assert.True(vm.SaveProfileCommand.CanExecute(null));
|
||||
Assert.True(vm.IsProfileLoaded);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task My_profile_privacy_checkbox_is_two_way_and_disabled_before_loading()
|
||||
{
|
||||
await using var api = new ProfileTransport("Performer") { HideIpAddressInCalls = true };
|
||||
var vm = new UserProfilePageViewModel(api);
|
||||
var page = new UserProfilePage { DataContext = vm };
|
||||
var window = new Window { Content = page };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
var checkbox = Assert.Single(page.GetVisualDescendants().OfType<CheckBox>(),
|
||||
control => Equals(control.Content, "Masquer mon adresse IP aux autres participants"));
|
||||
Assert.False(checkbox.IsEffectivelyEnabled);
|
||||
await vm.RefreshAsync();
|
||||
Assert.True(checkbox.IsEffectivelyEnabled);
|
||||
Assert.True(checkbox.IsChecked);
|
||||
checkbox.IsChecked = false;
|
||||
Assert.False(vm.HideIpAddressInCalls);
|
||||
Assert.Equal(false, vm.BuildProfileUpdateRequest().HideIpAddressInCalls);
|
||||
vm.IsBusy = true;
|
||||
Assert.False(checkbox.IsEffectivelyEnabled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Profile_read_failure_prevents_saving_a_default_call_privacy_preference()
|
||||
{
|
||||
await using var api = new ProfileTransport("Performer") { FailProfile = true };
|
||||
var vm = new UserProfilePageViewModel(api);
|
||||
await vm.RefreshAsync();
|
||||
Assert.False(vm.IsProfileLoaded);
|
||||
Assert.False(vm.SaveProfileCommand.CanExecute(null));
|
||||
Assert.Contains("Impossible de charger le profil", vm.StatusMessage);
|
||||
}
|
||||
|
||||
internal sealed class ProfileTransport : YavscApiClient
|
||||
{
|
||||
private readonly string _tokenPath;
|
||||
|
|
@ -188,6 +242,7 @@ public class AdministrationAccessTests
|
|||
public Task<Me>? PendingProfile { get; set; }
|
||||
public bool FailProfile { get; set; }
|
||||
public bool FailUsage { get; set; }
|
||||
public bool HideIpAddressInCalls { get; set; }
|
||||
public ResourceUsageApiClient UsageClient => new(this, Settings.ApiUrl);
|
||||
|
||||
public ProfileTransport(string role)
|
||||
|
|
@ -216,7 +271,7 @@ public class AdministrationAccessTests
|
|||
if (FailProfile)
|
||||
throw new HttpRequestException("Profile unavailable");
|
||||
result = PendingProfile is not null ? await PendingProfile
|
||||
: new Me { UserId = "alice-id", UserName = "alice", Roles = [_role] };
|
||||
: new Me { UserId = "alice-id", UserName = "alice", Roles = [_role], HideIpAddressInCalls = HideIpAddressInCalls };
|
||||
}
|
||||
else if (path.Contains("/resource-usage/"))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ public partial class UserProfilePageViewModel : ViewModelBase
|
|||
[ObservableProperty]
|
||||
public partial bool AllowMonthlyEmail { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool HideIpAddressInCalls { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool HasPassword { get; set; }
|
||||
|
||||
|
|
@ -195,6 +198,7 @@ public partial class UserProfilePageViewModel : ViewModelBase
|
|||
BankInfoSummary = string.IsNullOrWhiteSpace(me.BankInfoSummary) ? "Aucune information bancaire" : me.BankInfoSummary;
|
||||
EmailConfirmed = me.EmailConfirmed;
|
||||
AllowMonthlyEmail = me.AllowMonthlyEmail;
|
||||
HideIpAddressInCalls = me.HideIpAddressInCalls;
|
||||
HasPassword = me.HasPassword;
|
||||
TwoFactorEnabled = me.TwoFactorEnabled;
|
||||
ExternalLoginCount = me.ExternalLoginCount;
|
||||
|
|
@ -280,7 +284,8 @@ public partial class UserProfilePageViewModel : ViewModelBase
|
|||
UserName = NormalizeField(UserName, "—"),
|
||||
FullName = NormalizeField(FullName, "—"),
|
||||
Address = NormalizeField(Address, "—"),
|
||||
GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré")
|
||||
GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré"),
|
||||
HideIpAddressInCalls = HideIpAddressInCalls
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,16 @@
|
|||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="4" IsEnabled="{Binding IsProfileLoaded}">
|
||||
<TextBlock Text="Confidentialité des futurs appels audio-vidéo"
|
||||
FontWeight="SemiBold"/>
|
||||
<CheckBox Content="Masquer mon adresse IP aux autres participants"
|
||||
IsChecked="{Binding HideIpAddressInCalls, Mode=TwoWay}"
|
||||
IsEnabled="{Binding !IsBusy}"/>
|
||||
<TextBlock Text="Préférence enregistrée avec le profil et partagée entre vos appareils. Les appels ne sont pas encore disponibles. Lors de leur mise en service, cette option imposera un relais TURN, avec une latence potentiellement accrue ; les serveurs contactés pourront toujours connaître votre adresse IP."
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button x:Name="ChooseAvatarButton"
|
||||
Content="Choisir un avatar"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace Yavsc.Abstract.Identity {
|
|||
{
|
||||
public bool EmailConfirmed { get; set; }
|
||||
public bool AllowMonthlyEmail { get; set; }
|
||||
public bool HideIpAddressInCalls { get; set; }
|
||||
public bool HasPassword { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
public int ExternalLoginCount { get; set; }
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ public class ProfileUpdateRequest
|
|||
public string? Address { get; set; }
|
||||
public string? GoogleCalendarId { get; set; }
|
||||
public string? BankInfoSummary { get; set; }
|
||||
public bool? HideIpAddressInCalls { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ namespace Yavsc.WebApi.Controllers
|
|||
Avatar = userData.Avatar,
|
||||
FullName = userData.FullName,
|
||||
Address = userData.PostalAddress?.Address ?? string.Empty,
|
||||
DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar
|
||||
DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar,
|
||||
HideIpAddressInCalls = userData.HideIpAddressInCalls
|
||||
};
|
||||
user.BankInfoSummary = userData.BankInfo is { Count: > 0 }
|
||||
? string.Join(" | ", userData.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
|
||||
|
|
@ -151,6 +152,11 @@ namespace Yavsc.WebApi.Controllers
|
|||
return BadRequest(new { error = "Payload is required." });
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
if (request.BankInfoSummary is not null)
|
||||
{
|
||||
return BadRequest(new { error = "Bank information must be managed through the bank-info endpoint." });
|
||||
|
|
@ -212,8 +218,13 @@ namespace Yavsc.WebApi.Controllers
|
|||
user.DedicatedGoogleCalendar = request.GoogleCalendarId.Trim();
|
||||
}
|
||||
|
||||
if (request.HideIpAddressInCalls is bool hideIpAddressInCalls)
|
||||
{
|
||||
user.HideIpAddressInCalls = hideIpAddressInCalls;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(uid);
|
||||
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar });
|
||||
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar, hideIpAddressInCalls = user.HideIpAddressInCalls });
|
||||
}
|
||||
|
||||
[HttpPut("preferences/monthly-email")]
|
||||
|
|
|
|||
4969
src/Yavsc.Org/Migrations/20261025155604_HideIpAddressInCalls.Designer.cs
generated
Normal file
4969
src/Yavsc.Org/Migrations/20261025155604_HideIpAddressInCalls.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Yavsc.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class HideIpAddressInCalls : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HideIpAddressInCalls",
|
||||
table: "AspNetUsers",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HideIpAddressInCalls",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1170,6 +1170,9 @@ namespace Yavsc.Migrations
|
|||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<bool>("HideIpAddressInCalls")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ namespace Yavsc.Models
|
|||
|
||||
public bool AllowMonthlyEmail { get; set; } = false;
|
||||
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool HideIpAddressInCalls { get; set; }
|
||||
|
||||
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
[InverseProperty("Owner")]
|
||||
public virtual List<ChatRoom>? Rooms { get; set; }
|
||||
|
|
|
|||
149
src/test/Yavsc.Api.Tests/AccountCallPreferenceTests.cs
Normal file
149
src/test/Yavsc.Api.Tests/AccountCallPreferenceTests.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class AccountCallPreferenceTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
private const string Endpoint = "/api/v1/account/me";
|
||||
|
||||
private HttpClient Client(string? subject, string scope = "api")
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
}) { BaseAddress = new Uri(fixture.BaseAddress) };
|
||||
if (subject is not null)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task<string> CreateUserAsync()
|
||||
{
|
||||
var id = "call-preference-" + Guid.NewGuid().ToString("N");
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Users.Add(new ApplicationUser { Id = id, UserName = id, Email = $"{id}@example.test" });
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preference_is_not_exposed_when_a_user_entity_is_serialized()
|
||||
{
|
||||
var user = new ApplicationUser { HideIpAddressInCalls = true };
|
||||
Assert.DoesNotContain(nameof(ApplicationUser.HideIpAddressInCalls), JsonSerializer.Serialize(user));
|
||||
Assert.DoesNotContain(nameof(ApplicationUser.HideIpAddressInCalls), Newtonsoft.Json.JsonConvert.SerializeObject(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preference_defaults_to_false_and_round_trips_across_clients_and_database_contexts()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var id = await CreateUserAsync();
|
||||
using var firstDevice = Client(id);
|
||||
var initial = await firstDevice.GetFromJsonAsync<Me>(Endpoint, ct);
|
||||
Assert.NotNull(initial);
|
||||
Assert.False(initial.HideIpAddressInCalls);
|
||||
|
||||
foreach (var value in new[] { true, false })
|
||||
{
|
||||
using var response = await firstDevice.PutAsJsonAsync(Endpoint,
|
||||
new ProfileUpdateRequest { HideIpAddressInCalls = value }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var saved = await response.Content.ReadFromJsonAsync<JsonElement>(ct);
|
||||
Assert.Equal(value, saved.GetProperty("hideIpAddressInCalls").GetBoolean());
|
||||
|
||||
using var secondDevice = Client(id);
|
||||
var profile = await secondDevice.GetFromJsonAsync<Me>(Endpoint, ct);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal(value, profile.HideIpAddressInCalls);
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
Assert.Equal(value, (await db.Users.AsNoTracking().SingleAsync(u => u.Id == id, ct)).HideIpAddressInCalls);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Omitted_or_null_preference_does_not_reset_an_existing_value(bool explicitNull)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var id = await CreateUserAsync();
|
||||
using var client = Client(id);
|
||||
using var enabled = await client.PutAsJsonAsync(Endpoint, new { hideIpAddressInCalls = true }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, enabled.StatusCode);
|
||||
|
||||
using var update = explicitNull
|
||||
? await client.PutAsJsonAsync(Endpoint, new { fullName = "Updated profile", hideIpAddressInCalls = (bool?)null }, ct)
|
||||
: await client.PutAsJsonAsync(Endpoint, new { fullName = "Updated profile" }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
||||
var profile = await client.GetFromJsonAsync<Me>(Endpoint, ct);
|
||||
Assert.NotNull(profile);
|
||||
Assert.True(profile.HideIpAddressInCalls);
|
||||
Assert.Equal("Updated profile", profile.FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_uses_the_authenticated_account_not_a_user_id_in_the_payload()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var owner = await CreateUserAsync();
|
||||
var other = await CreateUserAsync();
|
||||
using var client = Client(owner);
|
||||
using var response = await client.PutAsJsonAsync(Endpoint,
|
||||
new { userId = other, hideIpAddressInCalls = true }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var otherClient = Client(other);
|
||||
var otherProfile = await otherClient.GetFromJsonAsync<Me>(Endpoint, ct);
|
||||
Assert.NotNull(otherProfile);
|
||||
Assert.False(otherProfile.HideIpAddressInCalls);
|
||||
var ownProfile = await client.GetFromJsonAsync<Me>(Endpoint, ct);
|
||||
Assert.NotNull(ownProfile);
|
||||
Assert.True(ownProfile.HideIpAddressInCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "api", HttpStatusCode.Unauthorized)]
|
||||
[InlineData("unknown-account", "profile", HttpStatusCode.Forbidden)]
|
||||
[InlineData("unknown-account", "api", HttpStatusCode.NotFound)]
|
||||
public async Task Read_and_write_require_an_existing_authenticated_account_and_api_scope(
|
||||
string? subject, string scope, HttpStatusCode expected)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var client = Client(subject, scope);
|
||||
using var read = await client.GetAsync(Endpoint, ct);
|
||||
Assert.Equal(expected, read.StatusCode);
|
||||
using var write = await client.PutAsJsonAsync(Endpoint, new { hideIpAddressInCalls = true }, ct);
|
||||
Assert.Equal(expected, write.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_boolean_is_rejected_without_saving_other_profile_changes()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var id = await CreateUserAsync();
|
||||
using var client = Client(id);
|
||||
using var response = await client.PutAsJsonAsync(Endpoint,
|
||||
new { fullName = "Must not be saved", hideIpAddressInCalls = "invalid" }, ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var profile = await client.GetFromJsonAsync<Me>(Endpoint, ct);
|
||||
Assert.NotNull(profile);
|
||||
Assert.False(profile.HideIpAddressInCalls);
|
||||
Assert.NotEqual("Must not be saved", profile.FullName);
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
builder.Services.AddInvoicing(builder.Configuration);
|
||||
builder.Services.AddSingleton<RecordingMessageDelivery>();
|
||||
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddTransient<ICalendarManager, CalendarManager>();
|
||||
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
||||
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue