Files
sencho/backend/src/__tests__/load-entitlement-provider.test.ts
T
Anso cffb481106 feat(entitlements): wire dynamic import of @studio-saelix/sencho-pro (#880)
Phase 2 of the open-core hybrid extraction documented in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. The
private @studio-saelix/sencho-pro package is now published to GitHub
Packages with v0.1.0 carrying the LemonSqueezy implementation
(LemonSqueezyEntitlementProvider). This PR delivers the public-side
hookup so the loader prefers the private package when installed and
falls back to the in-tree LicenseService when not.

loadEntitlementProvider() tries `await import('@studio-saelix/sencho-pro')`
first. If the package is missing, the loader falls back to
LicenseService.getInstance() so a Community-only build (no private
package installed, e.g. local dev or the public BSL Docker image)
still runs through the existing LemonSqueezy path. If the package
loaded but threw during construction, or if a transitive dep is
missing, the loader re-raises so the failure surfaces; silently
downgrading a paid install to community on a load-time bug would be
a license-bypass surface.

The discrimination uses two checks rather than the error code alone:
the message must include the literal package name. Without that
anchor, a missing transitive dep in a paid install would surface
with the same MODULE_NOT_FOUND code as the package itself missing.
ERR_PACKAGE_PATH_NOT_EXPORTED is intentionally NOT classified as
"not installed" because that code fires when the package was
resolved but its exports map does not include the requested path,
which is a packaging bug worth surfacing.

backend/src/types/sencho-pro.d.ts is an ambient module stub so tsc
passes when the package is not installed locally. The package's own
dist/index.d.ts shadows the stub when present; drift fails the
build. The stub uses class implements EntitlementProvider so the
interface clause carries the full method surface; we do not
redeclare individual methods.

eslint.config.mjs adds a no-restricted-imports rule blocking static
imports of @studio-saelix/sencho-pro and any subpath. The loader's
await import() is a dynamic import and is not flagged. Static
imports would bundle the package into the public BSL build via
TypeScript's module resolution, defeating the privacy split, and
would break in Community-only environments.

Adds 7 unit tests for isProPackageNotInstalled covering all the
discrimination paths: non-Error inputs, the two recognized codes,
the package-name anchor, transitive-dep MODULE_NOT_FOUND, and
ERR_PACKAGE_PATH_NOT_EXPORTED.

Test results: 91/91 backend test files pass, 1665 passing tests, 5
pre-existing skips. tsc clean. eslint 0 errors.

Out of scope for this PR (Phase 2b, separate follow-up):
  - Dockerfile change to install @studio-saelix/sencho-pro from
    GitHub Packages using GITHUB_TOKEN auth.
  - docker-publish.yml building dual images: saelix/sencho
    (Community-only) and saelix/sencho-pro (with private package).

Out of scope for this PR (cleanup, separate follow-up):
  - Removing services/LicenseService.ts from the public repo.
  - Switching the loader fallback from LicenseService to
    CommunityEntitlementProvider.

The transitional state keeps the public repo runnable on its own
during the dual-image rollout window. The cleanup PR lands once
saelix/sencho-pro is verified working in production.
2026-05-02 06:07:13 -04:00

76 lines
3.2 KiB
TypeScript

/**
* Tests for the loader's module-not-found discrimination. The
* predicate is a security boundary: a too-wide match silently
* downgrades a paid install to community on a transitive-dependency
* bug; a too-narrow match crashes bootstrap on a legitimate
* Community-only build. Keep these fixtures aligned with the runtime
* codes Node and bundlers actually produce.
*/
import { describe, it, expect } from 'vitest';
import { isProPackageNotInstalled } from '../entitlements/loadProvider';
function withCode<T extends Error>(err: T, code: string): T {
(err as Error & { code?: string }).code = code;
return err;
}
describe('isProPackageNotInstalled', () => {
it('returns false for non-Error values', () => {
expect(isProPackageNotInstalled(undefined)).toBe(false);
expect(isProPackageNotInstalled(null)).toBe(false);
expect(isProPackageNotInstalled('not an error')).toBe(false);
expect(isProPackageNotInstalled({})).toBe(false);
});
it('returns false for Error without a code', () => {
expect(isProPackageNotInstalled(new Error('something broke'))).toBe(false);
});
it('returns true for ERR_MODULE_NOT_FOUND on the private package (Node ESM)', () => {
const err = withCode(
new Error("Cannot find package '@studio-saelix/sencho-pro' imported from /app/dist/entitlements/loadProvider.js"),
'ERR_MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(true);
});
it('returns true for MODULE_NOT_FOUND on the private package (CJS / older Node)', () => {
const err = withCode(
new Error("Cannot find module '@studio-saelix/sencho-pro'"),
'MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(true);
});
it('returns false for MODULE_NOT_FOUND on a transitive dep of the private package', () => {
// The private package was installed but one of its dependencies is
// missing. We must NOT classify this as "package not installed";
// re-raising surfaces the bug instead of silently downgrading to
// community.
const err = withCode(
new Error("Cannot find module 'axios'"),
'MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(false);
});
it('returns false for ERR_PACKAGE_PATH_NOT_EXPORTED', () => {
// Package was resolved but its exports map does not include the
// path we asked for; this is a packaging bug, not a missing
// package, and should re-raise.
const err = withCode(
new Error("Package subpath './internal' is not defined by exports in @studio-saelix/sencho-pro/package.json"),
'ERR_PACKAGE_PATH_NOT_EXPORTED',
);
expect(isProPackageNotInstalled(err)).toBe(false);
});
it('returns false for an unrelated runtime error during construction', () => {
// The package loaded successfully but threw inside its
// constructor. The loader must re-raise; silently falling back
// would be a license-bypass surface.
const err = new TypeError('Cannot read property of undefined');
expect(isProPackageNotInstalled(err)).toBe(false);
});
});