Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG = resolve(__dirname, "..", "package.json");
const PACKAGE_JSON = JSON.parse(readFileSync(PKG, "utf8"));
const ALLOWED_RUNTIME_DEPS = new Set([
"@opentelemetry/api",
"@suaveplan/crypto-curve",
"@suaveplan/crypto-hash",
"@suaveplan/crypto-utils",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
]);
const REQUIRED_SUBPATHS = [
"./error-codes/error-codes",
"./constants/constants",
"./types/license",
"./codec/base32/base32",
"./codec/bitpack/bitpack",
"./codec/payload/payload",
"./codec/obfuscation/obfuscation",
"./time/time",
"./generate/generate",
"./validate/validate",
"./flags/flags",
"./manager/manager",
"./revocation/revocation-store/revocation-store",
"./otel/instruments",
"./schemas/schemas",
];
describe("dep-graph and subpath exports", () => {
it("dependencies match the universal allowlist", { timeout: 5000 }, () => {
const deps = Object.keys(
PACKAGE_JSON.dependencies as Record<string, string>
);
for (const dep of deps) {
expect(ALLOWED_RUNTIME_DEPS).toContain(dep);
}
// Every allowlisted dep with a runtime use must be present
for (const dep of ALLOWED_RUNTIME_DEPS) {
expect(deps).toContain(dep);
}
});
it("subpath exports cover every feature module", { timeout: 5000 }, () => {
const exports = PACKAGE_JSON.exports as Record<string, unknown>;
for (const subpath of REQUIRED_SUBPATHS) {
expect(exports).toHaveProperty(subpath);
}
expect(exports).toHaveProperty(".");
expect(exports).toHaveProperty("./package.json");
});
it("package is universal (no browser/Node-only deps)", {
timeout: 5000,
}, () => {
const deps = Object.keys(
PACKAGE_JSON.dependencies as Record<string, string>
);
for (const dep of deps) {
expect(dep.startsWith("node:")).toBe(false);
expect(dep).not.toMatch(/^@?(react|vue|svelte|angular|expo)/);
}
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(PACKAGE_JSON.sideEffects).toBe(false);
});
it("type is module", { timeout: 5000 }, () => {
expect(PACKAGE_JSON.type).toBe("module");
});
it("version is 0.1.0", { timeout: 5000 }, () => {
expect(PACKAGE_JSON.version).toBe("0.1.0");
});
});

View File

@@ -0,0 +1,155 @@
import { secp256k1 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { packPayload, unpackPayload } from "../src/codec/payload/payload.js";
import { LICENSE_SCHEMA } from "../src/constants/constants.js";
import { generateLicense } from "../src/generate/generate.js";
import {
type LicensePayload,
LicenseValidationError,
TimeEncodingType,
} from "../src/types/license.js";
import { validateLicense } from "../src/validate/validate.js";
const payloadArb = fc.record({
version: fc.integer({ min: 0, max: LICENSE_SCHEMA.version }),
signingKeyId: fc.integer({ min: 0, max: LICENSE_SCHEMA.signingKeyId }),
productId: fc.integer({ min: 0, max: LICENSE_SCHEMA.productId }),
packageTier: fc.integer({ min: 0, max: LICENSE_SCHEMA.packageTier }),
flags: fc.integer({ min: 0, max: LICENSE_SCHEMA.flags }),
timeEncodingType: fc
.integer({ min: 0, max: LICENSE_SCHEMA.timeEncodingType })
.map((v) => v as TimeEncodingType),
timeValue: fc.integer({ min: 0, max: LICENSE_SCHEMA.timeValue }),
featureMask: fc.integer({ min: 0, max: LICENSE_SCHEMA.featureMask }),
nonce: fc.integer({ min: 0, max: LICENSE_SCHEMA.nonce }),
});
function fresh() {
const s = secp256k1.randomPrivateKey();
const p = secp256k1.getPublicKey(s);
return { signingKey: { s, p }, publicKey: { p } };
}
describe("property tests", () => {
it("unpackPayload inverts packPayload", { timeout: 30000 }, () => {
fc.assert(
fc.property(payloadArb, (payload: LicensePayload) => {
const bytes = packPayload(payload);
expect(bytes.length).toBe(9);
const decoded = unpackPayload(bytes);
expect(decoded).toEqual(payload);
}),
{ numRuns: 200 }
);
});
it("1-byte mutation surfaces as INVALID_SIGNATURE or MALFORMED", {
timeout: 60000,
}, async () => {
const { signingKey, publicKey } = fresh();
// Sample once at the boundary; we mutate the generated key's
// characters for the actual property under test.
const payload: LicensePayload = {
version: 2,
signingKeyId: 0,
productId: 1,
packageTier: 0,
flags: 0,
timeEncodingType: TimeEncodingType.NONE,
timeValue: 0,
featureMask: 0,
nonce: 0x111,
};
const r = await generateLicense(payload, {
signingKey,
useFullSignature: false,
});
const validPubKeys = new Map([[0, publicKey]]);
const keyLen = r.key.length;
// The Base32 encoding of 29 bytes uses 47 chars; chars after
// that are padding bits that may not affect decoded bytes.
// We bound the tamper-index so it always lands on a
// semantically-significant character.
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 0, max: keyLen - 2 }),
fc.integer({ min: 0, max: 31 }),
async (idx, alphabetIdx) => {
const chars = r.key.split("");
if (chars[idx] === "-") return true; // skip dashes
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const newChar = ALPHABET[alphabetIdx] as string;
if (chars[idx] === newChar) return true; // no-op mutation
chars[idx] = newChar;
const tampered = chars.join("");
const v = await validateLicense(tampered, {
publicKeys: validPubKeys,
});
// Base32 has trailing padding bits; a character
// change in the final bit-positions may decode to
// the same bytes, producing a valid key. That is
// acceptable — we only need to assert that NO tamper
// produces a *different* valid license that the
// validator wrongly trusts as the original payload.
if (v.success) {
return (
v.details.nonce === payload.nonce &&
v.details.productId === payload.productId
);
}
return [
LicenseValidationError.INVALID_SIGNATURE,
LicenseValidationError.MALFORMED,
LicenseValidationError.INVALID_ENCODING,
LicenseValidationError.UNKNOWN_SIGNING_KEY,
LicenseValidationError.UNSUPPORTED_VERSION,
LicenseValidationError.EXPIRED,
].includes(v.error);
}
),
{ numRuns: 200 }
);
});
it("expired trial always fails validation", {
timeout: 60000,
}, async () => {
const { signingKey, publicKey } = fresh();
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 365 }),
async (durationDays) => {
const payload: LicensePayload = {
version: 2,
signingKeyId: 0,
productId: 1,
packageTier: 0,
flags: 1, // TRIAL
timeEncodingType: TimeEncodingType.RELATIVE_DAYS,
timeValue: durationDays,
featureMask: 0,
nonce: 7,
};
const r = await generateLicense(payload, {
signingKey,
useFullSignature: false,
});
const creation = new Date("2026-01-01T00:00:00Z");
const past =
creation.getTime() + (durationDays + 1) * 86_400_000;
const v = await validateLicense(r.key, {
publicKeys: new Map([[0, publicKey]]),
creationDate: creation,
referenceDate: new Date(past),
});
return (
!v.success && v.error === LicenseValidationError.EXPIRED
);
}
),
{ numRuns: 50 }
);
});
});

View File

@@ -0,0 +1,43 @@
/**
* Test-environment setup for @suaveplan/license.
*
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any
* source module is imported, so the meter resolved by `instruments.ts`
* at module load is the real provider's meter — not the noop fallback.
* Tests that inspect emitted metrics can read them out of
* `testInMemoryMetricExporter`.
*
* Node ≥18 already exposes `crypto.subtle` and `crypto.getRandomValues`
* globally, so no crypto polyfill is required.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000, // tests flush manually via `flushTestMetrics`
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
/**
* Force a flush of the test meter provider, returning the collected
* `ResourceMetrics` snapshots captured by the in-memory exporter so
* far. Resets the exporter's buffer.
*/
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}