fix(configuration): resolve High code-review findings (Configuration-001, Configuration-008)
Configuration-001: wrap the EXEC dbo.sp_ValidateDraft call in sp_PublishGeneration in a BEGIN TRY/CATCH ROLLBACK; THROW block so a validation RAISERROR aborts the publish instead of being ignored. Configuration-008: route caller-supplied strings interpolated into ConfigAuditLog.DetailsJson through STRING_ESCAPE(@x, 'json') and emit sp_RollbackToGeneration's @TargetGenerationId as a bare JSON number, closing the JSON-injection / denial-of-operation vector. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -156,6 +156,93 @@ public sealed class StoredProceduresTests
|
||||
ex.Message.ShouldContain("ReleaseReason is required");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Configuration-001: a draft that fails sp_ValidateDraft must NOT be published.
|
||||
/// Before the fix, sp_PublishGeneration ran sp_ValidateDraft, ignored its severity-16 RAISERROR,
|
||||
/// and published the invalid draft anyway.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Publish_aborts_when_ValidateDraft_rejects_the_draft()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "valbypass");
|
||||
|
||||
// Orphan tag — references a DriverInstanceId that does not exist in the generation.
|
||||
Exec(conn, @"INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
|
||||
VALUES (@g, 'tag-1', 'missing-driver', 'X', 'Int32', 'Read', 0, '{}')",
|
||||
("g", draftId));
|
||||
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId)));
|
||||
ex.Message.ShouldContain("unresolved DriverInstanceId");
|
||||
|
||||
var status = Scalar<string>(conn,
|
||||
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draftId));
|
||||
status.ShouldBe("Draft", "an invalid draft must remain Draft after a rejected publish");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Configuration-008: a quote/backslash-bearing value passed to a proc that
|
||||
/// records DetailsJson must produce well-formed JSON (STRING_ESCAPE), not malformed JSON that
|
||||
/// fails the CK_ConfigAuditLog_DetailsJson_IsJson check constraint.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RegisterNodeGenerationApplied_escapes_quotes_in_audit_DetailsJson()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, nodeId, _, draftId) = SeedClusterWithDraft(conn, suffix: "jsonesc");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
// A status string containing a double-quote and a backslash — would break naive concatenation.
|
||||
const string hostileStatus = "Applied\"; \\evil";
|
||||
|
||||
Should.NotThrow(() =>
|
||||
Exec(conn, "EXEC dbo.sp_RegisterNodeGenerationApplied @NodeId=@n, @GenerationId=@g, @Status=@s",
|
||||
("n", nodeId), ("g", draftId), ("s", hostileStatus)));
|
||||
|
||||
var detailsJson = Scalar<string>(conn,
|
||||
@"SELECT TOP 1 DetailsJson FROM dbo.ConfigAuditLog
|
||||
WHERE EventType = 'NodeApplied' AND NodeId = @n ORDER BY AuditId DESC",
|
||||
("n", nodeId));
|
||||
detailsJson.ShouldNotBeNull();
|
||||
|
||||
// Round-trip: ISJSON must accept it, and JSON_VALUE must recover the exact original string.
|
||||
var isValidJson = Scalar<int>(conn, "SELECT ISJSON(@j)", ("j", detailsJson));
|
||||
isValidJson.ShouldBe(1, "DetailsJson must be well-formed JSON");
|
||||
var recovered = Scalar<string>(conn, "SELECT JSON_VALUE(@j, '$.status')", ("j", detailsJson));
|
||||
recovered.ShouldBe(hostileStatus, "the escaped value must round-trip unchanged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Configuration-008 covering sp_ReleaseExternalIdReservation's @Kind/@Value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReleaseReservation_escapes_quotes_in_audit_DetailsJson()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
|
||||
// Seed an active reservation with a hostile Value, then release it.
|
||||
const string hostileValue = "Z\"100\\";
|
||||
Exec(conn,
|
||||
@"INSERT dbo.ExternalIdReservation (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
|
||||
VALUES ('ZTag', @v, NEWID(), 'cluster-resv-esc', SUSER_SNAME(), SYSUTCDATETIME());",
|
||||
("v", hostileValue));
|
||||
|
||||
Should.NotThrow(() =>
|
||||
Exec(conn, "EXEC dbo.sp_ReleaseExternalIdReservation @Kind='ZTag', @Value=@v, @ReleaseReason='cleanup'",
|
||||
("v", hostileValue)));
|
||||
|
||||
var detailsJson = Scalar<string>(conn,
|
||||
@"SELECT TOP 1 DetailsJson FROM dbo.ConfigAuditLog
|
||||
WHERE EventType = 'ExternalIdReleased' ORDER BY AuditId DESC");
|
||||
var isValidJson = Scalar<int>(conn, "SELECT ISJSON(@j)", ("j", detailsJson));
|
||||
isValidJson.ShouldBe(1, "DetailsJson must be well-formed JSON");
|
||||
var recovered = Scalar<string>(conn, "SELECT JSON_VALUE(@j, '$.value')", ("j", detailsJson));
|
||||
recovered.ShouldBe(hostileValue, "the escaped value must round-trip unchanged");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Creates a cluster, one node, one credential bound to the current SUSER_SNAME(), and an empty Draft.</summary>
|
||||
|
||||
Reference in New Issue
Block a user