fix(auth): C2 review — not-found throws (no spurious audit) on update/delete/set-methods, reject empty methods (unusable-key/stealth-disable), richer set-methods response, token advisory to stderr

This commit is contained in:
Joseph Doherty
2026-06-02 04:21:28 -04:00
parent 6518e93424
commit 8219b8ee18
4 changed files with 188 additions and 5 deletions
@@ -0,0 +1,83 @@
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for <see cref="SecurityCommands"/> static helpers.
/// Fix 4 (review): the "Save this token now" advisory must reach stderr so that
/// piping stdout captures only the token (the actual secret), not the advisory text.
/// </summary>
[Collection("Console")]
public class SecurityCommandsTests
{
/// <summary>
/// The advisory line "Save this token now — it will not be shown again:" must be
/// written to stderr, not stdout.
/// </summary>
[Fact]
public void PrintCreatedKey_AdvisoryLine_WrittenToStderr_NotStdout()
{
var json = """{"keyId":"abc123","name":"Test","token":"sbk_abc123_secret"}""";
var stdoutWriter = new StringWriter();
var stderrWriter = new StringWriter();
Console.SetOut(stdoutWriter);
Console.SetError(stderrWriter);
try
{
var exitCode = SecurityCommands.PrintCreatedKey(json);
Assert.Equal(0, exitCode);
var stdout = stdoutWriter.ToString();
var stderr = stderrWriter.ToString();
// The advisory must appear on stderr.
Assert.Contains("Save this token now", stderr);
Assert.Contains("will not be shown again", stderr);
// The advisory must NOT appear on stdout (so pipe captures only the token).
Assert.DoesNotContain("Save this token now", stdout);
// The token itself must appear on stdout.
Assert.Contains("sbk_abc123_secret", stdout);
}
finally
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
}
}
/// <summary>
/// The token value is written to stdout; piping <c>| xargs</c> captures only the token.
/// The keyId info line also appears on stdout (it is not sensitive and does not impede piping
/// since operators pipe the token line, not the whole output).
/// </summary>
[Fact]
public void PrintCreatedKey_Token_WrittenToStdout()
{
var json = """{"keyId":"key-42","name":"MES","token":"sbk_key-42_mysecret"}""";
var stdoutWriter = new StringWriter();
var stderrWriter = new StringWriter();
Console.SetOut(stdoutWriter);
Console.SetError(stderrWriter);
try
{
SecurityCommands.PrintCreatedKey(json);
var stdout = stdoutWriter.ToString();
Assert.Contains("sbk_key-42_mysecret", stdout);
Assert.Contains("key-42", stdout);
}
finally
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true });
}
}
}
@@ -167,12 +167,99 @@ public class ApiKeyCreationTests : TestKit, IDisposable
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(new[] { "New1", "New2", "New3" }, _admin.Keys["key-1"].Methods);
Assert.Equal("true", response.JsonData);
// Fix 3 (review): response is now the richer { KeyId, Methods } shape.
using var doc = JsonDocument.Parse(response.JsonData);
Assert.Equal("key-1", doc.RootElement.GetProperty("keyId").GetString());
var methods = doc.RootElement.GetProperty("methods").EnumerateArray()
.Select(m => m.GetString()).ToArray();
Assert.Equal(new[] { "New1", "New2", "New3" }, methods);
_auditService.Received(1).LogAsync(
"admin", "Update", "ApiKey", "key-1", Arg.Any<string>(), Arg.Any<object?>());
}
// =========================================================================
// Fix 1 (review): not-found on mutating ops → ManagementError, no audit
// =========================================================================
[Fact]
public void UpdateApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
{
// No keys seeded — "key-unknown" does not exist.
var actor = CreateActor();
actor.Tell(Envelope(new UpdateApiKeyCommand("key-unknown", false), "Admin"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("key-unknown", response.Error);
// Seam returned false → audit must not have fired.
_auditService.DidNotReceive().LogAsync(
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
}
[Fact]
public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
{
var actor = CreateActor();
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-unknown", new[] { "M1" }), "Admin"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("key-unknown", response.Error);
_auditService.DidNotReceive().LogAsync(
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
}
[Fact]
public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
{
var actor = CreateActor();
actor.Tell(Envelope(new DeleteApiKeyCommand("key-unknown"), "Admin"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("key-unknown", response.Error);
_auditService.DidNotReceive().LogAsync(
Arg.Any<string>(), "Delete", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
}
// =========================================================================
// Fix 2 (review): empty methods set is rejected before seam + audit
// =========================================================================
[Fact]
public void CreateApiKey_EmptyMethods_ReturnsManagementError()
{
var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", Array.Empty<string>()), "Admin"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("method", response.Error, StringComparison.OrdinalIgnoreCase);
// No key should have been created.
Assert.Empty(_admin.Keys);
_auditService.DidNotReceive().LogAsync(
Arg.Any<string>(), "Create", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
}
[Fact]
public void SetApiKeyMethods_EmptyMethods_ReturnsManagementError()
{
_admin.Seed("key-1", "Service A", enabled: true, "M1");
var actor = CreateActor();
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", Array.Empty<string>()), "Admin"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("method", response.Error, StringComparison.OrdinalIgnoreCase);
// Existing scope set must be unchanged.
Assert.Equal(new[] { "M1" }, _admin.Keys["key-1"].Methods);
_auditService.DidNotReceive().LogAsync(
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
}
[Theory]
[MemberData(nameof(AllApiKeyCommands))]
public void EveryApiKeyCommand_RequiresAdminRole(object command)