fix(management): EWS write gate requires username:password credentials
This commit is contained in:
@@ -2213,7 +2213,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
"EWS transport requires Host to be an absolute https:// EWS endpoint URL "
|
"EWS transport requires Host to be an absolute https:// EWS endpoint URL "
|
||||||
+ "(e.g. https://mail.example.com/ews/exchange.asmx).");
|
+ $"(e.g. https://mail.example.com/ews/exchange.asmx); got '{config.Host}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists
|
// The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists
|
||||||
@@ -2223,6 +2223,19 @@ public class ManagementActor : ReceiveActor
|
|||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
"EWS transport supports only Basic authentication; set AuthMode to 'basic'.");
|
"EWS transport supports only Basic authentication; set AuthMode to 'basic'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same split semantics as the delivery adapter: FIRST colon only, because a
|
||||||
|
// domain-qualified user name contains none but a password legitimately may.
|
||||||
|
// Without this the common mistake — flipping an existing SMTP row (whose
|
||||||
|
// Credentials is a bare password) to Ews — would persist happily and then park
|
||||||
|
// every single notification at delivery time.
|
||||||
|
var credentialParts = config.Credentials?.Split(':', 2) ?? [];
|
||||||
|
if (credentialParts.Length != 2 || credentialParts[0].Length == 0 || credentialParts[1].Length == 0)
|
||||||
|
{
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
"EWS transport requires Credentials in 'username:password' form "
|
||||||
|
+ "(username may be 'domain\\user').");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1680,6 +1680,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Port = 25,
|
Port = 25,
|
||||||
|
Credentials = "example\\notify:fake-password",
|
||||||
};
|
};
|
||||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
_services.AddScoped(_ => notifRepo);
|
_services.AddScoped(_ => notifRepo);
|
||||||
@@ -1755,6 +1756,9 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||||
Assert.Contains("https://", response.Error);
|
Assert.Contains("https://", response.Error);
|
||||||
|
// The offending value is echoed so the operator sees what was rejected
|
||||||
|
// (matches the delivery adapter's message style).
|
||||||
|
Assert.Contains("http://ews.example.test/ews/exchange.asmx", response.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1784,6 +1788,97 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Assert.Contains("Basic authentication", response.Error);
|
Assert.Contains("Basic authentication", response.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateSmtpConfig_WithEwsTransportAndMalformedCredentials_ReturnsManagementError()
|
||||||
|
{
|
||||||
|
var notifRepo = Substitute.For<INotificationRepository>();
|
||||||
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||||
|
"old.example.com", "Basic", "old@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 25,
|
||||||
|
Credentials = "example\\notify:fake-password",
|
||||||
|
};
|
||||||
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
_services.AddScoped(_ => notifRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
// A supplied credential with no colon at all — the EWS sender cannot build a
|
||||||
|
// Basic header from it, so every send would park.
|
||||||
|
var envelope = Envelope(
|
||||||
|
new UpdateSmtpConfigCommand(
|
||||||
|
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||||
|
Credentials: "passwordwithoutcolon",
|
||||||
|
Transport: "Ews"),
|
||||||
|
"Administrator");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||||
|
Assert.Contains("username:password", response.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateSmtpConfig_WithEwsTransportAndEmptyCredentialHalf_ReturnsManagementError()
|
||||||
|
{
|
||||||
|
var notifRepo = Substitute.For<INotificationRepository>();
|
||||||
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||||
|
"old.example.com", "Basic", "old@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 25,
|
||||||
|
};
|
||||||
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
_services.AddScoped(_ => notifRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
// A trailing colon splits into two parts, but the password half is empty —
|
||||||
|
// the adapter rejects it, so the write gate must too.
|
||||||
|
var envelope = Envelope(
|
||||||
|
new UpdateSmtpConfigCommand(
|
||||||
|
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||||
|
Credentials: "example\\notify:",
|
||||||
|
Transport: "Ews"),
|
||||||
|
"Administrator");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||||
|
Assert.Contains("username:password", response.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateSmtpConfig_FlippingToEwsWithStoredBadCredentials_ReturnsManagementError()
|
||||||
|
{
|
||||||
|
var notifRepo = Substitute.For<INotificationRepository>();
|
||||||
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||||
|
"old.example.com", "Basic", "old@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 25,
|
||||||
|
Credentials = "bare-smtp-password",
|
||||||
|
};
|
||||||
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
_services.AddScoped(_ => notifRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
// Credentials omitted entirely: the gate validates the EFFECTIVE state, so the
|
||||||
|
// preserved (unusable) stored value is what gets rejected.
|
||||||
|
var envelope = Envelope(
|
||||||
|
new UpdateSmtpConfigCommand(
|
||||||
|
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||||
|
Transport: "Ews"),
|
||||||
|
"Administrator");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||||
|
Assert.Contains("username:password", response.Error);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue()
|
public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue()
|
||||||
{
|
{
|
||||||
@@ -1794,6 +1889,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Id = 1,
|
Id = 1,
|
||||||
Port = 443,
|
Port = 443,
|
||||||
Transport = "Ews",
|
Transport = "Ews",
|
||||||
|
Credentials = "example\\notify:fake-password",
|
||||||
};
|
};
|
||||||
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
_services.AddScoped(_ => notifRepo);
|
_services.AddScoped(_ => notifRepo);
|
||||||
@@ -1815,6 +1911,59 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host);
|
Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Smtp")]
|
||||||
|
[InlineData("Ews")]
|
||||||
|
[InlineData("Graph")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void UpdateSmtpConfig_TransportAcceptance_MatchesEmailTransportParser(string? transport)
|
||||||
|
{
|
||||||
|
// Parity pin: the write gate re-implements the transport parse inline (ManagementService
|
||||||
|
// does not reference NotificationService), so this asserts the mirror accepts exactly the
|
||||||
|
// values EmailTransportParser accepts. If the parser gains a transport, this goes red.
|
||||||
|
var parserAccepts = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NotificationService.EmailTransportParser.Parse(transport);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
parserAccepts = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifRepo = Substitute.For<INotificationRepository>();
|
||||||
|
// A fixture valid under EITHER transport, so the transport parse is the only variable.
|
||||||
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||||
|
"https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 443,
|
||||||
|
Credentials = "example\\notify:fake-password",
|
||||||
|
};
|
||||||
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
_services.AddScoped(_ => notifRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope(
|
||||||
|
new UpdateSmtpConfigCommand(
|
||||||
|
1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
|
||||||
|
Transport: transport),
|
||||||
|
"Administrator");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
if (parserAccepts)
|
||||||
|
{
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var error = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("Unknown email transport", error.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// SMS Notifications (S5) — list Type discriminator + SMS-config management
|
// SMS Notifications (S5) — list Type discriminator + SMS-config management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
+5
@@ -28,5 +28,10 @@
|
|||||||
<!-- #206: SecuredWrite audit now routes through the real CentralAuditWriter so the
|
<!-- #206: SecuredWrite audit now routes through the real CentralAuditWriter so the
|
||||||
handler tests verify SourceNode stamping end-to-end (actor → writer → repo). -->
|
handler tests verify SourceNode stamping end-to-end (actor → writer → repo). -->
|
||||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj" />
|
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj" />
|
||||||
|
<!-- Test-only: ManagementActor deliberately does NOT reference NotificationService, so its
|
||||||
|
EWS write gate re-implements the transport parse inline. This reference exists purely so
|
||||||
|
a parity test can pin that mirror to the real EmailTransportParser. Do not take a
|
||||||
|
production dependency on it from ManagementService. -->
|
||||||
|
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user