207 lines
8.7 KiB
C#
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}");
|
|
}
|
|
}
|