fix(transport): carry Transport + OAuth2 authority/scope on SmtpConfigDto (OAuth2 fields were silently dropped)
This commit is contained in:
@@ -266,6 +266,12 @@ public sealed class ArtifactDiff
|
|||||||
AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
|
AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
|
||||||
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
||||||
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
||||||
|
// Non-sensitive delivery metadata: diffed by value (unlike the credential,
|
||||||
|
// which is presence-only below). Both sides null on pre-EWS bundles, so a
|
||||||
|
// legacy configuration still classifies Identical.
|
||||||
|
AddIfDifferent(changes, "OAuth2Authority", existing.OAuth2Authority, incoming.OAuth2Authority);
|
||||||
|
AddIfDifferent(changes, "OAuth2Scope", existing.OAuth2Scope, incoming.OAuth2Scope);
|
||||||
|
AddIfDifferent(changes, "Transport", existing.Transport, incoming.Transport);
|
||||||
|
|
||||||
var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
|
var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
|
||||||
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
|
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
|
||||||
|
|||||||
@@ -3437,6 +3437,11 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
target.MaxConcurrentConnections = dto.MaxConcurrentConnections;
|
target.MaxConcurrentConnections = dto.MaxConcurrentConnections;
|
||||||
target.MaxRetries = dto.MaxRetries;
|
target.MaxRetries = dto.MaxRetries;
|
||||||
target.RetryDelay = dto.RetryDelay;
|
target.RetryDelay = dto.RetryDelay;
|
||||||
|
// Non-sensitive delivery metadata; null on a bundle written before these
|
||||||
|
// were carried, which is the entity's "provider defaults / Smtp" state.
|
||||||
|
target.OAuth2Authority = dto.OAuth2Authority;
|
||||||
|
target.OAuth2Scope = dto.OAuth2Scope;
|
||||||
|
target.Transport = dto.Transport;
|
||||||
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
|
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,6 +285,12 @@ public sealed record NotificationRecipientDto(
|
|||||||
// WhenWritingNull policy keeps it out of email-only bundles' JSON.
|
// WhenWritingNull policy keeps it out of email-only bundles' JSON.
|
||||||
string? PhoneNumber = null);
|
string? PhoneNumber = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An email delivery configuration (the <c>SmtpConfiguration</c> entity). Every
|
||||||
|
/// non-sensitive field is carried directly and only the credential (SMTP password
|
||||||
|
/// or OAuth2 client secret) rides inside <see cref="Secrets"/>, so a future
|
||||||
|
/// "share without secrets" export can drop it as a unit.
|
||||||
|
/// </summary>
|
||||||
public sealed record SmtpConfigDto(
|
public sealed record SmtpConfigDto(
|
||||||
string Host,
|
string Host,
|
||||||
int Port,
|
int Port,
|
||||||
@@ -295,7 +301,17 @@ public sealed record SmtpConfigDto(
|
|||||||
int MaxConcurrentConnections,
|
int MaxConcurrentConnections,
|
||||||
int MaxRetries,
|
int MaxRetries,
|
||||||
TimeSpan RetryDelay,
|
TimeSpan RetryDelay,
|
||||||
SecretsBlock? Secrets);
|
SecretsBlock? Secrets,
|
||||||
|
// OAuth2 token-endpoint authority and scope: endpoint URLs, not secrets, so
|
||||||
|
// they belong on the public DTO rather than in the SecretsBlock. Trailing +
|
||||||
|
// nullable so a bundle written before these were carried deserializes them as
|
||||||
|
// null (= provider defaults); WhenWritingNull keeps them out of the JSON of
|
||||||
|
// configurations that don't set them.
|
||||||
|
string? OAuth2Authority = null,
|
||||||
|
string? OAuth2Scope = null,
|
||||||
|
// Delivery transport ("Smtp" or "Ews"); null means Smtp. Trailing + nullable
|
||||||
|
// for the same additive-evolution reason — no bundleFormatVersion bump.
|
||||||
|
string? Transport = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An SMS provider configuration (the <c>SmsConfiguration</c> entity). Mirrors
|
/// An SMS provider configuration (the <c>SmsConfiguration</c> entity). Mirrors
|
||||||
|
|||||||
@@ -186,7 +186,12 @@ public sealed class EntitySerializer
|
|||||||
MaxConcurrentConnections: smtp.MaxConcurrentConnections,
|
MaxConcurrentConnections: smtp.MaxConcurrentConnections,
|
||||||
MaxRetries: smtp.MaxRetries,
|
MaxRetries: smtp.MaxRetries,
|
||||||
RetryDelay: smtp.RetryDelay,
|
RetryDelay: smtp.RetryDelay,
|
||||||
Secrets: secrets);
|
Secrets: secrets,
|
||||||
|
// Non-sensitive delivery metadata: the OAuth2 endpoint pair and
|
||||||
|
// the Smtp/Ews transport selector travel as plain DTO fields.
|
||||||
|
OAuth2Authority: smtp.OAuth2Authority,
|
||||||
|
OAuth2Scope: smtp.OAuth2Scope,
|
||||||
|
Transport: smtp.Transport);
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
// Inbound API keys are not transported between environments:
|
// Inbound API keys are not transported between environments:
|
||||||
// the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
|
// the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
|
||||||
@@ -486,6 +491,11 @@ public sealed class EntitySerializer
|
|||||||
MaxConcurrentConnections = dto.MaxConcurrentConnections,
|
MaxConcurrentConnections = dto.MaxConcurrentConnections,
|
||||||
MaxRetries = dto.MaxRetries,
|
MaxRetries = dto.MaxRetries,
|
||||||
RetryDelay = dto.RetryDelay,
|
RetryDelay = dto.RetryDelay,
|
||||||
|
// Null on a bundle written before these fields were carried, which
|
||||||
|
// is exactly the entity's "use the provider defaults / Smtp" state.
|
||||||
|
OAuth2Authority = dto.OAuth2Authority,
|
||||||
|
OAuth2Scope = dto.OAuth2Scope,
|
||||||
|
Transport = dto.Transport,
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -633,6 +633,12 @@ public sealed class RoundTripEquivalenceTests : IDisposable
|
|||||||
MaxConcurrentConnections = 4,
|
MaxConcurrentConnections = 4,
|
||||||
MaxRetries = 9,
|
MaxRetries = 9,
|
||||||
RetryDelay = TimeSpan.FromSeconds(33),
|
RetryDelay = TimeSpan.FromSeconds(33),
|
||||||
|
// Non-sensitive delivery metadata. Left null, these three were
|
||||||
|
// silently dropped by the bundle without the guard noticing
|
||||||
|
// (null == null), so they are seeded explicitly here.
|
||||||
|
Transport = "Ews",
|
||||||
|
OAuth2Authority = "https://login.example.test/token",
|
||||||
|
OAuth2Scope = "https://mail.example.test/.default",
|
||||||
});
|
});
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -938,4 +938,118 @@ public sealed class EntitySerializerTests
|
|||||||
var rt = new EntitySerializer().FromBundleContent(dto);
|
var rt = new EntitySerializer().FromBundleContent(dto);
|
||||||
Assert.Null(Assert.Single(rt.SmsConfigurations).AuthToken);
|
Assert.Null(Assert.Single(rt.SmsConfigurations).AuthToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- EWS: Transport + the pre-existing OAuth2 authority/scope drop ---------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_smtp_config_preserves_transport_and_oauth2_fields()
|
||||||
|
{
|
||||||
|
// Transport is the EWS selector; OAuth2Authority/OAuth2Scope predate it and
|
||||||
|
// were silently dropped by the bundle (they were never on the DTO). All
|
||||||
|
// three are non-sensitive endpoint metadata, so they ride as plain DTO
|
||||||
|
// fields — only Credentials belongs in the SecretsBlock.
|
||||||
|
var smtp = new SmtpConfiguration("https://mail.example.test/EWS/Exchange.asmx", "Basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 587,
|
||||||
|
Credentials = "user:p@ssw0rd",
|
||||||
|
TlsMode = "StartTLS",
|
||||||
|
ConnectionTimeoutSeconds = 25,
|
||||||
|
MaxConcurrentConnections = 3,
|
||||||
|
MaxRetries = 4,
|
||||||
|
RetryDelay = TimeSpan.FromSeconds(90),
|
||||||
|
Transport = "Ews",
|
||||||
|
OAuth2Authority = "https://login.example.test/token",
|
||||||
|
OAuth2Scope = "https://mail.example.test/.default",
|
||||||
|
};
|
||||||
|
var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };
|
||||||
|
|
||||||
|
var sut = new EntitySerializer();
|
||||||
|
var dto = sut.ToBundleContent(aggregate);
|
||||||
|
|
||||||
|
var dtoSmtp = Assert.Single(dto.SmtpConfigs);
|
||||||
|
Assert.Equal("Ews", dtoSmtp.Transport);
|
||||||
|
Assert.Equal("https://login.example.test/token", dtoSmtp.OAuth2Authority);
|
||||||
|
Assert.Equal("https://mail.example.test/.default", dtoSmtp.OAuth2Scope);
|
||||||
|
|
||||||
|
var rt = sut.FromBundleContent(dto);
|
||||||
|
|
||||||
|
var rtSmtp = Assert.Single(rt.SmtpConfigurations);
|
||||||
|
Assert.Equal("Ews", rtSmtp.Transport);
|
||||||
|
Assert.Equal("https://login.example.test/token", rtSmtp.OAuth2Authority);
|
||||||
|
Assert.Equal("https://mail.example.test/.default", rtSmtp.OAuth2Scope);
|
||||||
|
// The pre-existing fields still survive alongside the new ones.
|
||||||
|
Assert.Equal("https://mail.example.test/EWS/Exchange.asmx", rtSmtp.Host);
|
||||||
|
Assert.Equal(587, rtSmtp.Port);
|
||||||
|
Assert.Equal("StartTLS", rtSmtp.TlsMode);
|
||||||
|
Assert.Equal("user:p@ssw0rd", rtSmtp.Credentials);
|
||||||
|
Assert.Equal(25, rtSmtp.ConnectionTimeoutSeconds);
|
||||||
|
Assert.Equal(3, rtSmtp.MaxConcurrentConnections);
|
||||||
|
Assert.Equal(4, rtSmtp.MaxRetries);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(90), rtSmtp.RetryDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_smtp_config_without_transport_or_oauth2_fields_yields_nulls()
|
||||||
|
{
|
||||||
|
// Backward-compat at the DTO level (mirrors the PhoneNumber precedent): an
|
||||||
|
// SMTP DTO built without the three trailing params — as an old bundle's JSON
|
||||||
|
// deserializes, since those properties are simply absent — imports with all
|
||||||
|
// three null, i.e. legacy SMTP delivery and provider-default OAuth2 endpoints.
|
||||||
|
var dto = new BundleContentDto(
|
||||||
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||||
|
Templates: Array.Empty<TemplateDto>(),
|
||||||
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||||
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
||||||
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||||
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||||
|
SmtpConfigs: new[]
|
||||||
|
{
|
||||||
|
new SmtpConfigDto(
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: 587,
|
||||||
|
AuthType: "Basic",
|
||||||
|
FromAddress: "noreply@example.com",
|
||||||
|
TlsMode: "StartTLS",
|
||||||
|
ConnectionTimeoutSeconds: 30,
|
||||||
|
MaxConcurrentConnections: 2,
|
||||||
|
MaxRetries: 3,
|
||||||
|
RetryDelay: TimeSpan.FromSeconds(60),
|
||||||
|
Secrets: null),
|
||||||
|
},
|
||||||
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||||
|
|
||||||
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||||
|
|
||||||
|
var smtp = Assert.Single(aggregate.SmtpConfigurations);
|
||||||
|
Assert.Null(smtp.Transport);
|
||||||
|
Assert.Null(smtp.OAuth2Authority);
|
||||||
|
Assert.Null(smtp.OAuth2Scope);
|
||||||
|
Assert.Equal("smtp.example.com", smtp.Host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmtpConfigDto_omits_null_transport_and_oauth2_fields_from_json()
|
||||||
|
{
|
||||||
|
// WhenWritingNull keeps the three new properties out of a plain-SMTP
|
||||||
|
// bundle's JSON entirely, so the wire shape is unchanged for existing
|
||||||
|
// exports (additive evolution — no bundleFormatVersion bump).
|
||||||
|
var dto = new SmtpConfigDto(
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: 587,
|
||||||
|
AuthType: "Basic",
|
||||||
|
FromAddress: "noreply@example.com",
|
||||||
|
TlsMode: "StartTLS",
|
||||||
|
ConnectionTimeoutSeconds: 30,
|
||||||
|
MaxConcurrentConnections: 2,
|
||||||
|
MaxRetries: 3,
|
||||||
|
RetryDelay: TimeSpan.FromSeconds(60),
|
||||||
|
Secrets: null);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(dto, BundleJsonOptions.Default);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("Transport", json, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("OAuth2Authority", json, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("OAuth2Scope", json, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user