mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
refactor(backend): collapse entitlement provider abstraction back to LicenseService (#889)
Removes backend/src/entitlements/ (registry, loadProvider, CommunityEntitlementProvider, types, headers, normalize) and the two abstraction-only tests. Relocates headers/normalize/types to services/license-*.ts. Swaps 22 consumer call sites from getEntitlementProvider() to LicenseService.getInstance(). Drops the Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and github_token BuildKit secret in docker-publish.yml. Removes the now stale no-restricted-imports rule in backend/eslint.config.mjs. Net: 37 files changed, ~700 lines removed, no behavior change. Local dev no longer requires GitHub Packages auth to start the backend. Rationale and revisit conditions in docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
This commit is contained in:
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Smoke test for `CommunityEntitlementProvider`. The class is the Phase
|
||||
* 2 fallback for a build that ships without `@studio-saelix/sencho-pro`
|
||||
* and is NOT instantiated in production today. This test exists to keep
|
||||
* the class covered against bitrot: every test run proves the
|
||||
* EntitlementProvider interface still matches and the Community
|
||||
* implementation still satisfies the contract.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('CommunityEntitlementProvider', () => {
|
||||
it('reports community tier and null variant', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
|
||||
expect(provider.getTier()).toBe('community');
|
||||
expect(provider.getVariant()).toBeNull();
|
||||
expect(provider.getProxyHeaders()).toEqual({ tier: 'community', variant: null });
|
||||
});
|
||||
|
||||
it('returns single-admin seat limits matching the Community plan', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
expect(provider.getSeatLimits()).toEqual({ maxAdmins: 1, maxViewers: 0 });
|
||||
});
|
||||
|
||||
it('rejects activate() with a clear install-the-private-package message', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
const result = await provider.activate('any-key');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('@studio-saelix/sencho-pro');
|
||||
});
|
||||
|
||||
it('deactivate() is a successful no-op', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
const result = await provider.deactivate();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('initialize() seeds a persistent instance_id when one is missing', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
db.setSystemState('instance_id', '');
|
||||
new CommunityEntitlementProvider().initialize();
|
||||
expect(db.getSystemState('instance_id')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('getLicenseInfo() returns Community-only state', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const info = new CommunityEntitlementProvider().getLicenseInfo();
|
||||
expect(info.tier).toBe('community');
|
||||
expect(info.status).toBe('community');
|
||||
expect(info.variant).toBeNull();
|
||||
expect(info.maskedKey).toBeNull();
|
||||
expect(info.isLifetime).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -54,22 +54,15 @@ export async function setupTestDb(): Promise<string> {
|
||||
// path-traversal or 404 on missing files. Realign here.
|
||||
db.getDb().prepare('UPDATE nodes SET compose_dir = ? WHERE is_default = 1').run(composeDir);
|
||||
|
||||
// Register the in-tree LicenseService as the active EntitlementProvider
|
||||
// so tier-gated middleware can resolve a provider during the test. In
|
||||
// Force the LicenseService singleton to materialize on the test DB. In
|
||||
// production this is wired by `bootstrap/startup.ts`; tests bypass that
|
||||
// path by importing modules directly, so the registry would otherwise
|
||||
// throw on first tier check. The mocking pattern many tests use
|
||||
// (`vi.spyOn(LicenseService.getInstance(), 'getTier')`) continues to
|
||||
// work because LicenseService.getInstance() and getEntitlementProvider()
|
||||
// return the same singleton in Phase 1.
|
||||
//
|
||||
// The registry binding is module-scope and survives across test files
|
||||
// within the same Vitest worker. Avoid `vi.resetModules()` in this
|
||||
// codebase; it would drop the binding and cause subsequent tier
|
||||
// checks to throw with "EntitlementProvider not initialized."
|
||||
// path by importing modules directly. Without this prime, the first
|
||||
// tier check in a test runs `LicenseService.getInstance()` against a
|
||||
// singleton whose lazy-init never ran. The mocking pattern many tests
|
||||
// use (`vi.spyOn(LicenseService.getInstance(), 'getTier')`) continues
|
||||
// to work against the same singleton.
|
||||
const { LicenseService } = await import('../../services/LicenseService');
|
||||
const { setEntitlementProvider } = await import('../../entitlements/registry');
|
||||
setEntitlementProvider(LicenseService.getInstance());
|
||||
LicenseService.getInstance();
|
||||
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -98,15 +98,6 @@ vi.mock('../services/LicenseService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// SchedulerService now talks to the entitlement registry rather than
|
||||
// LicenseService.getInstance() directly. Mock the registry to return the
|
||||
// same shape the test was already mocking on LicenseService.
|
||||
vi.mock('../entitlements/registry', () => ({
|
||||
getEntitlementProvider: () => ({
|
||||
getTier: mockGetTier,
|
||||
getVariant: mockGetVariant,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DockerController', () => ({
|
||||
default: {
|
||||
|
||||
Reference in New Issue
Block a user