diff --git a/ayanova.tests/LicenceGateTests.cs b/ayanova.tests/LicenceGateTests.cs new file mode 100644 index 00000000..21ad460f --- /dev/null +++ b/ayanova.tests/LicenceGateTests.cs @@ -0,0 +1,206 @@ +/* +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}"); + } +} diff --git a/ayanova.tests/ayanova.tests.csproj b/ayanova.tests/ayanova.tests.csproj new file mode 100644 index 00000000..56d04cf5 --- /dev/null +++ b/ayanova.tests/ayanova.tests.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + disable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index 5e74137a..6122936a 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -62,7 +62,7 @@ them. Items touching those three areas carry more weight here than they did in S ### Gates (prove on the unmodified code) - [x] **Integration suite baseline.** Run `raven-test-integration` against the unmodified build and record the counts in the log below. Expected: everything passes except the three known server-side failures listed in that repo's CLAUDE.md. Result: 103 tests, 96 passed, 2 failed (`ContractCrud.CRUD`, `CustomerWithWorkOrders_CannotBeDeleted`), 5 skipped (including `DirectMessage_SendAndDelete`). Needs a freshly generated test database: a reused one fails 32 tests at the `superuser` login -- [ ] **Licence verification gate.** Write a test that runs every v8 row of Sockeye's `alicense` fixture through AyaNova's own verification routine. The fixture is customer data: it stays outside the repo and is found through an environment variable, as Sockeye's re-sign harness does with `SOCKEYE_RESIGN_FIXTURE`. Must be green on unmodified code. A regression here would reject licences customers already have installed. How AyaNova verifies: +- [x] **Licence verification gate.** Write a test that runs every v8 row of Sockeye's `alicense` fixture through AyaNova's own verification routine. The fixture is customer data: it stays outside the repo, at `C:\data\sockeye-fixtures\resign.json` (a fixed path, as in Sockeye's `sockeye.tests`; the environment variable was dropped there). Must be green on unmodified code. A regression here would reject licences customers already have installed. **Done:** `ayanova.tests\LicenceGateTests.cs` (not in `raven.sln`; run with `dotnet test ayanova.tests`). It calls the production `License.Parse` by reflection for each of the 726 v8 rows, and checks each outcome against what the key itself requires on a perpetual build (verify and parse, or rejection as not licensed for this build). Plus a tamper test, a build-timestamp sanity check and an `MExBB` check. Set `AYANOVA_LICENCE_GATE_OUT` to write per-row outcomes (ids only) for diffing two builds. How AyaNova verifies: - **Library:** BouncyCastle (`util/License.cs:15-17`). `PemReader` reads a SubjectPublicKeyInfo PEM (`BEGIN PUBLIC KEY`, not PKCS#1) into an `AsymmetricKeyParameter`; `SignerUtilities.GetSigner("SHA256WITHRSA")` verifies (`License.cs:968-978`) - **Public key:** hardcoded RSA-2048 PEM, "our real 2016 public key", `License.cs:957-965` - **Signed payload:** the JSON between `[KEY` and `KEY]`, with whitespace outside strings stripped by regex (`License.cs:950`); base64 signature between `[SIGNATURE` and `SIGNATURE]` (`:951`) @@ -191,3 +191,4 @@ Found during the port, out of scope. One line each. | 2026-10-22 | Case 4676 data-list SQL injection fix (.NET 8) | Integration suite same as baseline. API probe: valid Integer, Decimal and Roles filters return rows; `0 OR 1=1`, `0); select pg_sleep(3); --`, `1)=1 OR (1` and similar are rejected with `ArgumentException` before any SQL runs | | 2026-10-22 | `AdminConnectionString` via `NpgsqlConnectionStringBuilder` (.NET 8) | Boot database check passes; integration suite same as baseline. Old vs new on sample strings: old turned role `AyaNova` and password `AyaNova@1 x` into `postgres`, and host `ayanova.example.com` into `postgres.example.com`; new changes only `Database`. The section 4 role test still applies | | 2026-10-22 | Retarget `net10.0` + framework-matched `Microsoft.*` 10.0.13 + Npgsql EF 10.0.3 | Builds, 0 errors (warnings: CA2022 `FileUtil.cs:875`; NU1902/NU1903 MailKit/MimeKit/Pkcs). Boots on .NET 10.0.3, licence accepted. Suite, first run after boot: 95 passed, 3 failed: the two known plus `VendorCrud.CRUD`, caused by the existing `FormFieldReference` start-up race (see Deferred); `VendorCrud` passes alone. Full rerun: 96 passed, 2 failed (known), 5 skipped = baseline. No EF warning-as-error thrown. Licence, report and E2E gates to run next on this build | +| 2026-10-22 | Licence gate (`ayanova.tests`) on `6d6d4d9a` (.NET 10) and on unmodified `eb607d34` (.NET 8 runtime, temporary worktree) | Both 730/730 passed. 726 v8 rows: 389 verify and parse, 337 rejected as not licensed for this build (225 subscription keys, 112 perpetual with maintenance before the build date), 0 signature, format or parse errors. Per-row outcomes identical between the two builds |