Files
raven/ayanova.tests/LicenceGateTests.cs

207 lines
8.7 KiB
C#

/*
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}");
}
}