Initial Commit
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
155
packages/security-stack/security/license/tests/property.test.ts
Normal file
155
packages/security-stack/security/license/tests/property.test.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user