Skip to content

Commit 56c6cac

Browse files
committed
Add AccessRule command and validator tests
1 parent 1326bfe commit 56c6cac

6 files changed

Lines changed: 911 additions & 9 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
using Bit.Core.Entities;
2+
using Bit.Core.Exceptions;
3+
using Bit.Core.Repositories;
4+
using Bit.Pam.Entities;
5+
using Bit.Pam.Repositories;
6+
using Bit.Services.Pam.OrganizationFeatures.Commands;
7+
using Bit.Services.Pam.Services;
8+
using Bit.Test.Common.AutoFixture;
9+
using Bit.Test.Common.AutoFixture.Attributes;
10+
using Microsoft.Extensions.Time.Testing;
11+
using NSubstitute;
12+
using Xunit;
13+
14+
namespace Bit.Services.Pam.Test.Commands;
15+
16+
[SutProviderCustomize]
17+
public class CreateAccessRuleCommandTests
18+
{
19+
private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc);
20+
21+
[Theory, BitAutoData]
22+
public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(AccessRule rule)
23+
{
24+
var sutProvider = SetupSutProvider();
25+
rule.Name = "VPN + business hours";
26+
rule.Conditions = """{"kind":"human_approval"}""";
27+
rule.DefaultLeaseDurationSeconds = 3600;
28+
rule.MaxLeaseDurationSeconds = 28800;
29+
sutProvider.GetDependency<IAccessRuleValidator>()
30+
.Validate(rule.Conditions)
31+
.Returns(AccessRuleValidationResult.Valid);
32+
sutProvider.GetDependency<IAccessRuleRepository>()
33+
.GetManyByOrganizationIdAsync(rule.OrganizationId)
34+
.Returns(new List<AccessRule>());
35+
sutProvider.GetDependency<IAccessRuleRepository>()
36+
.CreateAsync(rule)
37+
.Returns(rule);
38+
39+
var result = await sutProvider.Sut.CreateAsync(rule, []);
40+
41+
Assert.Equal(_now, result.CreationDate);
42+
Assert.Equal(_now, result.RevisionDate);
43+
Assert.Equal(3600, result.DefaultLeaseDurationSeconds);
44+
Assert.Equal(28800, result.MaxLeaseDurationSeconds);
45+
await sutProvider.GetDependency<IAccessRuleRepository>().Received(1)
46+
.CreateAsync(Arg.Is<AccessRule>(r =>
47+
r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800));
48+
}
49+
50+
[Theory, BitAutoData]
51+
public async Task CreateAsync_WithCollections_AssociatesAndReturnsThem(AccessRule rule, Collection collectionA,
52+
Collection collectionB)
53+
{
54+
var sutProvider = SetupSutProvider();
55+
rule.Name = "VPN + business hours";
56+
rule.Conditions = """{"kind":"human_approval"}""";
57+
collectionA.OrganizationId = rule.OrganizationId;
58+
collectionA.AccessRuleId = null;
59+
collectionB.OrganizationId = rule.OrganizationId;
60+
collectionB.AccessRuleId = null;
61+
var collectionIds = new[] { collectionA.Id, collectionB.Id };
62+
sutProvider.GetDependency<IAccessRuleValidator>()
63+
.Validate(rule.Conditions)
64+
.Returns(AccessRuleValidationResult.Valid);
65+
sutProvider.GetDependency<IAccessRuleRepository>()
66+
.GetManyByOrganizationIdAsync(rule.OrganizationId)
67+
.Returns(new List<AccessRule>());
68+
sutProvider.GetDependency<IAccessRuleRepository>()
69+
.CreateAsync(rule)
70+
.Returns(rule);
71+
sutProvider.GetDependency<ICollectionRepository>()
72+
.GetManyByManyIdsAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.OrderBy(x => x).SequenceEqual(collectionIds.OrderBy(x => x))))
73+
.Returns(new List<Collection> { collectionA, collectionB });
74+
75+
await sutProvider.Sut.CreateAsync(rule, collectionIds);
76+
77+
await sutProvider.GetDependency<IAccessRuleRepository>().Received(1)
78+
.SetCollectionAssociationsAsync(rule.OrganizationId, rule.Id,
79+
Arg.Is<IEnumerable<Guid>>(ids => ids.OrderBy(x => x).SequenceEqual(collectionIds.OrderBy(x => x))),
80+
Arg.Is<IEnumerable<Guid>>(ids => !ids.Any()));
81+
}
82+
83+
[Theory, BitAutoData]
84+
public async Task CreateAsync_CollectionInDifferentOrg_ThrowsBadRequest(AccessRule rule, Collection collection)
85+
{
86+
var sutProvider = SetupSutProvider();
87+
rule.Name = "test";
88+
rule.Conditions = """{"kind":"human_approval"}""";
89+
collection.OrganizationId = Guid.NewGuid();
90+
sutProvider.GetDependency<IAccessRuleValidator>()
91+
.Validate(rule.Conditions)
92+
.Returns(AccessRuleValidationResult.Valid);
93+
sutProvider.GetDependency<IAccessRuleRepository>()
94+
.GetManyByOrganizationIdAsync(rule.OrganizationId)
95+
.Returns(new List<AccessRule>());
96+
sutProvider.GetDependency<ICollectionRepository>()
97+
.GetManyByManyIdsAsync(Arg.Any<IEnumerable<Guid>>())
98+
.Returns(new List<Collection> { collection });
99+
100+
var ex = await Assert.ThrowsAsync<BadRequestException>(
101+
() => sutProvider.Sut.CreateAsync(rule, new[] { collection.Id }));
102+
Assert.Contains("do not belong to this organization", ex.Message);
103+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
104+
}
105+
106+
[Theory, BitAutoData]
107+
public async Task CreateAsync_CollectionGovernedByAnotherRule_ThrowsBadRequest(
108+
AccessRule rule, AccessRule otherRule, Collection collection)
109+
{
110+
var sutProvider = SetupSutProvider();
111+
rule.Name = "test";
112+
rule.Conditions = """{"kind":"human_approval"}""";
113+
otherRule.OrganizationId = rule.OrganizationId;
114+
otherRule.Name = "other";
115+
collection.OrganizationId = rule.OrganizationId;
116+
collection.AccessRuleId = otherRule.Id; // governed by another rule
117+
sutProvider.GetDependency<IAccessRuleValidator>()
118+
.Validate(rule.Conditions)
119+
.Returns(AccessRuleValidationResult.Valid);
120+
sutProvider.GetDependency<IAccessRuleRepository>()
121+
.GetManyByOrganizationIdAsync(rule.OrganizationId)
122+
.Returns(new List<AccessRule> { otherRule });
123+
sutProvider.GetDependency<ICollectionRepository>()
124+
.GetManyByManyIdsAsync(Arg.Any<IEnumerable<Guid>>())
125+
.Returns(new List<Collection> { collection });
126+
127+
var ex = await Assert.ThrowsAsync<BadRequestException>(
128+
() => sutProvider.Sut.CreateAsync(rule, new[] { collection.Id }));
129+
Assert.Contains("already governed by another access rule", ex.Message);
130+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
131+
}
132+
133+
[Theory, BitAutoData]
134+
public async Task CreateAsync_CollectionNotFound_ThrowsBadRequest(AccessRule rule, Guid missingCollectionId)
135+
{
136+
var sutProvider = SetupSutProvider();
137+
rule.Name = "test";
138+
rule.Conditions = """{"kind":"human_approval"}""";
139+
sutProvider.GetDependency<IAccessRuleValidator>()
140+
.Validate(rule.Conditions)
141+
.Returns(AccessRuleValidationResult.Valid);
142+
sutProvider.GetDependency<IAccessRuleRepository>()
143+
.GetManyByOrganizationIdAsync(rule.OrganizationId)
144+
.Returns(new List<AccessRule>());
145+
sutProvider.GetDependency<ICollectionRepository>()
146+
.GetManyByManyIdsAsync(Arg.Any<IEnumerable<Guid>>())
147+
.Returns(new List<Collection>());
148+
149+
var ex = await Assert.ThrowsAsync<BadRequestException>(
150+
() => sutProvider.Sut.CreateAsync(rule, new[] { missingCollectionId }));
151+
Assert.Contains("could not be found", ex.Message);
152+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
153+
}
154+
155+
[Theory, BitAutoData]
156+
public async Task CreateAsync_EmptyName_ThrowsBadRequest(AccessRule rule)
157+
{
158+
var sutProvider = SetupSutProvider();
159+
rule.Name = " ";
160+
161+
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAsync(rule, []));
162+
Assert.Contains("Name is required", ex.Message);
163+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
164+
}
165+
166+
[Theory, BitAutoData]
167+
public async Task CreateAsync_InvalidRule_ThrowsBadRequest(AccessRule rule)
168+
{
169+
var sutProvider = SetupSutProvider();
170+
rule.Name = "test";
171+
rule.Conditions = """{"kind":"bogus"}""";
172+
sutProvider.GetDependency<IAccessRuleValidator>()
173+
.Validate(rule.Conditions)
174+
.Returns(AccessRuleValidationResult.Invalid("Unsupported rule kind"));
175+
176+
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAsync(rule, []));
177+
Assert.Equal("Unsupported rule kind", ex.Message);
178+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
179+
}
180+
181+
[Theory, BitAutoData]
182+
public async Task CreateAsync_DuplicateName_ThrowsBadRequest(AccessRule rule, AccessRule existing)
183+
{
184+
var sutProvider = SetupSutProvider();
185+
rule.Name = "duplicate";
186+
rule.Conditions = """{"kind":"human_approval"}""";
187+
existing.OrganizationId = rule.OrganizationId;
188+
existing.Name = "Duplicate"; // case-insensitive collision
189+
sutProvider.GetDependency<IAccessRuleValidator>()
190+
.Validate(rule.Conditions)
191+
.Returns(AccessRuleValidationResult.Valid);
192+
sutProvider.GetDependency<IAccessRuleRepository>()
193+
.GetManyByOrganizationIdAsync(rule.OrganizationId)
194+
.Returns(new List<AccessRule> { existing });
195+
196+
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAsync(rule, []));
197+
Assert.Contains("already exists", ex.Message);
198+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
199+
}
200+
201+
[Theory, BitAutoData]
202+
public async Task CreateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(AccessRule rule)
203+
{
204+
var sutProvider = SetupSutProvider();
205+
rule.Name = "extendable";
206+
rule.AllowsExtensions = true;
207+
rule.MaxExtensionDurationSeconds = null;
208+
209+
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAsync(rule, []));
210+
Assert.Contains("maximum extension length", ex.Message);
211+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
212+
}
213+
214+
[Theory]
215+
[BitAutoData(0)]
216+
[BitAutoData(-1)]
217+
public async Task CreateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensionDurationSeconds, AccessRule rule)
218+
{
219+
var sutProvider = SetupSutProvider();
220+
rule.Name = "extendable";
221+
rule.AllowsExtensions = true;
222+
rule.MaxExtensionDurationSeconds = maxExtensionDurationSeconds;
223+
224+
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAsync(rule, []));
225+
Assert.Contains("maximum extension length", ex.Message);
226+
await sutProvider.GetDependency<IAccessRuleRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default!);
227+
}
228+
229+
[Theory, BitAutoData]
230+
public async Task CreateAsync_AllowsExtensionsWithPositiveMax_Persists(AccessRule rule)
231+
{
232+
var sutProvider = SetupSutProvider();
233+
rule.Name = "extendable";
234+
rule.Conditions = """{"kind":"human_approval"}""";
235+
rule.AllowsExtensions = true;
236+
rule.MaxExtensionDurationSeconds = 3600;
237+
sutProvider.GetDependency<IAccessRuleValidator>()
238+
.Validate(rule.Conditions)
239+
.Returns(AccessRuleValidationResult.Valid);
240+
sutProvider.GetDependency<IAccessRuleRepository>()
241+
.GetManyByOrganizationIdAsync(rule.OrganizationId)
242+
.Returns(new List<AccessRule>());
243+
sutProvider.GetDependency<IAccessRuleRepository>()
244+
.CreateAsync(rule)
245+
.Returns(rule);
246+
247+
var result = await sutProvider.Sut.CreateAsync(rule, []);
248+
249+
Assert.True(result.AllowsExtensions);
250+
Assert.Equal(3600, result.MaxExtensionDurationSeconds);
251+
await sutProvider.GetDependency<IAccessRuleRepository>().Received(1)
252+
.CreateAsync(Arg.Is<AccessRule>(r => r.AllowsExtensions && r.MaxExtensionDurationSeconds == 3600));
253+
}
254+
255+
private static SutProvider<CreateAccessRuleCommand> SetupSutProvider()
256+
{
257+
var sutProvider = new SutProvider<CreateAccessRuleCommand>()
258+
.WithFakeTimeProvider()
259+
.Create();
260+
sutProvider.GetDependency<FakeTimeProvider>().SetUtcNow(_now);
261+
return sutProvider;
262+
}
263+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using Bit.Core.Exceptions;
2+
using Bit.Pam.Entities;
3+
using Bit.Pam.Enums;
4+
using Bit.Pam.Models;
5+
using Bit.Pam.Repositories;
6+
using Bit.Pam.Services;
7+
using Bit.Services.Pam.OrganizationFeatures.Commands;
8+
using Bit.Test.Common.AutoFixture;
9+
using Bit.Test.Common.AutoFixture.Attributes;
10+
using NSubstitute;
11+
using Xunit;
12+
13+
namespace Bit.Services.Pam.Test.Commands;
14+
15+
[SutProviderCustomize]
16+
public class DeleteAccessRuleCommandTests
17+
{
18+
[Theory, BitAutoData]
19+
public async Task DeleteAsync_HappyPath_HardDeletes(
20+
AccessRule existing, Guid deletedBy, SutProvider<DeleteAccessRuleCommand> sutProvider)
21+
{
22+
sutProvider.GetDependency<IAccessRuleRepository>()
23+
.GetByIdAsync(existing.Id)
24+
.Returns(existing);
25+
26+
await sutProvider.Sut.DeleteAsync(existing.OrganizationId, existing.Id, deletedBy);
27+
28+
await sutProvider.GetDependency<IAccessRuleRepository>().Received(1)
29+
.DeleteAsync(existing);
30+
}
31+
32+
[Theory, BitAutoData]
33+
public async Task DeleteAsync_HappyPath_EmitsAttemptThenOutcome(
34+
AccessRule existing, Guid deletedBy, SutProvider<DeleteAccessRuleCommand> sutProvider)
35+
{
36+
sutProvider.GetDependency<IAccessRuleRepository>()
37+
.GetByIdAsync(existing.Id)
38+
.Returns(existing);
39+
40+
await sutProvider.Sut.DeleteAsync(existing.OrganizationId, existing.Id, deletedBy);
41+
42+
var emitter = sutProvider.GetDependency<IAccessAuditEventEmitter>();
43+
await emitter.Received(1).EmitAsync(Arg.Is<AccessAuditEventData>(e =>
44+
e.Kind == AccessAuditEventKind.RuleDeleted && e.Phase == AccessAuditEventPhase.Attempt
45+
&& e.AccessRuleId == existing.Id && e.ActorId == deletedBy));
46+
await emitter.Received(1).EmitAsync(Arg.Is<AccessAuditEventData>(e =>
47+
e.Kind == AccessAuditEventKind.RuleDeleted && e.Phase == AccessAuditEventPhase.Outcome
48+
&& e.AccessRuleId == existing.Id && e.RuleName == existing.Name));
49+
}
50+
51+
[Theory, BitAutoData]
52+
public async Task DeleteAsync_MissingExisting_ThrowsNotFound(
53+
SutProvider<DeleteAccessRuleCommand> sutProvider)
54+
{
55+
sutProvider.GetDependency<IAccessRuleRepository>()
56+
.GetByIdAsync(Arg.Any<Guid>())
57+
.Returns((AccessRule?)null);
58+
59+
await Assert.ThrowsAsync<NotFoundException>(
60+
() => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()));
61+
await sutProvider.GetDependency<IAccessRuleRepository>()
62+
.DidNotReceiveWithAnyArgs().DeleteAsync(default!);
63+
}
64+
65+
[Theory, BitAutoData]
66+
public async Task DeleteAsync_WrongOrg_ThrowsNotFound(
67+
AccessRule existing, SutProvider<DeleteAccessRuleCommand> sutProvider)
68+
{
69+
sutProvider.GetDependency<IAccessRuleRepository>()
70+
.GetByIdAsync(existing.Id)
71+
.Returns(existing);
72+
73+
await Assert.ThrowsAsync<NotFoundException>(
74+
() => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), existing.Id, Guid.NewGuid()));
75+
await sutProvider.GetDependency<IAccessRuleRepository>()
76+
.DidNotReceiveWithAnyArgs().DeleteAsync(default!);
77+
}
78+
}

0 commit comments

Comments
 (0)