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:
Paul Schneider 2026-10-25 16:49:51 +00:00
commit d8200cb98d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
14 changed files with 5263 additions and 12 deletions

View 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);
}
}