feat(deploy): reject Sql Query tags naming undeclared queries; warn on Writable/writable disagreement (lmxopcua#781)
v2-ci / unit-tests (push) Has been cancelled

Item 5: DraftValidator now emits SqlQueryNotDeclared (always blocking) for a Query tag whose
query is absent from the merged driver config's queries map, via the driver's own tag parse.
Item 3: StartDeployment appends a non-blocking warning to the accepted message when the Tag
entity's AccessLevel and the Sql blob's writable flag disagree.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 18:08:39 -05:00
parent 7d989fb529
commit 241318e7e5
6 changed files with 239 additions and 0 deletions
@@ -124,4 +124,49 @@ public sealed class AdminOperationsActorTagConfigGateTests : ControlPlaneActorTe
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
ExpectMsg<StartDeploymentResult>(PresenceBudget).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
}
// lmxopcua#781 item 3 — through the actor, in Error mode too: the writable mismatch is a warning, never a reject.
private const string SqlQueries = "{\"queries\":{\"line\":{\"sql\":\"SELECT 1 AS v\",\"keyColumns\":[]}}}";
private StartDeploymentResult DeploySql(bool writableInBlob, TagAccessLevel access)
{
var dbFactory = NewInMemoryDbFactory();
using (var db = dbFactory.CreateDbContext())
{
db.DriverInstances.Add(new DriverInstance
{ DriverInstanceId = "d-1", ClusterId = "", Name = "drv", DriverType = "Sql", DriverConfig = SqlQueries });
db.Devices.Add(new Device { DeviceId = "dev-1", DriverInstanceId = "d-1", Name = "dev", DeviceConfig = "{}" });
db.Tags.Add(new Tag
{
TagId = "tag-1", DeviceId = "dev-1", Name = "flow", DataType = "Int32", AccessLevel = access,
TagConfig = "{\"model\":\"Query\",\"query\":\"line\",\"column\":\"v\",\"writable\":"
+ (writableInBlob ? "true" : "false") + "}",
});
db.SaveChanges();
}
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Error));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
return ExpectMsg<StartDeploymentResult>(PresenceBudget);
}
[Fact]
public void Sql_writable_mismatch_is_accepted_with_a_warning_even_in_error_mode()
{
var reply = DeploySql(writableInBlob: true, TagAccessLevel.Read);
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
reply.Message.ShouldNotBeNull();
reply.Message.ShouldContain("tag-1");
reply.Message.ShouldContain("writable");
}
[Fact]
public void Sql_writable_agreement_adds_no_warning()
{
var reply = DeploySql(writableInBlob: true, TagAccessLevel.ReadWrite);
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
reply.Message.ShouldBeNull();
}
}