diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs index 18177ec4..bec219f7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs @@ -5,6 +5,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; +/// +/// Pins against its documented contract: what it accepts, what +/// it captures verbatim, and — the larger half — everything it must reject. Rejection is the whole +/// safety story here: a blob the parser waves through becomes an OPC UA node the driver cannot feed, or (for +/// a typo'd enum) a node fed from the wrong model while publishing a confident Good (R2-11). +/// public class SqlEquipmentTagParserTests { [Fact] @@ -27,4 +33,167 @@ public class SqlEquipmentTagParserTests => SqlEquipmentTagParser.TryParse( """{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""", "raw", out _).ShouldBeFalse(); + + // ---- WideRow: the where-pair selector ---- + + [Fact] + public void TryParse_WideRow_wherePair_readsColumnAndSelector_andLeavesTopByTimestampUnset() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"dbo.LatestStatus","columnName":"oven_temp", + "timestampColumn":"sample_ts","rowSelector":{"whereColumn":"station_id","whereValue":"7"}} + """; + + SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.Table.ShouldBe("dbo.LatestStatus"); + def.ColumnName.ShouldBe("oven_temp"); + def.TimestampColumn.ShouldBe("sample_ts"); + def.RowSelectorColumn.ShouldBe("station_id"); + def.RowSelectorValue.ShouldBe("7"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp"); + // Wide-row carries none of the key-value fields. + def.KeyColumn.ShouldBeNull(); + def.KeyValue.ShouldBeNull(); + def.ValueColumn.ShouldBeNull(); + def.DeclaredType.ShouldBeNull(); // no "type" authored ⇒ infer from column metadata + } + + [Theory] + [InlineData("7", "7")] // JSON number → its raw token, so the reader binds one string either way + [InlineData("\"7\"", "7")] // JSON string → its contents + [InlineData("true", "true")] // JSON bool → its raw token (lower-case, as written) + public void TryParse_WideRow_whereValue_isCapturedFromEitherAStringOrABareScalar( + string authored, string expected) + { + var json = """{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id","whereValue":""" + + authored + "}}"; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + def.RowSelectorValue.ShouldBe(expected); + } + + [Fact] + public void TryParse_WideRow_keepsAMaliciousWhereValueVerbatim() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"T","columnName":"c", + "rowSelector":{"whereColumn":"station_id","whereValue":"7'); DROP TABLE x --"}} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + // Captured as-is: the planner BINDS it as @w, so sanitising here would only corrupt legal values. + def.RowSelectorValue.ShouldBe("7'); DROP TABLE x --"); + } + + // ---- WideRow: the topByTimestamp selector ---- + + [Fact] + public void TryParse_WideRow_topByTimestamp_readsTheOrderColumn_andLeavesTheWherePairUnset() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"dbo.Readings","columnName":"oven_temp", + "rowSelector":{"topByTimestamp":"sample_ts"},"type":"Float32"} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + def.RowSelectorTopByTimestamp.ShouldBe("sample_ts"); + def.RowSelectorColumn.ShouldBeNull(); + def.RowSelectorValue.ShouldBeNull(); + def.DeclaredType.ShouldBe(DriverDataType.Float32); + } + + [Fact] + public void TryParse_WideRow_bothSelectorsAuthored_keepsOnlyTheWherePair() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"T","columnName":"c", + "rowSelector":{"whereColumn":"station_id","whereValue":"7","topByTimestamp":"sample_ts"}} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + // SqlGroupPlanner relies on exactly one selector being populated — it branches on the where-pair + // first and would otherwise silently drop the topByTimestamp ordering from a group's key. + def.RowSelectorColumn.ShouldBe("station_id"); + def.RowSelectorValue.ShouldBe("7"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + } + + // ---- WideRow rejections ---- + + [Theory] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":" ","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":42,"rowSelector":{"topByTimestamp":"ts"}}""")] + public void TryParse_WideRow_withoutAUsableColumnName_rejectsTag(string json) + => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + + [Theory] + // No rowSelector object at all. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c"}""")] + // Present but empty. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{}}""")] + // Half a where-pair is not a selector: a whereColumn with no value… + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id"}}""")] + // …and a whereValue with no column. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereValue":"7"}}""")] + // rowSelector authored as the wrong JSON kind is ignored, leaving no selector. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":"station_id=7"}""")] + public void TryParse_WideRow_withNoRowSelector_rejectsTag(string json) + { + // Accepting one would author a node whose query cannot say WHICH row it reads. + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + // ---- recognition + shared rejections ---- + + [Theory] + [InlineData("""["not","an","object"]""")] + [InlineData(""""just a string"""")] + [InlineData("42")] + [InlineData("null")] + [InlineData("not json at all")] + [InlineData("")] + [InlineData(" ")] + public void TryParse_aNonObjectRoot_rejectsTag_withoutThrowing(string reference) + => SqlEquipmentTagParser.TryParse(reference, "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_aBlobWithNeitherADriverMarkerNorAModelDiscriminator_rejectsTag() + { + // Otherwise another driver's TagConfig that happens to carry a "table" would be served as a Sql tag. + const string json = + """{"table":"dbo.TagValues","keyColumn":"tag_name","keyValue":"v","valueColumn":"num_value"}"""; + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + [Fact] + public void TryParse_aBlobBelongingToAnotherDriver_rejectsTag() + => SqlEquipmentTagParser.TryParse( + """{"driver":"Modbus","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""", + "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_theDeferredQueryModel_rejectsTag() + { + // Design §5.4 / P3. Serving it would materialise a node the runtime has no way to read. + const string json = """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}"""; + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + [Theory] + [InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"KeyValue","table":" ","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")] + public void TryParse_withoutATable_rejectsTag(string json) + => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_invalidTypeEnum_rejectsTag() + => SqlEquipmentTagParser.TryParse( + """{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""", + "raw", out _).ShouldBeFalse(); }