refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,76 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// <summary>
/// NS-009: Tests for scrubbing SMTP credential secrets out of log/result text.
/// </summary>
public class CredentialRedactorTests
{
[Fact]
public void Scrub_BasicAuthPassword_IsMasked()
{
// Password 'Hunter2pass!word' is 16 chars (>= MinSecretLength=12) and
// therefore qualifies as a redactable secret-shaped trailing component.
var text = "535 5.7.8 Authentication failed for user 'svc' with password 'Hunter2pass!word'";
var result = CredentialRedactor.Scrub(text, "svc:Hunter2pass!word");
Assert.DoesNotContain("Hunter2pass!word", result);
Assert.DoesNotContain("svc:Hunter2pass!word", result);
}
[Fact]
public void Scrub_OAuth2ClientSecret_IsMasked()
{
var text = "Token request failed: client_secret=Sup3rSecretValue rejected by tenant";
var result = CredentialRedactor.Scrub(text, "tenant-guid:client-guid:Sup3rSecretValue");
Assert.DoesNotContain("Sup3rSecretValue", result);
}
[Fact]
public void Scrub_NullCredentials_ReturnsTextUnchanged()
{
Assert.Equal("plain text", CredentialRedactor.Scrub("plain text", null));
}
[Fact]
public void Scrub_NullText_ReturnsEmpty()
{
Assert.Equal(string.Empty, CredentialRedactor.Scrub(null, "user:pass"));
}
// --- NS-025: don't over-mask short non-secret components ---
[Fact]
public void Scrub_ShortUserName_IsNotMaskedOutsidePackedString()
{
// 'root' is the Basic Auth user name — short, common, and absolutely
// not a secret. It must NOT be masked when it appears in unrelated
// diagnostic text like a file path.
var text = "Config file at /root/.config/scada.conf was not found.";
var result = CredentialRedactor.Scrub(text, "root:hunter2longenoughpwd");
Assert.Contains("/root/.config", result);
}
[Fact]
public void Scrub_TenantId_IsNotMaskedOutsidePackedString()
{
// The tenant id is not secret — only the client secret is. A tenant id
// appearing in unrelated text (e.g. an error-code suffix) must survive.
var text = "Error code tnt-1234567890-abcd reported by upstream";
var result = CredentialRedactor.Scrub(text, "tnt-1234567890-abcd:cli-guid:RealClientSecretLongEnough");
Assert.Contains("tnt-1234567890-abcd", result);
}
[Fact]
public void Scrub_FullPackedCredential_IsAlwaysMaskedRegardlessOfLength()
{
// Even a short packed string must be masked when it appears verbatim —
// that exact appearance can only come from the credential itself.
var text = "Auth bundle was rejected: u:p";
var result = CredentialRedactor.Scrub(text, "u:p");
Assert.DoesNotContain("u:p", result);
}
}
@@ -0,0 +1,81 @@
using System.Text;
using MailKit.Security;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// <summary>
/// NS-016: <see cref="MailKitSmtpClientWrapper.AuthenticateAsync"/> must never
/// silently skip authentication for a misconfigured SMTP config — a missing
/// credential, an unrecognised auth type, or an unparseable Basic credential
/// must be a hard, surfaced error rather than an unauthenticated send.
/// NS-021: the OAuth2 (XOAUTH2) branch must carry a non-empty user identity
/// (the SMTP From address) — an empty user is rejected by M365 with `535 5.7.3`.
/// </summary>
public class MailKitSmtpClientWrapperTests
{
[Fact]
public async Task Authenticate_EmptyCredentials_Throws()
{
// An AuthType of "basic"/"oauth2" with a null/empty Credentials value is a
// misconfigured row; the wrapper used to "return" and send unauthenticated.
var wrapper = new MailKitSmtpClientWrapper();
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("basic", null));
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("oauth2", ""));
}
[Fact]
public async Task Authenticate_UnknownAuthType_Throws()
{
// The switch had cases only for "basic"/"oauth2" and no default — any other
// value (typo, future "ntlm") fell through and sent unauthenticated.
var wrapper = new MailKitSmtpClientWrapper();
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("ntlm", "user:pass"));
}
[Fact]
public async Task Authenticate_BasicCredentialWithoutColon_Throws()
{
// A "basic" credential string that does not split into exactly two parts was
// silently skipped — the connection then sent unauthenticated.
var wrapper = new MailKitSmtpClientWrapper();
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("basic", "nocolon"));
}
[Fact]
public async Task Authenticate_OAuth2WithoutUserName_Throws()
{
// NS-021: passing an OAuth2 access token but no user identity (FromAddress)
// used to construct `new SaslMechanismOAuth2("", credentials)`, which M365
// rejects with `535 5.7.3`. The wrapper now refuses upfront so the caller
// sees a clean configuration error rather than a confusing server reject.
var wrapper = new MailKitSmtpClientWrapper();
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: null));
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: ""));
}
[Fact]
public void XOAuth2InitialResponse_CarriesUserAndBearer()
{
// NS-021 regression guard: independent of the wrapper, prove that MailKit's
// SaslMechanismOAuth2 puts `user=<userName>` into the initial-response bytes
// — i.e. wiring the wrapper to pass `FromAddress` is sufficient to fix the
// M365 handshake. If MailKit ever changes the framing this test will catch it.
var sasl = new SaslMechanismOAuth2("noreply@example.com", "tok-xyz");
var initial = sasl.Challenge(string.Empty);
var asString = Encoding.UTF8.GetString(Convert.FromBase64String(initial));
Assert.Contains("user=noreply@example.com", asString);
Assert.Contains("auth=Bearer tok-xyz", asString);
}
}
@@ -0,0 +1,16 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// <summary>
/// WP-11: Tests for NotificationOptions defaults.
/// </summary>
public class NotificationOptionsTests
{
[Fact]
public void DefaultOptions_HasReasonableDefaults()
{
var options = new NotificationOptions();
Assert.Equal(30, options.ConnectionTimeoutSeconds);
Assert.Equal(5, options.MaxConcurrentConnections);
}
}
@@ -0,0 +1,302 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// <summary>
/// Tests for OAuth2 token flow — token acquisition, caching, and credential parsing.
/// </summary>
public class OAuth2TokenServiceTests
{
private static HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string responseJson)
{
var handler = new MockHttpMessageHandler(statusCode, responseJson);
return new HttpClient(handler);
}
private static IHttpClientFactory CreateMockFactory(HttpClient client)
{
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>()).Returns(client);
return factory;
}
[Fact]
public async Task GetTokenAsync_ReturnsAccessToken_FromTokenEndpoint()
{
var tokenResponse = JsonSerializer.Serialize(new
{
access_token = "mock-access-token-12345",
expires_in = 3600,
token_type = "Bearer"
});
var client = CreateMockHttpClient(HttpStatusCode.OK, tokenResponse);
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
var token = await service.GetTokenAsync("tenant123:client456:secret789");
Assert.Equal("mock-access-token-12345", token);
}
[Fact]
public async Task GetTokenAsync_CachesToken_OnSubsequentCalls()
{
var tokenResponse = JsonSerializer.Serialize(new
{
access_token = "cached-token",
expires_in = 3600,
token_type = "Bearer"
});
var handler = new CountingHttpMessageHandler(HttpStatusCode.OK, tokenResponse);
var client = new HttpClient(handler);
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
var token1 = await service.GetTokenAsync("tenant:client:secret");
var token2 = await service.GetTokenAsync("tenant:client:secret");
Assert.Equal("cached-token", token1);
Assert.Equal("cached-token", token2);
Assert.Equal(1, handler.CallCount); // Only one HTTP call should be made
}
[Fact]
public async Task GetTokenAsync_InvalidCredentialFormat_ThrowsInvalidOperationException()
{
var client = CreateMockHttpClient(HttpStatusCode.OK, "{}");
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.GetTokenAsync("invalid-no-colons"));
}
[Fact]
public async Task GetTokenAsync_HttpFailure_ThrowsHttpRequestException()
{
var client = CreateMockHttpClient(HttpStatusCode.Unauthorized, "Unauthorized");
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
await Assert.ThrowsAsync<HttpRequestException>(
() => service.GetTokenAsync("tenant:client:secret"));
}
// ── NotificationService-006: token cache must be keyed to credential identity ──
[Fact]
public async Task GetTokenAsync_DifferentCredentials_ReturnPerCredentialTokens()
{
// NS-006: the singleton cached a single token ignoring the credentials
// argument, so a second SMTP config with a different tenant/client got the
// first config's token. Each distinct credential must get its own token.
var handler = new PerTenantHttpMessageHandler();
var client = new HttpClient(handler);
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
var tokenA = await service.GetTokenAsync("tenantA:clientA:secretA");
var tokenB = await service.GetTokenAsync("tenantB:clientB:secretB");
Assert.Equal("token-for-tenantA", tokenA);
Assert.Equal("token-for-tenantB", tokenB);
}
[Fact]
public async Task GetTokenAsync_SameCredentials_CachedPerCredential()
{
// NS-006: caching still works — repeated calls with the same credential
// identity make exactly one HTTP call.
var handler = new PerTenantHttpMessageHandler();
var client = new HttpClient(handler);
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
await service.GetTokenAsync("tenantA:clientA:secretA");
await service.GetTokenAsync("tenantA:clientA:secretA");
await service.GetTokenAsync("tenantB:clientB:secretB");
Assert.Equal(2, handler.CallCount); // one per distinct credential, not per call
}
// ── NotificationService-012: token expiry/refresh and concurrent acquisition ──
[Fact]
public async Task GetTokenAsync_ExpiredToken_RefreshesOnNextCall()
{
// NS-012: token expiry/refresh was untested — the cache test used a 3600s
// token so the refresh branch never ran. The service refreshes 60s before
// the stated expiry, so an expires_in of 60 makes the token immediately
// stale and the next call must fetch a fresh one.
var handler = new SequenceHttpMessageHandler(
TokenJson("first-token", expiresIn: 60),
TokenJson("second-token", expiresIn: 3600));
var client = new HttpClient(handler);
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
var token1 = await service.GetTokenAsync("tenant:client:secret");
var token2 = await service.GetTokenAsync("tenant:client:secret");
Assert.Equal("first-token", token1);
Assert.Equal("second-token", token2); // refreshed because the first was already stale
Assert.Equal(2, handler.CallCount);
}
[Fact]
public async Task GetTokenAsync_ConcurrentCalls_MakeExactlyOneHttpRequest()
{
// NS-012: the double-checked-locking path was never exercised. Many callers
// racing for the same uncached credential must collapse to a single token
// fetch, not one HTTP call per caller.
var handler = new SlowCountingHttpMessageHandler(
TokenJson("concurrent-token", expiresIn: 3600), delay: TimeSpan.FromMilliseconds(100));
var client = new HttpClient(handler);
var factory = CreateMockFactory(client);
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
var tasks = Enumerable.Range(0, 20)
.Select(_ => service.GetTokenAsync("tenant:client:secret"))
.ToArray();
var tokens = await Task.WhenAll(tasks);
Assert.All(tokens, t => Assert.Equal("concurrent-token", t));
Assert.Equal(1, handler.CallCount);
}
private static string TokenJson(string accessToken, int expiresIn) =>
JsonSerializer.Serialize(new
{
access_token = accessToken,
expires_in = expiresIn,
token_type = "Bearer"
});
/// <summary>HTTP handler returning a different response per invocation, in order.</summary>
private class SequenceHttpMessageHandler : HttpMessageHandler
{
private readonly string[] _responses;
public int CallCount { get; private set; }
public SequenceHttpMessageHandler(params string[] responses) => _responses = responses;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var body = _responses[Math.Min(CallCount, _responses.Length - 1)];
CallCount++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body)
});
}
}
/// <summary>HTTP handler that delays and counts invocations (thread-safe count).</summary>
private class SlowCountingHttpMessageHandler : HttpMessageHandler
{
private readonly string _response;
private readonly TimeSpan _delay;
private int _callCount;
public int CallCount => _callCount;
public SlowCountingHttpMessageHandler(string response, TimeSpan delay)
{
_response = response;
_delay = delay;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _callCount);
await Task.Delay(_delay, cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(_response)
};
}
}
/// <summary>
/// HTTP handler that returns a distinct access token per tenant id, parsed from
/// the request URL (<c>https://login.microsoftonline.com/{tenantId}/...</c>).
/// </summary>
private class PerTenantHttpMessageHandler : HttpMessageHandler
{
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
var segments = request.RequestUri!.AbsolutePath.Trim('/').Split('/');
var tenantId = segments[0];
var json = JsonSerializer.Serialize(new
{
access_token = $"token-for-{tenantId}",
expires_in = 3600,
token_type = "Bearer"
});
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
});
}
}
/// <summary>
/// Simple mock HTTP handler that returns a fixed response.
/// </summary>
private class MockHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly string _responseContent;
public MockHttpMessageHandler(HttpStatusCode statusCode, string responseContent)
{
_statusCode = statusCode;
_responseContent = responseContent;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_responseContent)
});
}
}
/// <summary>
/// Mock HTTP handler that counts invocations.
/// </summary>
private class CountingHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly string _responseContent;
public int CallCount { get; private set; }
public CountingHttpMessageHandler(HttpStatusCode statusCode, string responseContent)
{
_statusCode = statusCode;
_responseContent = responseContent;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
return Task.FromResult(new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_responseContent)
});
}
}
}
@@ -0,0 +1,132 @@
using System.Net.Sockets;
using MailKit;
using MailKit.Net.Smtp;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// <summary>
/// NS-002/NS-003: Tests for the shared SMTP error classification policy. This
/// policy is correctness-relevant — it decides whether a delivery failure is
/// retried (transient) or returned to the caller (permanent) — and is shared
/// between <see cref="NotificationDeliveryService"/> and the central outbox's
/// <c>EmailNotificationDeliveryAdapter</c>, so it deserves direct coverage.
/// </summary>
public class SmtpErrorClassifierTests
{
[Theory]
[InlineData(421)] // service not available
[InlineData(450)] // mailbox unavailable (busy)
[InlineData(451)] // local error in processing
[InlineData(452)] // insufficient system storage
public void Classify_Smtp4xxCommand_IsTransient(int statusCode)
{
var ex = new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "rejected");
Assert.Equal(SmtpErrorClass.Transient, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
}
[Theory]
[InlineData(500)] // syntax error
[InlineData(550)] // mailbox unavailable (rejected)
[InlineData(553)] // mailbox name not allowed
[InlineData(554)] // transaction failed
public void Classify_Smtp5xxCommand_IsPermanent(int statusCode)
{
var ex = new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "rejected");
Assert.Equal(SmtpErrorClass.Permanent, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
}
[Fact]
public void Classify_SmtpCommandWithUnusualCode_IsUnknown()
{
// A status code outside the 4xx/5xx bands is not classifiable.
var ex = new SmtpCommandException(
SmtpErrorCode.UnexpectedStatusCode, (SmtpStatusCode)250, "ok-ish");
Assert.Equal(SmtpErrorClass.Unknown, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
}
[Fact]
public void Classify_SmtpProtocolException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new SmtpProtocolException("protocol error"), CancellationToken.None));
}
[Fact]
public void Classify_ServiceNotConnectedException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new ServiceNotConnectedException(), CancellationToken.None));
}
[Fact]
public void Classify_SocketException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new SocketException(), CancellationToken.None));
}
[Fact]
public void Classify_TimeoutException_IsTransient()
{
Assert.Equal(
SmtpErrorClass.Transient,
SmtpErrorClassifier.Classify(new TimeoutException(), CancellationToken.None));
}
[Fact]
public void Classify_RequestedCancellation_IsUnknown()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Equal(
SmtpErrorClass.Unknown,
SmtpErrorClassifier.Classify(new OperationCanceledException(), cts.Token));
}
[Fact]
public void Classify_OperationCanceledWithoutRequestedCancellation_IsUnknown()
{
// Not a recognised SMTP error, and cancellation was not requested.
Assert.Equal(
SmtpErrorClass.Unknown,
SmtpErrorClassifier.Classify(new OperationCanceledException(), CancellationToken.None));
}
[Fact]
public void Classify_UnrecognisedException_IsUnknown()
{
Assert.Equal(
SmtpErrorClass.Unknown,
SmtpErrorClassifier.Classify(new InvalidOperationException("bad credential triple"), CancellationToken.None));
}
[Theory]
[InlineData(450, true)]
[InlineData(550, false)]
[InlineData(250, false)]
public void IsTransient_MatchesClassification(int statusCode, bool expectedTransient)
{
var ex = new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "x");
Assert.Equal(expectedTransient, SmtpErrorClassifier.IsTransient(ex, CancellationToken.None));
}
[Fact]
public void IsTransient_RequestedCancellation_IsFalse()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.False(SmtpErrorClassifier.IsTransient(new OperationCanceledException(), cts.Token));
}
}
@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// <summary>
/// NS-005: Tests for parsing the configured SMTP TLS mode into the three-state enum.
/// </summary>
public class SmtpTlsModeParserTests
{
[Theory]
[InlineData("none", SmtpTlsMode.None)]
[InlineData("None", SmtpTlsMode.None)]
[InlineData("NONE", SmtpTlsMode.None)]
[InlineData("starttls", SmtpTlsMode.StartTls)]
[InlineData("StartTLS", SmtpTlsMode.StartTls)]
[InlineData("ssl", SmtpTlsMode.Ssl)]
[InlineData("SSL", SmtpTlsMode.Ssl)]
[InlineData(" starttls ", SmtpTlsMode.StartTls)]
public void Parse_KnownModes_ReturnsExpected(string input, SmtpTlsMode expected)
{
Assert.Equal(expected, SmtpTlsModeParser.Parse(input));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_NullOrEmpty_DefaultsToStartTls(string? input)
{
Assert.Equal(SmtpTlsMode.StartTls, SmtpTlsModeParser.Parse(input));
}
[Theory]
[InlineData("auto")]
[InlineData("tls")]
[InlineData("implicit")]
public void Parse_UnknownMode_Throws(string input)
{
// NS-005: an unknown mode must be rejected, not silently treated as Auto.
Assert.Throws<ArgumentException>(() => SmtpTlsModeParser.Parse(input));
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
</ItemGroup>
</Project>