Skip to content

Commit b16e1fa

Browse files
committed
Relocate condition tests onto the conditions
Move each condition's evaluation branches out of AccessRuleEngineTests into IpAllowlistConditionTests and TimeOfDayConditionTests, and test the combination semantics directly on AccessEvaluation.Combine. AccessRuleEngineTests keeps only the engine's orchestration (delegation, folding, signal forwarding, empty list, null-entry fail-closed), exercised with a stub condition. Also add direct TimeWindow.Contains coverage for the inclusive boundaries (time == from/to) and unparseable bounds, which the engine tests never hit.
1 parent 2475cd7 commit b16e1fa

4 files changed

Lines changed: 365 additions & 223 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using Bit.Services.Pam.Engine;
2+
using Xunit;
3+
4+
namespace Bit.Services.Pam.Test.Engine;
5+
6+
public class AccessEvaluationTests
7+
{
8+
[Fact]
9+
public void Combine_Empty_Allows()
10+
{
11+
// An empty rule is vacuously satisfied.
12+
Assert.Equal(AccessEvaluationOutcome.Allow, AccessEvaluation.Combine([]).Outcome);
13+
}
14+
15+
[Fact]
16+
public void Combine_AllAllow_Allows()
17+
{
18+
var result = AccessEvaluation.Combine([AccessEvaluation.Allow, AccessEvaluation.Allow]);
19+
20+
Assert.Equal(AccessEvaluationOutcome.Allow, result.Outcome);
21+
}
22+
23+
[Fact]
24+
public void Combine_ApprovalOverAllow_RequiresApproval()
25+
{
26+
var result = AccessEvaluation.Combine([AccessEvaluation.Allow, AccessEvaluation.RequiresApproval]);
27+
28+
Assert.Equal(AccessEvaluationOutcome.RequiresApproval, result.Outcome);
29+
}
30+
31+
[Fact]
32+
public void Combine_DenyOutranksAllow_Denies()
33+
{
34+
var result = AccessEvaluation.Combine([AccessEvaluation.Allow, AccessEvaluation.Deny(DenyReason.NotWithinIpRange)]);
35+
36+
Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
37+
Assert.Equal(DenyReason.NotWithinIpRange, result.Reason);
38+
}
39+
40+
[Fact]
41+
public void Combine_DenyOutranksApproval_Denies()
42+
{
43+
// Deny beats a pending approval regardless of order: there is nothing to approve if access is barred.
44+
var result = AccessEvaluation.Combine([AccessEvaluation.RequiresApproval, AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow)]);
45+
46+
Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
47+
Assert.Equal(DenyReason.NotWithinTimeWindow, result.Reason);
48+
}
49+
50+
[Fact]
51+
public void Combine_ApprovalAfterDeny_StillDenies()
52+
{
53+
var result = AccessEvaluation.Combine([AccessEvaluation.Deny(DenyReason.NotWithinIpRange), AccessEvaluation.RequiresApproval]);
54+
55+
Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
56+
Assert.Equal(DenyReason.NotWithinIpRange, result.Reason);
57+
}
58+
59+
[Fact]
60+
public void Combine_FirstDenyWins_PreservesItsReason()
61+
{
62+
// Combine short-circuits on the first deny, so its reason is the one reported.
63+
var result = AccessEvaluation.Combine(
64+
[
65+
AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow),
66+
AccessEvaluation.Deny(DenyReason.NotWithinIpRange),
67+
]);
68+
69+
Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
70+
Assert.Equal(DenyReason.NotWithinTimeWindow, result.Reason);
71+
}
72+
}
Lines changed: 33 additions & 223 deletions
Original file line numberDiff line numberDiff line change
@@ -1,276 +1,86 @@
11
using System.Net;
22
using Bit.Services.Pam.Engine;
3-
using Bit.Services.Pam.Enums;
43
using Bit.Services.Pam.Models.Conditions;
54
using Xunit;
65

76
namespace Bit.Services.Pam.Test.Engine;
87

98
public class AccessRuleEngineTests
109
{
11-
// 2026-06-04T12:00:00Z is a Thursday, so "thu" windows match in UTC.
12-
private static readonly DateTimeOffset _now = new(2026, 6, 4, 12, 0, 0, TimeSpan.Zero);
13-
1410
private readonly AccessRuleEngine _sut = new();
1511

16-
private static AccessSignals Signals(IPAddress? ip = null, DateTimeOffset? at = null) => new()
12+
private static AccessSignals Signals() => new()
1713
{
18-
IpAddress = ip,
19-
Timestamp = at ?? _now,
14+
IpAddress = IPAddress.Parse("10.1.2.3"),
15+
Timestamp = new DateTimeOffset(2026, 6, 4, 12, 0, 0, TimeSpan.Zero),
2016
};
2117

22-
private static AccessCondition[] Set(params AccessCondition[] conditions) => conditions;
23-
24-
[Fact]
25-
public void Evaluate_HumanApproval_RequiresApproval()
26-
{
27-
var evaluation = _sut.Evaluate(Set(new HumanApprovalCondition()), Signals());
28-
29-
Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome);
30-
}
31-
32-
[Fact]
33-
public void Evaluate_IpAllowlist_IpInRange_Allows()
34-
{
35-
var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] });
36-
37-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3")));
38-
39-
Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
40-
}
41-
42-
[Fact]
43-
public void Evaluate_IpAllowlist_IpOutOfRange_Denies()
44-
{
45-
var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] });
46-
47-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("192.168.1.1")));
48-
49-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
50-
Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
51-
}
52-
53-
[Fact]
54-
public void Evaluate_IpAllowlist_UnknownIp_DeniesClosed()
55-
{
56-
var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] });
57-
58-
var evaluation = _sut.Evaluate(conditions, Signals(ip: null));
59-
60-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
61-
Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
62-
}
63-
64-
[Fact]
65-
public void Evaluate_IpAllowlist_NoEntries_DeniesClosed()
66-
{
67-
var evaluation = _sut.Evaluate(Set(new IpAllowlistCondition()), Signals(IPAddress.Parse("10.1.2.3")));
68-
69-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
70-
Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
71-
}
72-
73-
[Fact]
74-
public void Evaluate_TimeOfDay_WithinWindow_Allows()
75-
{
76-
var conditions = Set(new TimeOfDayCondition
77-
{
78-
Tz = "UTC",
79-
Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }],
80-
});
81-
82-
var evaluation = _sut.Evaluate(conditions, Signals());
83-
84-
Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
85-
}
86-
87-
[Fact]
88-
public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies()
89-
{
90-
var conditions = Set(new TimeOfDayCondition
91-
{
92-
Tz = "UTC",
93-
Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "06:00" }],
94-
});
95-
96-
var evaluation = _sut.Evaluate(conditions, Signals());
97-
98-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
99-
Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
100-
}
101-
10218
[Fact]
103-
public void Evaluate_TimeOfDay_DayNotListed_Denies()
19+
public void Evaluate_NoConditions_Allows()
10420
{
105-
var conditions = Set(new TimeOfDayCondition
106-
{
107-
Tz = "UTC",
108-
Windows = [new TimeWindow { Days = [AccessWeekday.Fri], From = "00:00", To = "23:59" }],
109-
});
110-
111-
var evaluation = _sut.Evaluate(conditions, Signals());
112-
113-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
114-
Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
21+
// A rule with no conditions is vacuously satisfied: access is auto-granted while still flowing through
22+
// PAM for audit logging.
23+
Assert.Equal(AccessEvaluationOutcome.Allow, _sut.Evaluate([], Signals()).Outcome);
11524
}
11625

11726
[Fact]
118-
public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone()
27+
public void Evaluate_DefersToEachConditionsOwnResult()
11928
{
120-
// 23:00 UTC is 19:00 (Thursday) in America/New_York during June DST, inside the window.
121-
var conditions = Set(new TimeOfDayCondition
122-
{
123-
Tz = "America/New_York",
124-
Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "18:00", To = "20:00" }],
125-
});
126-
127-
var evaluation = _sut.Evaluate(conditions, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero)));
128-
129-
Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
29+
// The engine does not decide anything itself; it returns what the condition's Evaluate reports.
30+
Assert.Equal(AccessEvaluationOutcome.RequiresApproval,
31+
_sut.Evaluate([new StubCondition(AccessEvaluation.RequiresApproval)], Signals()).Outcome);
13032
}
13133

13234
[Fact]
133-
public void Evaluate_TimeOfDay_UnknownTimezone_DeniesClosed()
35+
public void Evaluate_CombinesConditionResults_DenyWins()
13436
{
135-
var conditions = Set(new TimeOfDayCondition
37+
// Folding is delegated to AccessEvaluation.Combine (deny > approval > allow); a single denying condition
38+
// drives the whole rule to deny. The full precedence matrix is covered in AccessEvaluationTests.
39+
var conditions = new AccessCondition[]
13640
{
137-
Tz = "Not/AZone",
138-
Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "23:59" }],
139-
});
41+
new StubCondition(AccessEvaluation.Allow),
42+
new StubCondition(AccessEvaluation.Deny(DenyReason.NotWithinIpRange)),
43+
};
14044

14145
var evaluation = _sut.Evaluate(conditions, Signals());
14246

143-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
144-
Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
145-
}
146-
147-
[Fact]
148-
public void Evaluate_AllConditionsAllow_Allows()
149-
{
150-
var conditions = Set(
151-
new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] },
152-
new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }] });
153-
154-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3")));
155-
156-
Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
157-
}
158-
159-
[Fact]
160-
public void Evaluate_OneConditionDenies_DeniesWithThatReason()
161-
{
162-
var conditions = Set(
163-
new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] },
164-
new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "06:00" }] });
165-
166-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3")));
167-
168-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
169-
Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
170-
}
171-
172-
[Fact]
173-
public void Evaluate_AllowPlusHumanApproval_RequiresApproval()
174-
{
175-
var conditions = Set(
176-
new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] },
177-
new HumanApprovalCondition());
178-
179-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3")));
180-
181-
Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome);
182-
}
183-
184-
[Fact]
185-
public void Evaluate_DenyOutranksApproval()
186-
{
187-
// A denying condition beats a pending approval: there is nothing to approve if access is barred outright.
188-
var conditions = Set(
189-
new HumanApprovalCondition(),
190-
new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] });
191-
192-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("192.168.1.1")));
193-
19447
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
19548
Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
19649
}
19750

19851
[Fact]
199-
public void Evaluate_NoConditions_Allows()
52+
public void Evaluate_ForwardsSignalsToConditions()
20053
{
201-
// A rule with no conditions is vacuously satisfied: access is auto-granted while still flowing through
202-
// PAM for audit logging.
203-
var evaluation = _sut.Evaluate(Set(), Signals());
54+
var signals = Signals();
55+
var condition = new StubCondition(AccessEvaluation.Allow);
56+
57+
_sut.Evaluate([condition], signals);
20458

205-
Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
59+
Assert.Same(signals, condition.ReceivedSignals);
20660
}
20761

20862
[Fact]
20963
public void Evaluate_NullConditionEntry_DeniesClosed()
21064
{
21165
// A null entry (only reachable from a malformed stored document) cannot be evaluated, so it fails closed.
212-
// An unknown condition kind can no longer reach the engine: visitor dispatch is exhaustive at compile time.
66+
// An unknown condition kind can no longer reach the engine: it is rejected at JSON deserialization.
21367
var evaluation = _sut.Evaluate([null!], Signals());
21468

21569
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
21670
Assert.Equal(DenyReason.UnsupportedCondition, evaluation.Reason);
21771
}
21872

219-
[Fact]
220-
public void Evaluate_IpAllowlist_MalformedCidr_DeniesClosed()
73+
/// <summary>A condition with a fixed result, isolating the engine's folding from any real condition logic.</summary>
74+
private sealed class StubCondition(AccessEvaluation result) : AccessCondition
22175
{
222-
// A present-but-unparseable CIDR matches no address, so a caller with a known IP still fails closed.
223-
var conditions = Set(new IpAllowlistCondition { Cidrs = ["not-a-cidr"] });
224-
225-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3")));
76+
public AccessSignals? ReceivedSignals { get; private set; }
22677

227-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
228-
Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
229-
}
230-
231-
[Fact]
232-
public void Evaluate_IpAllowlist_LaterCidrMatches_Allows()
233-
{
234-
// The caller matches the second entry, so evaluation must not stop at the first non-matching CIDR.
235-
var conditions = Set(new IpAllowlistCondition { Cidrs = ["192.168.0.0/16", "10.0.0.0/8"] });
236-
237-
var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3")));
238-
239-
Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
240-
}
241-
242-
[Fact]
243-
public void Evaluate_TimeOfDay_MalformedWindowTime_DeniesClosed()
244-
{
245-
// The day matches but the window's bounds are unparseable, so the window cannot admit the caller.
246-
var conditions = Set(new TimeOfDayCondition
247-
{
248-
Tz = "UTC",
249-
Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "25:99", To = "30:00" }],
250-
});
251-
252-
var evaluation = _sut.Evaluate(conditions, Signals());
253-
254-
Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
255-
Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
256-
}
257-
258-
[Fact]
259-
public void Evaluate_TimeOfDay_LaterWindowMatches_Allows()
260-
{
261-
// The first window is the wrong day; the second admits the caller, so windows past the first must be checked.
262-
var conditions = Set(new TimeOfDayCondition
78+
public override AccessEvaluation Evaluate(AccessSignals signals)
26379
{
264-
Tz = "UTC",
265-
Windows =
266-
[
267-
new TimeWindow { Days = [AccessWeekday.Fri], From = "09:00", To = "17:00" },
268-
new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" },
269-
],
270-
});
271-
272-
var evaluation = _sut.Evaluate(conditions, Signals());
80+
ReceivedSignals = signals;
81+
return result;
82+
}
27383

274-
Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
84+
public override T Accept<T>(IAccessConditionVisitor<T> visitor) => throw new NotSupportedException();
27585
}
27686
}

0 commit comments

Comments
 (0)