case 4690 - licence verification gate: ayanova.tests runs every v8 fixture key through License.Parse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
206
ayanova.tests/LicenceGateTests.cs
Normal file
206
ayanova.tests/LicenceGateTests.cs
Normal file
@@ -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<Dictionary<long, LicenceRow>> _rows = new(Load);
|
||||
public static IReadOnlyDictionary<long, LicenceRow> V8Rows => _rows.Value;
|
||||
|
||||
static Dictionary<long, LicenceRow> 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<List<LicenceRow>>(
|
||||
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<object[]> 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<ApplicationException>(() => 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 <Deterministic>False</Deterministic>
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
24
ayanova.tests/ayanova.tests.csproj
Normal file
24
ayanova.tests/ayanova.tests.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\server\AyaNova\AyaNova.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user