merge(agent-b): rows 98/99 — site-scope gate fail-closed on unresolvable targets (5 sites, not 2: the inline GetInstance/GetSite/GetDataConnection shapes were an id-enumeration oracle; all route through DenyUnresolvedTargetIfScoped, denial indistinguishable from out-of-scope); CLI audit query/export drop 7 case-sensitive AcceptOnlyFromAmong gates for help-text values + a new strict server-side ParseEnumListStrict (no silent drop, no ordinals)
This commit is contained in:
@@ -231,8 +231,12 @@ public class CommandTreeTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("sideways")]
|
||||
// AcceptOnlyFromAmong is case-SENSITIVE (same contract as the audit
|
||||
// --channel / --kind / --status options), so the literals are lowercase only.
|
||||
// AcceptOnlyFromAmong is case-SENSITIVE, so the literals are lowercase only.
|
||||
// The audit --channel/--kind/--status options USED to share this contract; they
|
||||
// dropped AcceptOnlyFromAmong in register row 99 precisely because the
|
||||
// case-sensitivity rejected values the server accepts. --direction keeps it: it is
|
||||
// a pure client-side literal that never reaches a server-side parse, so there is no
|
||||
// authoritative check behind it to defer to.
|
||||
[InlineData("Up")]
|
||||
public void TemplateFolderReorder_RejectsAnythingElse(string direction)
|
||||
{
|
||||
@@ -255,6 +259,81 @@ public class CommandTreeTests
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Register row 99: the audit filter options must NOT be case-sensitively
|
||||
// gated client-side.
|
||||
//
|
||||
// `audit query --status delivered` used to fail at the CLI parse layer while
|
||||
// the server would have accepted it — AcceptOnlyFromAmong matches ordinally
|
||||
// against the capitalised literals. The valid set now lives in the help text
|
||||
// and the server's strict, case-insensitive check is authoritative (the same
|
||||
// treatment row 88 gave `deploy status`). These tests fail before that change.
|
||||
// ========================================================================
|
||||
|
||||
public static TheoryData<string, string, string> AuditFilterValues() => new()
|
||||
{
|
||||
// subcommand, option, value — lowercase, uppercase and mixed case of a real member.
|
||||
{ "query", "--status", "delivered" },
|
||||
{ "query", "--status", "DELIVERED" },
|
||||
{ "query", "--channel", "apioutbound" },
|
||||
{ "query", "--kind", "apicall" },
|
||||
{ "export", "--status", "parked" },
|
||||
{ "export", "--channel", "notification" },
|
||||
{ "export", "--kind", "dbwrite" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AuditFilterValues))]
|
||||
public void AuditFilterOptions_AcceptValuesInAnyCase(string subcommand, string option, string value)
|
||||
{
|
||||
var audit = AuditCommands.Build(Url, Format, Username, Password);
|
||||
var cmd = audit.Subcommands.Single(c => c.Name == subcommand);
|
||||
|
||||
// `export` has three required options; supply them so the only thing under
|
||||
// test is the filter value's own acceptance.
|
||||
var args = subcommand == "export"
|
||||
? new[] { "--since", "1h", "--until", "now", "--format", "csv", "--output", "out.csv", option, value }
|
||||
: new[] { option, value };
|
||||
|
||||
var parse = cmd.Parse(args);
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("csv")]
|
||||
[InlineData("CSV")]
|
||||
[InlineData("jsonl")]
|
||||
public void AuditExport_FormatOption_AcceptsValuesInAnyCase(string format)
|
||||
{
|
||||
// The server lowercases --format before matching and returns a 400 naming
|
||||
// csv/jsonl/parquet for anything else, so the client-side literal gate was
|
||||
// pure friction (register row 99).
|
||||
var audit = AuditCommands.Build(Url, Format, Username, Password);
|
||||
var export = audit.Subcommands.Single(c => c.Name == "export");
|
||||
|
||||
var parse = export.Parse(new[]
|
||||
{
|
||||
"--since", "1h", "--until", "now", "--format", format, "--output", "out.csv",
|
||||
});
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditQuery_UnrecognisedFilterValue_IsNotRejectedClientSide()
|
||||
{
|
||||
// Deliberate: the CLI no longer second-guesses the filter vocabulary. A bad
|
||||
// value must reach the server, which answers 400 naming the valid set —
|
||||
// one authoritative check instead of two that can drift apart.
|
||||
var audit = AuditCommands.Build(Url, Format, Username, Password);
|
||||
var query = audit.Subcommands.Single(c => c.Name == "query");
|
||||
|
||||
var parse = query.Parse(new[] { "--status", "NotAStatus" });
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteArea_HasMoveVerb()
|
||||
{
|
||||
|
||||
@@ -40,15 +40,34 @@ public class AuditExportCommandTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_InvalidFormat_Rejected()
|
||||
public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer()
|
||||
{
|
||||
// Register row 99: the client-side AcceptOnlyFromAmong gate is gone. It
|
||||
// matched case-SENSITIVELY, so it rejected `--format CSV` even though the
|
||||
// server lowercases the value before matching. The server returns a 400
|
||||
// naming csv/jsonl/parquet and is the authoritative check.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "xml", "--output", "/tmp/out.xml",
|
||||
});
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CSV")]
|
||||
[InlineData("Csv")]
|
||||
[InlineData("JSONL")]
|
||||
public void Export_FormatInAnyCase_IsAccepted(string format)
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", format, "--output", "/tmp/out.csv",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
// ---- Query string -----------------------------------------------------
|
||||
@@ -148,16 +167,18 @@ public class AuditExportCommandTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_MultiValueChannel_WithOneInvalidName_FailsFast()
|
||||
public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||
{
|
||||
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
|
||||
// Register row 99: the vocabulary gate moved server-side; the multi-value
|
||||
// shape itself is unchanged, so both values reach the server to adjudicate.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "export", "--since", "1h", "--until", "0h",
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "csv", "--output", "/tmp/out.csv",
|
||||
"--channel", "ApiOutbound", "OutboundApi");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
"--channel", "ApiOutbound", "OutboundApi",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
// ---- Streaming export to file -----------------------------------------
|
||||
|
||||
@@ -424,43 +424,51 @@ public class AuditQueryCommandTests
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_MultiValueChannel_WithOneInvalidName_FailsFast()
|
||||
// ========================================================================
|
||||
// Register row 99: filter-vocabulary validation moved OFF the CLI.
|
||||
//
|
||||
// These four tests used to assert that AcceptOnlyFromAmong rejected an
|
||||
// unrecognised --channel/--kind/--status at the parse layer. That gate matched
|
||||
// case-SENSITIVELY, so it also rejected `--status delivered`, which the server
|
||||
// has always accepted — the CLI was stricter than the system it drives. The
|
||||
// gate is gone; the parse layer now accepts any string and the server answers
|
||||
// 400 naming the valid set (AuditEndpointsTests pins that side).
|
||||
//
|
||||
// What is asserted here is the CLIENT half of that contract: the value is
|
||||
// carried through to the request rather than rejected locally.
|
||||
// ========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
|
||||
[InlineData("--kind", "CachedCall")]
|
||||
[InlineData("--status", "Bogus")]
|
||||
public void Query_UnrecognisedFilterValue_IsNotRejectedAtTheParseLayer(string option, string value)
|
||||
{
|
||||
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "query", "--channel", "ApiOutbound", "OutboundApi");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
var parse = root.Parse(new[] { "audit", "query", option, value });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
|
||||
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
|
||||
{
|
||||
// "OutboundApi" is the old (non-existent) name; the real enum is "ApiOutbound".
|
||||
// The multi-value shape is unchanged; only the vocabulary gate is gone, so
|
||||
// BOTH values reach the server for it to adjudicate.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--channel", "OutboundApi");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
Assert.Contains("OutboundApi", err);
|
||||
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "OutboundApi" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_KindWithInvalidName_FailsFast_NonZeroExit()
|
||||
[Theory]
|
||||
[InlineData("--channel", "apioutbound")]
|
||||
[InlineData("--kind", "apicall")]
|
||||
[InlineData("--status", "delivered")]
|
||||
[InlineData("--status", "DELIVERED")]
|
||||
public void Query_FilterValueInAnyCase_IsAccepted(string option, string value)
|
||||
{
|
||||
// The defect row 99 names: these all failed client-side before the fix.
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--kind", "CachedCall");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_StatusWithInvalidName_FailsFast_NonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--status", "Bogus");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
var parse = root.Parse(new[] { "audit", "query", option, value });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers
|
||||
/// used by the ManagementService + CentralUI audit endpoints and the
|
||||
/// <c>AuditLogPage</c> drill-in parser. The contract under test: parse each
|
||||
/// repeated value independently, silently drop unparseable/blank elements, and
|
||||
/// collapse an empty result to <c>null</c>.
|
||||
/// Audit Log #23 (M8): tests for the shared multi-value query-param parsers used by
|
||||
/// the ManagementService + CentralUI audit endpoints and the <c>AuditLogPage</c>
|
||||
/// drill-in parser. Two contracts are under test:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Lax</b> (<c>ParseEnumList</c>/<c>ParseStringList</c>, used by the
|
||||
/// CentralUI surfaces): parse each repeated value independently, silently drop
|
||||
/// unparseable/blank elements, collapse an empty result to <c>null</c>.</item>
|
||||
/// <item><b>Strict</b> (<c>ParseEnumListStrict</c>, register row 99, used by the
|
||||
/// ManagementService endpoints the CLI calls): an unrecognised value is an error
|
||||
/// naming the valid set; blanks are still dropped.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AuditQueryParamParsersTests
|
||||
{
|
||||
@@ -53,6 +59,92 @@ public class AuditQueryParamParsersTests
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditStatus>(new[] { "Bogus", "" }));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Strict variant (register row 99) — the contract the ManagementService
|
||||
// /api/audit/query + /api/audit/export endpoints use, so the CLI can drop its
|
||||
// case-SENSITIVE AcceptOnlyFromAmong guard and still fail loudly on garbage.
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumListStrict_NullInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(null, "status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumListStrict_EmptyInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(Array.Empty<string?>(), "status"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("delivered")]
|
||||
[InlineData("DELIVERED")]
|
||||
[InlineData("Delivered")]
|
||||
[InlineData(" delivered ")]
|
||||
public void ParseEnumListStrict_IsCaseInsensitiveAndTrims(string raw)
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(new[] { raw }, "status");
|
||||
Assert.Equal(new[] { AuditStatus.Delivered }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumListStrict_AllValuesValid_ParsesEverything()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumListStrict<AuditChannel>(
|
||||
new[] { "ApiOutbound", "dboutbound" }, "channel");
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumListStrict_UnrecognisedValue_ThrowsNamingTheValidSet()
|
||||
{
|
||||
var ex = Assert.Throws<AuditFilterParseException>(
|
||||
() => AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(
|
||||
new[] { "Delivered", "NotAStatus" }, "status"));
|
||||
|
||||
// The offending value AND the whole valid vocabulary must be in the message —
|
||||
// naming the valid set is the entire point of the check.
|
||||
Assert.Contains("NotAStatus", ex.Message);
|
||||
Assert.Contains("status", ex.Message);
|
||||
foreach (var name in Enum.GetNames<AuditStatus>())
|
||||
{
|
||||
Assert.Contains(name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// "7" is out of range; "3" lands on a REAL member. Enum.TryParse accepts BOTH —
|
||||
// a filter is a name-based contract, never an ordinal one, so both are rejected.
|
||||
[InlineData("7")]
|
||||
[InlineData("3")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("+2")]
|
||||
public void ParseEnumListStrict_NumericString_IsRejected(string raw)
|
||||
{
|
||||
var ex = Assert.Throws<AuditFilterParseException>(
|
||||
() => AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(new[] { raw }, "status"));
|
||||
Assert.Contains(raw, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumListStrict_BlankElements_AreStillDroppedSilently()
|
||||
{
|
||||
// A repeated query param can legitimately carry an empty slot; only a
|
||||
// NON-blank unrecognised value is an error.
|
||||
var result = AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(
|
||||
new[] { "Delivered", "", " ", null }, "status");
|
||||
Assert.Equal(new[] { AuditStatus.Delivered }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumListStrict_AllBlank_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(
|
||||
new[] { "", " ", null }, "status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseStringList_NullInput_ReturnsNull()
|
||||
{
|
||||
|
||||
@@ -416,23 +416,80 @@ public class AuditEndpointsTests
|
||||
Assert.Null(filter.SourceSiteIds);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Register row 99: the enum filter dimensions parse STRICTLY here.
|
||||
//
|
||||
// These used to assert the LAX contract — an unrecognised enum name was
|
||||
// dropped and the query ran unfiltered on that dimension, so `--status
|
||||
// Nonsense` returned a plausible, complete-looking page that did not answer
|
||||
// the question asked. That is exactly the failure row 88 fixed for `deploy
|
||||
// status`, and it became reachable from the CLI once the (case-SENSITIVE,
|
||||
// and therefore over-strict) client-side AcceptOnlyFromAmong guard was
|
||||
// removed. The Guid/date dimensions stay lax — see ParseFilter's remarks.
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_UnparseableValuesInRepeatedSet_AreDroppedSilently()
|
||||
public void ParseFilter_UnrecognisedEnumValue_Throws()
|
||||
{
|
||||
// Lax-parse contract: an unrecognised enum name is dropped, the rest of
|
||||
// the repeated set survives — no 400, no whole-set drop.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" },
|
||||
});
|
||||
|
||||
var ex = Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
|
||||
Assert.Contains("Bogus", ex.Message);
|
||||
Assert.Contains("channel", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_UnrecognisedStatus_ThrowsNamingTheValidSet()
|
||||
{
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["status"] = new[] { "Nonsense" },
|
||||
});
|
||||
|
||||
var ex = Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
|
||||
foreach (var name in Enum.GetNames<AuditStatus>())
|
||||
{
|
||||
Assert.Contains(name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_LowercaseEnumValues_AreAccepted()
|
||||
{
|
||||
// The CLI sends whatever the operator typed now that its own case-sensitive
|
||||
// guard is gone; `--status delivered` must resolve here.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["channel"] = new[] { "apioutbound" },
|
||||
["kind"] = new[] { "apicall" },
|
||||
["status"] = new[] { "delivered", "PARKED" },
|
||||
});
|
||||
|
||||
var filter = AuditEndpoints.ParseFilter(query);
|
||||
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, filter.Channels);
|
||||
// Every value unparseable → the dimension stays unconstrained (null).
|
||||
Assert.Null(filter.Statuses);
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
|
||||
Assert.Equal(new[] { AuditKind.ApiCall }, filter.Kinds);
|
||||
Assert.Equal(new[] { AuditStatus.Delivered, AuditStatus.Parked }, filter.Statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_NumericEnumValue_IsRejected()
|
||||
{
|
||||
// Enum.TryParse would accept "3" as an ordinal; a filter is a name-based
|
||||
// contract, so it must not.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["status"] = new[] { "3" },
|
||||
});
|
||||
|
||||
Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -985,6 +985,193 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Register row 98: the scope gate fails CLOSED on an unresolvable target.
|
||||
//
|
||||
// EnforceSiteScopeForInstance / EnforceSiteScopeForIdentifier used to skip
|
||||
// EnforceSiteScope entirely when the id did not resolve, so a site-scoped
|
||||
// caller PASSED the gate and fell through into the handler body. A gate that
|
||||
// cannot prove the target is inside the caller's scope must treat it as
|
||||
// outside. System-wide callers return at the guards above the lookup and are
|
||||
// untouched — they still get the handler's ordinary "not found".
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// _templateRepo is unstubbed for id 999 → GetInstanceByIdAsync returns null.
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
// The denial must not double as an existence oracle: the message says the
|
||||
// instance is not in scope, never whether it exists.
|
||||
Assert.DoesNotContain("not found", response.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForSiteScopedUser_IsIndistinguishableFromOutOfScope()
|
||||
{
|
||||
// The security property: a scoped caller cannot tell "instance 7 lives at a
|
||||
// site you may not see" apart from "instance 999 does not exist" — both are
|
||||
// the same ManagementUnauthorized shape.
|
||||
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
|
||||
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer"));
|
||||
var outOfScope = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
|
||||
actor.Tell(ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer"));
|
||||
var nonexistent = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(outOfScope.GetType(), nonexistent.GetType());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForAdminUser_IsUnchanged()
|
||||
{
|
||||
// An Administrator is system-wide, so the gate returns before the lookup:
|
||||
// the handler runs and answers for itself. NOT a ManagementUnauthorized.
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForSystemWideDeployer_IsUnchanged()
|
||||
{
|
||||
// The other system-wide arm: a Deployer with NO site grants. Same rule,
|
||||
// same untouched behaviour.
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(999), Array.Empty<string>(), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_InScopeIdForSiteScopedUser_IsStillAllowed()
|
||||
{
|
||||
// The fail-closed change must not deny a legitimate in-scope target.
|
||||
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// HandleGetSite resolves the site itself and carried its own copy of the
|
||||
// fail-OPEN shape (`if (site != null) EnforceSiteScope(...)`), so a scoped
|
||||
// caller used to get success/null for any id — an enumeration oracle.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.Site?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_NonexistentIdForAdminUser_IsUnchanged()
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.Site?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDataConnection_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// Third copy of the same inline fail-OPEN shape.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.DataConnection?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetDataConnectionCommand(999), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryEventLogs_NonexistentSiteIdentifierForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// EnforceSiteScopeForIdentifier shares the shape: an identifier that
|
||||
// resolves to no Site row is a denial for a scoped caller.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdentifierAsync("GHOST", Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.Site?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(
|
||||
new QueryEventLogsCommand("GHOST"),
|
||||
new[] { "1" },
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryEventLogs_InScopeSiteIdentifierForSiteScopedUser_IsStillAllowed()
|
||||
{
|
||||
AddSiteRepoWithSite(1, "SITE1");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(
|
||||
new QueryEventLogsCommand("SITE1"),
|
||||
new[] { "1" },
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
// The scope gate passes; whatever the (unwired) CommunicationService does
|
||||
// next is not this test's business — only that it is NOT a scope denial.
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user