Skip to content

Commit 4b665a5

Browse files
committed
Session Store Tests
1 parent b467cba commit 4b665a5

16 files changed

Lines changed: 3283 additions & 16 deletions

‎src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,27 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain)
5353

5454
public static void UpdateProjection(this UAuthSessionChain source, SessionChainProjection target)
5555
{
56-
DeviceId.TryCreate(source.Device.DeviceId?.Value, out var deviceId);
56+
if (source.Device.DeviceId is not DeviceId deviceId)
57+
throw new ArgumentException("Device id required.");
5758

5859
target.ActiveSessionId = source.ActiveSessionId;
5960
target.RevokedAt = source.RevokedAt;
61+
6062
target.DeviceId = deviceId;
6163
target.Device = source.Device;
64+
6265
target.ClaimsSnapshot = source.ClaimsSnapshot;
63-
target.SecurityVersionAtCreation = source.SecurityVersionAtCreation;
66+
67+
target.SecurityVersionAtCreation =
68+
source.SecurityVersionAtCreation;
69+
6470
target.LastSeenAt = source.LastSeenAt;
6571
target.AbsoluteExpiresAt = source.AbsoluteExpiresAt;
66-
// Version store-owned
72+
73+
target.RotationCount = source.RotationCount;
74+
target.TouchCount = source.TouchCount;
75+
76+
// Version intentionally omitted:
77+
// optimistic concurrency/version is store-owned.
6778
}
6879
}

‎src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs‎

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,21 +132,31 @@ public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, C
132132
projection.Version++;
133133
}
134134

135-
public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default)
135+
public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default)
136136
{
137137
ct.ThrowIfCancellationRequested();
138138

139+
if (session.Tenant != _tenant)
140+
throw new InvalidOperationException("Tenant mismatch.");
141+
139142
if (!_inExecution)
140143
throw new InvalidOperationException("Must be called inside ExecuteAsync");
141144

142-
var projection = session.ToProjection();
143-
144145
if (session.Version != 0)
145146
throw new InvalidOperationException("New session must have version 0.");
146147

147-
DbSetSession.Add(projection);
148+
var exists = await _db.Set<SessionProjection>()
149+
.AnyAsync(
150+
x => x.Tenant == _tenant &&
151+
x.SessionId == session.SessionId,
152+
ct);
148153

149-
return Task.CompletedTask;
154+
if (exists)
155+
throw new UAuthConcurrencyException(
156+
"session_already_exists");
157+
158+
var projection = session.ToProjection();
159+
DbSetSession.Add(projection);
150160
}
151161

152162
public async Task<bool> RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default)
@@ -165,6 +175,19 @@ public async Task<bool> RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs
165175
domain.UpdateProjection(projection);
166176
projection.Version++;
167177

178+
var chain = await DbSetChain
179+
.SingleOrDefaultAsync(
180+
x => x.Tenant == _tenant &&
181+
x.ChainId == projection.ChainId,
182+
ct);
183+
184+
if (chain?.ActiveSessionId == sessionId)
185+
{
186+
chain.ActiveSessionId = null;
187+
chain.LastSeenAt = at;
188+
chain.Version++;
189+
}
190+
168191
return true;
169192
}
170193

@@ -314,22 +337,38 @@ public async Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion,
314337
projection.Version++;
315338
}
316339

317-
public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default)
340+
public async Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default)
318341
{
319342
ct.ThrowIfCancellationRequested();
320343

344+
if (chain.Tenant != _tenant)
345+
throw new InvalidOperationException("Tenant mismatch.");
346+
321347
if (!_inExecution)
322348
throw new InvalidOperationException("Must be called inside ExecuteAsync");
323349

324350
if (chain.Version != 0)
325351
throw new InvalidOperationException("New chain must have version 0.");
326352

327-
var projection = chain.ToProjection();
353+
var exists =
354+
DbSetChain.Local.Any(x =>
355+
x.Tenant == _tenant &&
356+
x.ChainId == chain.ChainId);
328357

329-
DbSetChain.Add(projection);
330-
_db.Entry(projection).State = EntityState.Added;
358+
if (!exists)
359+
{
360+
exists = await DbSetChain
361+
.AsNoTracking()
362+
.AnyAsync(
363+
x => x.Tenant == _tenant &&
364+
x.ChainId == chain.ChainId,
365+
ct);
366+
}
331367

332-
return Task.CompletedTask;
368+
if (exists)
369+
throw new UAuthConcurrencyException("chain_already_exists");
370+
371+
DbSetChain.Add(chain.ToProjection());
333372
}
334373

335374
public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default)
@@ -475,6 +514,9 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can
475514
{
476515
ct.ThrowIfCancellationRequested();
477516

517+
if (root.Tenant != _tenant)
518+
throw new InvalidOperationException("Tenant mismatch.");
519+
478520
if (!_inExecution)
479521
throw new InvalidOperationException("Must be called inside ExecuteAsync");
480522

@@ -494,21 +536,40 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can
494536
projection.Version++;
495537
}
496538

497-
public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default)
539+
public async Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default)
498540
{
499541
ct.ThrowIfCancellationRequested();
500542

543+
if (root.Tenant != _tenant)
544+
throw new InvalidOperationException("Tenant mismatch.");
545+
501546
if (!_inExecution)
502547
throw new InvalidOperationException("Must be called inside ExecuteAsync");
503548

504549
if (root.Version != 0)
505550
throw new InvalidOperationException("New root must have version 0.");
506551

552+
var exists = DbSetRoot.Local.Any(x =>
553+
x.Tenant == _tenant &&
554+
x.UserKey == root.UserKey);
555+
556+
if (!exists)
557+
{
558+
exists = await DbSetRoot
559+
.AsNoTracking()
560+
.AnyAsync(
561+
x =>
562+
x.Tenant == _tenant &&
563+
x.UserKey == root.UserKey,
564+
ct);
565+
}
566+
567+
if (exists)
568+
throw new UAuthConcurrencyException("root_already_exists");
569+
507570
var projection = root.ToProjection();
508571

509572
DbSetRoot.Add(projection);
510-
511-
return Task.CompletedTask;
512573
}
513574

514575
public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default)
@@ -604,6 +665,21 @@ public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken
604665
if (projection is null)
605666
return;
606667

668+
var chain = await DbSetChain
669+
.SingleOrDefaultAsync(
670+
x => x.Tenant == _tenant &&
671+
x.ChainId == projection.ChainId,
672+
ct);
673+
674+
if (chain?.ActiveSessionId == sessionId)
675+
{
676+
chain.ActiveSessionId = null;
677+
678+
// Remove has no DateTimeOffset parameter.
679+
// Don't introduce DateTimeOffset.UtcNow here merely to update LastSeenAt.
680+
chain.Version++;
681+
}
682+
607683
DbSetSession.Remove(projection);
608684
}
609685

‎src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = defa
7373

7474
lock (_lock)
7575
{
76+
if (session.Tenant != _tenant)
77+
throw new InvalidOperationException("Tenant mismatch.");
78+
7679
if (_sessions.ContainsKey(session.SessionId))
7780
throw new UAuthConcurrencyException("session_already_exists");
7881

@@ -203,6 +206,9 @@ public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = def
203206
{
204207
ct.ThrowIfCancellationRequested();
205208

209+
if (chain.Tenant != _tenant)
210+
throw new InvalidOperationException("Tenant mismatch.");
211+
206212
lock (_lock)
207213
{
208214
if (_chains.ContainsKey(chain.ChainId))
@@ -285,6 +291,9 @@ public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Cancellat
285291
{
286292
ct.ThrowIfCancellationRequested();
287293

294+
if (root.Tenant != _tenant)
295+
throw new InvalidOperationException("Tenant mismatch.");
296+
288297
if (!_roots.TryGetValue((_tenant, root.UserKey), out var current))
289298
throw new UAuthNotFoundException("root_not_found");
290299

@@ -299,6 +308,9 @@ public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = defaul
299308
{
300309
ct.ThrowIfCancellationRequested();
301310

311+
if (root.Tenant != _tenant)
312+
throw new InvalidOperationException("Tenant mismatch.");
313+
302314
lock (_lock)
303315
{
304316
if (_roots.ContainsKey((_tenant, root.UserKey)))
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using CodeBeam.UltimateAuth.Core.Abstractions;
2+
using CodeBeam.UltimateAuth.Core.MultiTenancy;
3+
using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore;
4+
using Microsoft.Data.Sqlite;
5+
using Microsoft.EntityFrameworkCore;
6+
7+
namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts;
8+
9+
public sealed class EfCoreSessionStoreChainContractTests
10+
: SessionStoreChainContractTests
11+
{
12+
protected override async Task<ISessionStoreTestDatabase>
13+
CreateDatabaseAsync()
14+
{
15+
var database = new Database();
16+
17+
await database.InitializeAsync();
18+
19+
return database;
20+
}
21+
22+
private sealed class Database
23+
: ISessionStoreTestDatabase
24+
{
25+
private readonly SqliteConnection _connection;
26+
private readonly UAuthSessionDbContext _db;
27+
28+
public Database()
29+
{
30+
_connection = new SqliteConnection(
31+
"Data Source=:memory:");
32+
33+
var options =
34+
new DbContextOptionsBuilder<UAuthSessionDbContext>()
35+
.UseSqlite(_connection)
36+
.Options;
37+
38+
_db = new UAuthSessionDbContext(options);
39+
}
40+
41+
public async Task InitializeAsync()
42+
{
43+
await _connection.OpenAsync();
44+
45+
await _db.Database.EnsureCreatedAsync();
46+
}
47+
48+
public ISessionStore CreateStore(
49+
TenantKey tenant)
50+
{
51+
return new EfCoreSessionStore<UAuthSessionDbContext>(
52+
_db,
53+
new TenantExecutionContext(tenant));
54+
}
55+
56+
public async ValueTask DisposeAsync()
57+
{
58+
await _db.DisposeAsync();
59+
await _connection.DisposeAsync();
60+
}
61+
}
62+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using CodeBeam.UltimateAuth.Core.Abstractions;
2+
using CodeBeam.UltimateAuth.Core.MultiTenancy;
3+
using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore;
4+
using CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts;
5+
using Microsoft.Data.Sqlite;
6+
using Microsoft.EntityFrameworkCore;
7+
8+
public sealed class EfCoreSessionStoreLifecycleContractTests
9+
: SessionStoreLifecycleContractTests
10+
{
11+
protected override async Task<ISessionStoreTestDatabase>
12+
CreateDatabaseAsync()
13+
{
14+
var database = new Database();
15+
await database.InitializeAsync();
16+
return database;
17+
}
18+
19+
private sealed class Database
20+
: ISessionStoreTestDatabase
21+
{
22+
private readonly SqliteConnection _connection;
23+
private readonly UAuthSessionDbContext _db;
24+
25+
public Database()
26+
{
27+
_connection =
28+
new SqliteConnection("Data Source=:memory:");
29+
30+
var options =
31+
new DbContextOptionsBuilder<UAuthSessionDbContext>()
32+
.UseSqlite(_connection)
33+
.Options;
34+
35+
_db = new UAuthSessionDbContext(options);
36+
}
37+
38+
public async Task InitializeAsync()
39+
{
40+
await _connection.OpenAsync();
41+
await _db.Database.EnsureCreatedAsync();
42+
}
43+
44+
public ISessionStore CreateStore(TenantKey tenant)
45+
=> new EfCoreSessionStore<UAuthSessionDbContext>(
46+
_db,
47+
new TenantExecutionContext(tenant));
48+
49+
public async ValueTask DisposeAsync()
50+
{
51+
await _db.DisposeAsync();
52+
await _connection.DisposeAsync();
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)