Skip to content

Commit ab32f4e

Browse files
committed
Merge remote-tracking branch 'origin/develop' into feat/cancellation-token-propagation
# Conflicts: # Common/Hubs/PublicShareHub.cs # Common/Services/ControlSender.cs # Common/Services/IControlSender.cs
2 parents 473c247 + eed7798 commit ab32f4e

39 files changed

Lines changed: 4109 additions & 77 deletions

‎API.IntegrationTests/Tests/AutomationTokenTests.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,10 @@ public async Task Create_ReturnsWorkingSecret(CancellationToken cancellationToke
243243
{
244244
name = "created-by-test",
245245
types = new[] { "Turnstile" }
246-
}));
246+
}), cancellationToken);
247247

248248
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
249-
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
249+
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
250250
var id = json.RootElement.GetProperty("id").GetGuid();
251251
var secret = json.RootElement.GetProperty("secret").GetString()!;
252252

@@ -264,9 +264,9 @@ public async Task Rotate_OldSecretStopsResolving(CancellationToken cancellationT
264264
var (tokenId, oldSecret) = await CreateAutomationTokenInDb();
265265
using var client = await CreateAdminClient("autorotate");
266266

267-
var response = await client.PostAsync($"/1/admin/automationTokens/{tokenId}/rotate", null);
267+
var response = await client.PostAsync($"/1/admin/automationTokens/{tokenId}/rotate", null, cancellationToken);
268268
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
269-
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
269+
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
270270
var newSecret = json.RootElement.GetProperty("secret").GetString()!;
271271

272272
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using OpenShock.API.IntegrationTests.Helpers;
6+
using OpenShock.Common.Constants;
7+
using OpenShock.Common.OpenShockDb;
8+
using OpenShock.Common.Services.AutomationTokens;
9+
using OpenShock.Common.Utils;
10+
11+
namespace OpenShock.API.IntegrationTests.Tests;
12+
13+
public sealed class ControlStatChangesTests
14+
{
15+
private const string Password = "SecurePassword123#";
16+
private const string Endpoint = "/1/integrations/control-stats/changes";
17+
18+
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
19+
public required WebApplicationFactory WebApplicationFactory { get; init; }
20+
21+
[Test]
22+
public async Task WithoutControlStatsReadToken_Returns403()
23+
{
24+
var body = TestHelper.JsonContent(new { userIds = new[] { Guid.NewGuid() } });
25+
26+
using var anonymous = WebApplicationFactory.CreateClient();
27+
await Assert.That((await anonymous.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
28+
29+
using var wrongType = await CreateTokenClient([AutomationTokenType.Turnstile, AutomationTokenType.RateLimit]);
30+
await Assert.That((await wrongType.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
31+
}
32+
33+
[Test]
34+
public async Task InitialSync_ReportsEveryRequestedUser_WithReceivedAndGivenTotals()
35+
{
36+
var owner = await CreateUser("chgowner");
37+
var controller = await CreateUser("chgctrl");
38+
var idle = await CreateUser("chgidle");
39+
var notRequested = await CreateUser("chgother");
40+
var shockerId = await CreateShocker(owner, "Changes");
41+
var at = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
42+
43+
await Upsert(
44+
Delta(owner, shockerId, controller, at, count: 2, intensity: 40),
45+
Delta(owner, shockerId, notRequested, at, count: 7));
46+
47+
using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
48+
using var doc = await Post(client, new { userIds = new[] { owner, controller, idle } });
49+
50+
var users = doc.RootElement.GetProperty("users").EnumerateArray()
51+
.ToDictionary(x => x.GetProperty("userId").GetGuid());
52+
await Assert.That(users.Keys).IsEquivalentTo(new[] { owner, controller, idle });
53+
54+
// Lifetime totals, so the not requested controller's activity counts towards the owner as well
55+
var received = SingleShock(users[owner].GetProperty("received"));
56+
await Assert.That(received.GetProperty("count").GetInt64()).IsEqualTo(9);
57+
await Assert.That(users[owner].GetProperty("given").GetArrayLength()).IsEqualTo(0);
58+
59+
var given = SingleShock(users[controller].GetProperty("given"));
60+
await Assert.That(given.GetProperty("count").GetInt64()).IsEqualTo(2);
61+
await Assert.That(given.GetProperty("averageIntensity").GetDouble()).IsEqualTo(40).Within(0.001);
62+
63+
await Assert.That(users[idle].GetProperty("received").GetArrayLength()).IsEqualTo(0);
64+
await Assert.That(users[idle].GetProperty("given").GetArrayLength()).IsEqualTo(0);
65+
}
66+
67+
[Test]
68+
public async Task Since_OnlyReportsUsersWithRecentActivity()
69+
{
70+
var owner = await CreateUser("chgsince");
71+
var quiet = await CreateUser("chgquiet");
72+
var shockerId = await CreateShocker(owner, "Since");
73+
var quietShockerId = await CreateShocker(quiet, "Quiet");
74+
75+
// Old activity only, outside of any lookback
76+
var old = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
77+
await Upsert(Delta(owner, shockerId, owner, old, count: 3), Delta(quiet, quietShockerId, quiet, old, count: 1));
78+
await Backdate(owner, quiet);
79+
80+
using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
81+
var userIds = new[] { owner, quiet };
82+
83+
DateTime cursor;
84+
using (var initial = await Post(client, new { userIds }))
85+
cursor = initial.RootElement.GetProperty("cursor").GetDateTime();
86+
87+
using (var unchanged = await Post(client, new { since = cursor, userIds }))
88+
await Assert.That(unchanged.RootElement.GetProperty("users").GetArrayLength()).IsEqualTo(0);
89+
90+
await Upsert(Delta(owner, shockerId, owner, DateTime.UtcNow, count: 1));
91+
92+
using var changed = await Post(client, new { since = cursor, userIds });
93+
var user = changed.RootElement.GetProperty("users").EnumerateArray().Single();
94+
await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner);
95+
await Assert.That(SingleShock(user.GetProperty("received")).GetProperty("count").GetInt64()).IsEqualTo(4);
96+
await Assert.That(SingleShock(user.GetProperty("given")).GetProperty("count").GetInt64()).IsEqualTo(4);
97+
}
98+
99+
[Test]
100+
public async Task Since_AtMinValue_ReportsUsersWithAnyActivity()
101+
{
102+
var owner = await CreateUser("chgmin");
103+
var idle = await CreateUser("chgminidle");
104+
var shockerId = await CreateShocker(owner, "MinValue");
105+
await Upsert(Delta(owner, shockerId, owner, new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), count: 2));
106+
107+
using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
108+
109+
// With and without offset, the latter is read as UTC
110+
foreach (var since in new[] { "0001-01-01T00:00:00Z", "0001-01-01T00:00:00" })
111+
{
112+
using var doc = await Post(client, new { since, userIds = new[] { owner, idle } });
113+
114+
var user = doc.RootElement.GetProperty("users").EnumerateArray().Single();
115+
await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner);
116+
await Assert.That(SingleShock(user.GetProperty("received")).GetProperty("count").GetInt64()).IsEqualTo(2);
117+
}
118+
}
119+
120+
[Test]
121+
public async Task Since_ReportsLateWritesIntoOldBuckets()
122+
{
123+
var owner = await CreateUser("chglate");
124+
var shockerId = await CreateShocker(owner, "Late");
125+
126+
using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
127+
var userIds = new[] { owner };
128+
129+
DateTime cursor;
130+
using (var initial = await Post(client, new { userIds }))
131+
cursor = initial.RootElement.GetProperty("cursor").GetDateTime();
132+
133+
// Live control from hours ago that only reached the database now, e.g. after an outage
134+
await Upsert(Delta(owner, shockerId, owner, DateTime.UtcNow.AddHours(-3), count: 1));
135+
136+
using var changed = await Post(client, new { since = cursor, userIds });
137+
var user = changed.RootElement.GetProperty("users").EnumerateArray().Single();
138+
await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner);
139+
}
140+
141+
[Test]
142+
public async Task Since_ReportsControllersWhoseStatsWereDeleted()
143+
{
144+
var owner = await CreateUser("chgdelown");
145+
var controller = await CreateUser("chgdelctl");
146+
var shockerId = await CreateShocker(owner, "Deleted");
147+
await Upsert(Delta(owner, shockerId, controller, new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), count: 4));
148+
await Backdate(owner);
149+
150+
using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
151+
var userIds = new[] { controller };
152+
153+
DateTime cursor;
154+
using (var initial = await Post(client, new { userIds }))
155+
cursor = initial.RootElement.GetProperty("cursor").GetDateTime();
156+
157+
// As when the owner's account is deleted, which cascades onto their stats
158+
await using (var scope = WebApplicationFactory.Services.CreateAsyncScope())
159+
{
160+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
161+
await db.ControlStats.Where(x => x.OwnerUserId == owner).ExecuteDeleteAsync();
162+
}
163+
164+
using var changed = await Post(client, new { since = cursor, userIds });
165+
var user = changed.RootElement.GetProperty("users").EnumerateArray().Single();
166+
await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(controller);
167+
await Assert.That(user.GetProperty("given").GetArrayLength()).IsEqualTo(0);
168+
}
169+
170+
/// <summary>Moves the users' rows out of the change lookback, as if written long ago.</summary>
171+
private async Task Backdate(params Guid[] ownerIds)
172+
{
173+
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
174+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
175+
var longAgo = DateTime.UtcNow.AddDays(-1);
176+
await db.ControlStats.Where(x => ownerIds.Contains(x.OwnerUserId))
177+
.ExecuteUpdateAsync(x => x.SetProperty(y => y.UpdatedAt, longAgo));
178+
}
179+
180+
private static ControlStatDelta Delta(Guid ownerId, Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) =>
181+
new(ownerId, shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000,
182+
IntensityMs: count * 1000 * intensity, intensity);
183+
184+
private static JsonElement SingleShock(JsonElement totals) => totals.EnumerateArray()
185+
.Single(x => x.GetProperty("type").GetString() == "Shock" && !x.GetProperty("live").GetBoolean());
186+
187+
private async Task Upsert(params ControlStatDelta[] deltas)
188+
{
189+
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
190+
await scope.ServiceProvider.GetRequiredService<OpenShockContext>().UpsertControlStatsAsync(deltas);
191+
}
192+
193+
private static async Task<JsonDocument> Post(HttpClient client, object body)
194+
{
195+
var response = await client.PostAsync(Endpoint, TestHelper.JsonContent(body));
196+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
197+
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
198+
}
199+
200+
private Task<Guid> CreateUser(string prefix) => TestHelper.CreateUserInDb(WebApplicationFactory,
201+
TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password);
202+
203+
private async Task<HttpClient> CreateTokenClient(List<AutomationTokenType> types)
204+
{
205+
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
206+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
207+
208+
var secret = IAutomationTokenService.GenerateSecret();
209+
db.AutomationTokens.Add(new AutomationToken
210+
{
211+
Id = Guid.CreateVersion7(),
212+
Name = "control-stats-test",
213+
TokenHash = HashingUtils.HashToken(secret),
214+
Types = types
215+
});
216+
await db.SaveChangesAsync();
217+
218+
var client = WebApplicationFactory.CreateClient();
219+
client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret);
220+
return client;
221+
}
222+
223+
private async Task<Guid> CreateShocker(Guid ownerId, string name)
224+
{
225+
var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, ownerId);
226+
227+
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
228+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
229+
230+
var shockerId = Guid.CreateVersion7();
231+
db.Shockers.Add(new Shocker
232+
{
233+
Id = shockerId,
234+
Name = name,
235+
Model = ShockerModelType.CaiXianlin,
236+
RfId = 1234,
237+
DeviceId = deviceId
238+
});
239+
await db.SaveChangesAsync();
240+
return shockerId;
241+
}
242+
}

0 commit comments

Comments
 (0)