/* Licence verification gate for the .NET 10 port (devdocs/modernization/NET10-PORT.md, section 0). Runs every v8 row of Sockeye's alicense fixture through AyaNova's own License.Parse, by reflection, so the production verification code is tested unchanged: signature, payload extraction, JSON parse, build-date check (MExBB) and build-type check. The fixture is customer data and stays outside the repo. Export it as described in sockeye\sockeye.tests\ResignTests.cs. Format 1 rows are v7 keys and are not used here. Set AYANOVA_LICENCE_GATE_OUT to a file path to also write "id outcome" lines (no key text), so two builds can be diffed. Tests reference AyaNova.csproj without SUBSCRIPTION_BUILD, so this is the perpetual build. */ using System.Reflection; using Microsoft.Extensions.Logging.Abstractions; using Newtonsoft.Json.Linq; [assembly: CollectionBehavior(DisableTestParallelization = true)] namespace ayanova.tests; public record LicenceRow(long Id, int Format, string Key); public static class LicenceFixture { static readonly Lazy> _rows = new(Load); public static IReadOnlyDictionary V8Rows => _rows.Value; static Dictionary Load() { var path = "C:\\data\\sockeye-fixtures\\resign.json"; if (!File.Exists(path)) throw new InvalidOperationException($"Licence fixture not found at {path}. Export it as described in sockeye\\sockeye.tests\\ResignTests.cs."); var rows = System.Text.Json.JsonSerializer.Deserialize>( File.ReadAllText(path), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return rows.Where(r => r.Format != 1).ToDictionary(r => r.Id); } } // Reflection access to AyaNova's internal licence code public static class AyaNovaLicence { static readonly Assembly Asm = typeof(AyaNova.Program).Assembly; static readonly Type LicenseType = Asm.GetType("AyaNova.Core.License", true); static readonly Type FileUtilType = Asm.GetType("AyaNova.Util.FileUtil", true); static readonly MethodInfo ParseMethod = LicenseType.GetMethod("Parse", BindingFlags.NonPublic | BindingFlags.Static); static readonly MethodInfo MExBBMethod = LicenseType.GetMethod("MExBB", BindingFlags.NonPublic | BindingFlags.Static); static readonly PropertyInfo ServerDbIdProperty = LicenseType.GetProperty("ServerDbId", BindingFlags.NonPublic | BindingFlags.Static); static readonly MethodInfo LinkerTimestampMethod = FileUtilType.GetMethod("GetLinkerTimestampUtc", BindingFlags.Public | BindingFlags.Static, new[] { typeof(Assembly) }); public static string MismatchToBuildError => (string)LicenseType.GetField("LICENSE_MISMATCH_TO_BUILD_ERROR", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null); public static DateTime BuildTimestampUtc => (DateTime)LinkerTimestampMethod.Invoke(null, new object[] { Asm }); public static void SetServerDbId(string dbId) => ServerDbIdProperty.SetValue(null, dbId); // Returns the parsed key object, or throws the exception Parse threw (unwrapped from reflection) public static object Parse(string keyText) { try { return ParseMethod.Invoke(null, new object[] { keyText, NullLogger.Instance }); } catch (TargetInvocationException e) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); throw; } } public static bool MExBB(DateTime buildDate, object key) => (bool)MExBBMethod.Invoke(null, new object[] { buildDate, key }); public static object NewKey() => Activator.CreateInstance(LicenseType.GetNestedType("AyaNovaLicenseKey", BindingFlags.NonPublic), true); public static object Get(object o, string name) => o.GetType().GetProperty(name).GetValue(o); public static void Set(object o, string name, object value) => o.GetType().GetProperty(name).SetValue(o, value); } public class LicenceGateTests { const string OK = "ok"; const string MISMATCH = "mismatch-to-build"; public static IEnumerable RowIds() => LicenceFixture.V8Rows.Keys.Select(id => new object[] { id }); [Fact] public void Fixture_has_v8_rows() { Assert.NotEmpty(LicenceFixture.V8Rows); Assert.All(LicenceFixture.V8Rows.Values, r => Assert.Equal("8", (string)KeyJson(r)["Key"]["LicenseFormat"])); } [Theory] [MemberData(nameof(RowIds))] public void Stored_v8_licence_verifies(long id) { var row = LicenceFixture.V8Rows[id]; var json = KeyJson(row)["Key"]; AyaNovaLicence.SetServerDbId((string)json["DBID"]); var expected = ExpectedOutcome(json); string actual; object parsed = null; try { parsed = AyaNovaLicence.Parse(row.Key); actual = OK; } catch (ApplicationException e) when (e.InnerException?.Message == AyaNovaLicence.MismatchToBuildError) { actual = MISMATCH; } catch (Exception e) { // Signature, format, parse or server-match failure: never acceptable for a stored key actual = $"error: {e.InnerException?.Message ?? e.Message}"; } RecordOutcome(id, actual); Assert.Equal(expected, actual); if (parsed != null) { Assert.Equal((string)json["RegisteredTo"], (string)AyaNovaLicence.Get(parsed, "RegisteredTo")); Assert.Equal((string)json["DBID"], (string)AyaNovaLicence.Get(parsed, "DbId")); Assert.Equal((bool?)json["Perpetual"] ?? true, (bool)AyaNovaLicence.Get(parsed, "Perpetual")); Assert.Equal((DateTime)json["LicenseExpiration"], (DateTime)AyaNovaLicence.Get(parsed, "LicenseExpiration")); Assert.Equal((DateTime)json["MaintenanceExpiration"], (DateTime)AyaNovaLicence.Get(parsed, "MaintenanceExpiration")); var features = (System.Collections.IList)AyaNovaLicence.Get(parsed, "Features"); Assert.Equal(((JArray)json["Features"]).Count, features.Count); } } [Fact] public void Tampered_v8_licence_fails_signature() { var row = LicenceFixture.V8Rows.Values.First(); AyaNovaLicence.SetServerDbId((string)KeyJson(row)["Key"]["DBID"]); // Any change to the signed payload must fail the signature check var tampered = row.Key.Replace("KEY]", "xKEY]"); var e = Assert.Throws(() => AyaNovaLicence.Parse(tampered)); Assert.Contains("integrity check", e.InnerException.Message); } // Guards the build date that MExBB compares against: it comes from the PE linker timestamp, // which is only a real date while AyaNova.csproj has False [Fact] public void Build_timestamp_is_a_real_recent_date() { var built = AyaNovaLicence.BuildTimestampUtc; Assert.InRange(built, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), DateTime.UtcNow.AddMinutes(5)); } [Fact] public void MExBB_compares_maintenance_with_build_date() { var built = AyaNovaLicence.BuildTimestampUtc; var key = AyaNovaLicence.NewKey(); AyaNovaLicence.Set(key, "Perpetual", true); AyaNovaLicence.Set(key, "MaintenanceExpiration", built.AddDays(-1)); Assert.True(AyaNovaLicence.MExBB(built, key)); AyaNovaLicence.Set(key, "MaintenanceExpiration", built.AddDays(1)); Assert.False(AyaNovaLicence.MExBB(built, key)); } // What production must do with this key on a perpetual build, worked out from the key itself static string ExpectedOutcome(JToken json) { if ((string)json["RegisteredTo"] == "REVOKED") return OK; var perpetual = (bool?)json["Perpetual"] ?? true; var trial = ((JArray)json["Features"]).Any(f => string.Equals((string)f["Name"], "TrialMode", StringComparison.OrdinalIgnoreCase)); var maintenanceBeforeBuild = perpetual && !trial && (DateTime)json["MaintenanceExpiration"] < AyaNovaLicence.BuildTimestampUtc; if (maintenanceBeforeBuild || !perpetual) return MISMATCH; return OK; } static JObject KeyJson(LicenceRow row) { var a = row.Key.IndexOf("[KEY", StringComparison.Ordinal) + 4; var b = row.Key.IndexOf("KEY]", StringComparison.Ordinal); return JObject.Parse(row.Key.Substring(a, b - a)); } static readonly object OutLock = new(); static void RecordOutcome(long id, string outcome) { var path = Environment.GetEnvironmentVariable("AYANOVA_LICENCE_GATE_OUT"); if (string.IsNullOrWhiteSpace(path)) return; lock (OutLock) File.AppendAllText(path, $"{id} {outcome}{Environment.NewLine}"); } }