f88eb49bd1
Stage 1b. Still no enforcement wired — this adds the credential model the identity cutover (stage 2) will read. ApiKey holds one principal per key, deliberately. The reason to separate keys from principals is so many keys share a grant set and rotating a key never touches an ACL row, and that holds with one principal per key since nothing stops two keys naming the same principal. A key belonging to several principals at once buys little and costs a join table. UserAuthorizationState.Principals stays a list, so adding it later needs no ACL-side change. ApiKeySecret is the security-critical half and is pure, so it is tested on its own: otua_<keyId>_<secret>, peppered HMAC-SHA256, FixedTimeEquals. Notes why a plain HMAC rather than an iterated KDF (the secret is 32 bytes of CSPRNG, and the call runs inline on session activation, which the SDK serializes server-wide behind its SessionManager event lock), and fails closed on an unset pepper rather than hashing under an empty key. Found and fixed a bug in my own first draft: the key id was url-safe base64, whose alphabet maps '/' to '_' — the very character TryParse splits on. Roughly one key in five would have had its id truncated at a random position, an intermittent auth failure reproducing only for SOME keys. The id is hex now, and the guard is falsified: reverting the alphabet reds both The_generated_key_id_never_contains_the_separator and, as a bonus signal, Two_generated_keys_never_collide, because truncated ids collide. ApiKeyId is NOT NULL, unlike the nullable NodeAclId precedent whose filtered unique index would admit two NULL ids. Fine for a config row, not for a credentials lookup key. OpcUaServer.csproj now references Core. Until now it did not, so IPermissionEvaluator was literally uncallable from the node manager — the place enforcement has to happen. No new transitive package surface. Schema doc updated for both, and its NodeAcl block flagged for pre-existing drift (it still documents the GenerationId column v3 dropped) without fixing it here. Core 247, OpcUaServer 364, Configuration 128 — all green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
226 lines
9.6 KiB
C#
226 lines
9.6 KiB
C#
using Microsoft.Data.SqlClient;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
|
|
|
/// <summary>
|
|
/// Introspects the applied schema via <c>sys.*</c> / <c>INFORMATION_SCHEMA.*</c> to confirm that
|
|
/// the Fluent-API DbContext produces the exact structure specified in
|
|
/// <c>docs/v2/config-db-schema.md</c>. Any change here is a deliberate decision — update the
|
|
/// schema doc first, then these tests.
|
|
/// </summary>
|
|
[Trait("Category", "SchemaCompliance")]
|
|
[Collection(nameof(SchemaComplianceCollection))]
|
|
public sealed class SchemaComplianceTests
|
|
{
|
|
private readonly SchemaComplianceFixture _fixture;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="SchemaComplianceTests"/> class.</summary>
|
|
/// <param name="fixture">The database schema compliance fixture.</param>
|
|
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
|
|
|
/// <summary>Verifies that all expected tables exist in the schema.</summary>
|
|
[Fact]
|
|
public void All_expected_tables_exist()
|
|
{
|
|
var expected = new[]
|
|
{
|
|
"ServerCluster", "ClusterNode", "ClusterNodeCredential",
|
|
"ConfigAuditLog",
|
|
"UnsArea", "UnsLine",
|
|
// v3 Raw-tree tables (Namespace retired; RawFolder/TagGroup/UnsTagReference added)
|
|
"RawFolder", "TagGroup", "UnsTagReference",
|
|
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
|
|
// ApiKey added in Gitea #520 — the OPC UA data plane is machine-to-machine, so a consuming
|
|
// system authenticates with a key resolving to a Principal that NodeAcl rows grant to.
|
|
"NodeAcl", "ApiKey", "ExternalIdReservation",
|
|
// DriverHostStatus was dropped in Gitea #521 — nothing ever wrote a row, and per-cluster
|
|
// mesh Phase 4 made the publisher its entity doc described unbuildable (a driver-only node
|
|
// has no ConfigDb connection). Per-host connectivity now rides DriverHealthChanged to /hosts.
|
|
// DriverInstanceResilienceStatus dropped in Gitea #524 — same defect as DriverHostStatus
|
|
// above: no writer, no reader, and Phase 4 leaves a driver-only node with no ConfigDb to
|
|
// write from. Live resilience counters ride the driver-resilience-status telemetry channel.
|
|
"LdapGroupRoleMapping",
|
|
// v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables.
|
|
// ScriptedAlarmState was dropped in per-cluster mesh Phase 4 (Task 9) — scripted-alarm
|
|
// condition state moved to the replicated LocalDb store.
|
|
"Script", "ScriptedAlarm",
|
|
// v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment)
|
|
"Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys",
|
|
};
|
|
|
|
var actual = QueryStrings(@"
|
|
SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;").ToHashSet();
|
|
|
|
foreach (var table in expected)
|
|
actual.ShouldContain(table, $"missing table: {table}");
|
|
|
|
actual.Count.ShouldBe(expected.Length);
|
|
}
|
|
|
|
/// <summary>Verifies that filtered unique indexes match the schema specification.</summary>
|
|
[Fact]
|
|
public void Filtered_unique_indexes_match_schema_spec()
|
|
{
|
|
// (IndexName, Filter, Uniqueness) tuples — from OtOpcUaConfigDbContext Fluent config.
|
|
// Kept here as a spec-level source of truth; the test ensures EF generated them verbatim.
|
|
var expected = new[]
|
|
{
|
|
("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"),
|
|
("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"),
|
|
// v3 sibling-name filtered unique indexes over nullable parent columns.
|
|
("UX_RawFolder_Parent_Name", "([ParentRawFolderId] IS NOT NULL)"),
|
|
("UX_RawFolder_ClusterRoot_Name", "([ParentRawFolderId] IS NULL)"),
|
|
("UX_DriverInstance_Folder_Name", "([RawFolderId] IS NOT NULL)"),
|
|
("UX_DriverInstance_ClusterRoot_Name", "([RawFolderId] IS NULL)"),
|
|
("UX_TagGroup_Parent_Name", "([ParentTagGroupId] IS NOT NULL)"),
|
|
("UX_TagGroup_DeviceRoot_Name", "([ParentTagGroupId] IS NULL)"),
|
|
("UX_Tag_Group_Name", "([TagGroupId] IS NOT NULL)"),
|
|
("UX_Tag_Device_Name", "([TagGroupId] IS NULL)"),
|
|
};
|
|
|
|
var rows = QueryRows(@"
|
|
SELECT i.name AS IndexName, i.filter_definition
|
|
FROM sys.indexes i
|
|
WHERE i.is_unique = 1 AND i.has_filter = 1;",
|
|
r => (Name: r.GetString(0), Filter: r.IsDBNull(1) ? null : r.GetString(1)));
|
|
|
|
foreach (var (name, filter) in expected)
|
|
{
|
|
var match = rows.FirstOrDefault(x => x.Name == name);
|
|
match.Name.ShouldBe(name, $"missing filtered unique index: {name}");
|
|
NormalizeFilter(match.Filter).ShouldBe(NormalizeFilter(filter),
|
|
$"filter predicate for {name} drifted");
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that check constraints match the schema specification.</summary>
|
|
[Fact]
|
|
public void Check_constraints_match_schema_spec()
|
|
{
|
|
var expected = new[]
|
|
{
|
|
"CK_ServerCluster_RedundancyMode_NodeCount",
|
|
"CK_Device_DeviceConfig_IsJson",
|
|
"CK_DriverInstance_DriverConfig_IsJson",
|
|
"CK_DriverInstance_ResilienceConfig_IsJson",
|
|
"CK_PollGroup_IntervalMs_Min",
|
|
"CK_Tag_TagConfig_IsJson",
|
|
"CK_ConfigAuditLog_DetailsJson_IsJson",
|
|
"CK_ConfigEdit_FieldsJson_IsJson",
|
|
};
|
|
|
|
var actual = QueryStrings("SELECT name FROM sys.check_constraints ORDER BY name;").ToHashSet();
|
|
|
|
foreach (var ck in expected)
|
|
actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
|
|
}
|
|
|
|
/// <summary>Verifies that JSON check constraints use the ISJSON function.</summary>
|
|
[Fact]
|
|
public void Json_check_constraints_use_IsJson_function()
|
|
{
|
|
var rows = QueryRows(@"
|
|
SELECT cc.name, cc.definition
|
|
FROM sys.check_constraints cc
|
|
WHERE cc.name LIKE 'CK_%_IsJson';",
|
|
r => (Name: r.GetString(0), Definition: r.GetString(1)));
|
|
|
|
rows.Count.ShouldBeGreaterThanOrEqualTo(4);
|
|
|
|
foreach (var (name, definition) in rows)
|
|
definition.ShouldContain("isjson(", Case.Insensitive,
|
|
$"{name} definition does not call ISJSON: {definition}");
|
|
}
|
|
|
|
/// <summary>Verifies that the Deployment Status column exists.</summary>
|
|
[Fact]
|
|
public void Deployment_Status_column_exists()
|
|
{
|
|
// v2 replaces ConfigGeneration with Deployment. Storage type for Status is design-defined
|
|
// (currently int via the EF enum mapping); the invariant we care about is that the
|
|
// column is present.
|
|
var rows = QueryRows(@"
|
|
SELECT c.COLUMN_NAME, c.DATA_TYPE
|
|
FROM INFORMATION_SCHEMA.COLUMNS c
|
|
WHERE c.TABLE_NAME = 'Deployment' AND c.COLUMN_NAME = 'Status';",
|
|
r => (Column: r.GetString(0), Type: r.GetString(1)));
|
|
|
|
rows.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that Equipment carries OPC 4.0 10 identity fields.</summary>
|
|
[Fact]
|
|
public void Equipment_carries_Opc40010_identity_fields()
|
|
{
|
|
var columns = QueryStrings(@"
|
|
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment';")
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var col in new[]
|
|
{
|
|
"EquipmentUuid", "EquipmentId", "MachineCode", "ZTag", "SAPID",
|
|
"Manufacturer", "Model", "SerialNumber",
|
|
})
|
|
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
|
|
}
|
|
|
|
/// <summary>Verifies the retired v2 tables (Namespace, EquipmentImportBatch/Row) are absent.</summary>
|
|
[Fact]
|
|
public void Retired_v2_tables_are_absent()
|
|
{
|
|
var tables = QueryStrings("SELECT name FROM sys.tables;").ToHashSet();
|
|
tables.ShouldNotContain("Namespace");
|
|
tables.ShouldNotContain("EquipmentImportBatch");
|
|
tables.ShouldNotContain("EquipmentImportRow");
|
|
}
|
|
|
|
/// <summary>Verifies the new v3 Raw-tree tables each carry their logical-id unique index.</summary>
|
|
[Fact]
|
|
public void V3_raw_tree_tables_have_logical_id_unique_indexes()
|
|
{
|
|
var expected = new[]
|
|
{
|
|
("RawFolder", "UX_RawFolder_LogicalId"),
|
|
("TagGroup", "UX_TagGroup_LogicalId"),
|
|
("UnsTagReference", "UX_UnsTagReference_LogicalId"),
|
|
};
|
|
|
|
foreach (var (table, index) in expected)
|
|
{
|
|
var indexes = QueryStrings($@"
|
|
SELECT i.name
|
|
FROM sys.indexes i
|
|
JOIN sys.tables t ON i.object_id = t.object_id
|
|
WHERE t.name = '{table}' AND i.is_unique = 1;").ToHashSet();
|
|
indexes.ShouldContain(index, $"{table} missing unique index {index}");
|
|
}
|
|
}
|
|
|
|
private List<string> QueryStrings(string sql)
|
|
{
|
|
using var conn = _fixture.OpenConnection();
|
|
using var cmd = new SqlCommand(sql, conn);
|
|
using var reader = cmd.ExecuteReader();
|
|
var result = new List<string>();
|
|
while (reader.Read())
|
|
result.Add(reader.GetString(0));
|
|
return result;
|
|
}
|
|
|
|
private List<T> QueryRows<T>(string sql, Func<SqlDataReader, T> project)
|
|
{
|
|
using var conn = _fixture.OpenConnection();
|
|
using var cmd = new SqlCommand(sql, conn);
|
|
using var reader = cmd.ExecuteReader();
|
|
var result = new List<T>();
|
|
while (reader.Read())
|
|
result.Add(project(reader));
|
|
return result;
|
|
}
|
|
|
|
private static string? NormalizeFilter(string? filter) =>
|
|
filter?.Replace(" ", string.Empty).Replace("(", string.Empty).Replace(")", string.Empty).ToLowerInvariant();
|
|
}
|