diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validation.test.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validation.test.ts index fe8eb8c867d2..bc6e4b0b4ca7 100644 --- a/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validation.test.ts +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validation.test.ts @@ -17,6 +17,8 @@ import { createProductInfo } from './product_info'; const DOT_NET_TICKS_EPOCH_OFFSET = 621355968000000000n; const DOT_NET_TICKS_PER_MS = 10000n; const DEVEXTREME_HTML_JS_BIT = 1n << 54n; +const DEVEXTREME_ASPNET_BIT = 1n << 17n; +const DEVEXTREME_COMPLETE_BITS = DEVEXTREME_HTML_JS_BIT | DEVEXTREME_ASPNET_BIT; function msToDotNetTicks(ms: number): string { return (BigInt(ms) * DOT_NET_TICKS_PER_MS + DOT_NET_TICKS_EPOCH_OFFSET).toString(); @@ -83,4 +85,80 @@ describe('LCP key validation', () => { expect(token.kind).toBe(TokenKind.verified); }); + + it('does not accept AspNet-only product bit without compatibility mode', () => { + const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck(); + const payload = `meta;251,${DEVEXTREME_ASPNET_BIT};`; + + const token = parseDevExpressProductKey(createLcpSource(payload)); + + expect(token.kind).toBe(TokenKind.corrupted); + if (token.kind === TokenKind.corrupted) { + expect(token.error).toBe('product-kind'); + } + }); + + it('accepts AspNet-only product bit in compatibility mode', () => { + const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck(); + const payload = `meta;251,${DEVEXTREME_ASPNET_BIT};`; + + const token = parseDevExpressProductKey(createLcpSource(payload), true); + + expect(token.kind).toBe(TokenKind.verified); + if (token.kind === TokenKind.verified) { + expect(token.payload.maxVersionAllowed).toBe(251); + } + }); + + it.each([false, true])('accepts HtmlJs-only product bit (acceptAspNetEntitlement=%s)', (acceptAspNetEntitlement) => { + const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck(); + const payload = `meta;251,${DEVEXTREME_HTML_JS_BIT};`; + + const token = parseDevExpressProductKey(createLcpSource(payload), acceptAspNetEntitlement); + + expect(token.kind).toBe(TokenKind.verified); + if (token.kind === TokenKind.verified) { + expect(token.payload.maxVersionAllowed).toBe(251); + } + }); + + it.each([ + { acceptAspNetEntitlement: false, expectedVersion: 251 }, + { acceptAspNetEntitlement: true, expectedVersion: 252 }, + ])('selects the correct entitlement for Complete followed by legacy AspNet (acceptAspNetEntitlement=$acceptAspNetEntitlement)', ({ + acceptAspNetEntitlement, + expectedVersion, + }) => { + const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck(); + const payload = [ + 'meta', + `251,${DEVEXTREME_COMPLETE_BITS}`, + `252,${DEVEXTREME_ASPNET_BIT}`, + '', + ].join(';'); + + const token = parseDevExpressProductKey(createLcpSource(payload), acceptAspNetEntitlement); + + expect(token.kind).toBe(TokenKind.verified); + if (token.kind === TokenKind.verified) { + expect(token.payload.maxVersionAllowed).toBe(expectedVersion); + } + }); + + it.each([false, true])('selects newer Complete after an older AspNet-only entitlement (acceptAspNetEntitlement=%s)', (acceptAspNetEntitlement) => { + const { parseDevExpressProductKey, TokenKind } = loadParserWithBypassedSignatureCheck(); + const payload = [ + 'meta', + `251,${DEVEXTREME_ASPNET_BIT}`, + `252,${DEVEXTREME_COMPLETE_BITS}`, + '', + ].join(';'); + + const token = parseDevExpressProductKey(createLcpSource(payload), acceptAspNetEntitlement); + + expect(token.kind).toBe(TokenKind.verified); + if (token.kind === TokenKind.verified) { + expect(token.payload.maxVersionAllowed).toBe(252); + } + }); }); diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts index 126e6b38c740..dedc910d1f5c 100644 --- a/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/lcp_key_validator.ts @@ -65,7 +65,10 @@ function productsFromString(encodedString: string): ParsedProducts { } } -export function parseDevExpressProductKey(productsLicenseSource: string): Token { +export function parseDevExpressProductKey( + productsLicenseSource: string, + acceptAspNetEntitlement = false, +): Token { if (!isProductOnlyLicense(productsLicenseSource)) { return GENERAL_ERROR; } @@ -93,7 +96,10 @@ export function parseDevExpressProductKey(productsLicenseSource: string): Token return errorToken; } - const maxVersionAllowed = findLatestDevExtremeVersion({ products }); + const maxVersionAllowed = findLatestDevExtremeVersion( + { products }, + acceptAspNetEntitlement, + ); if (!maxVersionAllowed) { return PRODUCT_KIND_ERROR; diff --git a/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts b/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts index a526a3c61983..5fdeaa037010 100644 --- a/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts +++ b/packages/devextreme/js/__internal/core/license/lcp_key_validation/license_info.ts @@ -17,12 +17,18 @@ export function getMaxExpiration(info: LicenseInfo): number { return Math.max(...expirationDates); } -export function findLatestDevExtremeVersion(info: LicenseInfo): number | undefined { +export function findLatestDevExtremeVersion( + info: LicenseInfo, + acceptAspNetEntitlement = false, +): number | undefined { if (!isLicenseValid(info)) { return undefined; } + const productKinds = acceptAspNetEntitlement + ? [ProductKind.DevExtremeHtmlJs, ProductKind.DevExtremeAspNet] + : [ProductKind.DevExtremeHtmlJs]; const sorted = [...info.products].sort((a, b) => b.version - a.version); - return sorted.find((p) => isProduct(p, ProductKind.DevExtremeHtmlJs))?.version; + return sorted.find((p) => isProduct(p, ...productKinds))?.version; } diff --git a/packages/devextreme/js/__internal/core/license/license_validation.test.ts b/packages/devextreme/js/__internal/core/license/license_validation.test.ts index 310c260a311f..80c6f27d1c33 100644 --- a/packages/devextreme/js/__internal/core/license/license_validation.test.ts +++ b/packages/devextreme/js/__internal/core/license/license_validation.test.ts @@ -3,6 +3,7 @@ import { } from '@jest/globals'; import config from '@js/core/config'; import errors from '@js/core/errors'; +import { fullVersion } from '@js/core/version'; import { base } from '../../ui/overlay/z_index'; import { @@ -11,12 +12,14 @@ import { clearAssertedVersions, } from '../../utils/version'; import { LICENSE_KEY_PLACEHOLDER } from './const'; +import * as productKeyValidator from './lcp_key_validation/lcp_key_validator'; import { parseLicenseKey, setLicenseCheckSkipCondition, validateLicense, } from './license_validation'; import * as trialPanel from './trial_panel.client'; +import { TokenKind } from './types'; jest.mock('./key', () => ({ PUBLIC_KEY: { @@ -583,3 +586,120 @@ describe('assertedVersions integration', () => { }); }); }); + +describe('AspNet entitlement gate', () => { + const LCP_LICENSE = 'LCPv1mock-license'; + + beforeEach(() => { + jest.spyOn(errors, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(trialPanel, 'renderTrialPanel'); + setLicenseCheckSkipCondition(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + clearAssertedVersions(); + }); + + it('accepts the AspNet entitlement when a DevExtreme.AspNet assertion exists', () => { + const parseSpy = jest.spyOn(productKeyValidator, 'parseDevExpressProductKey').mockReturnValue({ + kind: TokenKind.verified, + payload: { + customerId: 'test', + maxVersionAllowed: 999, + format: 1, + }, + }); + + assertDevExtremeVersion('DevExtreme.AspNet.Core', fullVersion); + validateLicense(LCP_LICENSE, fullVersion); + + expect(parseSpy).toHaveBeenCalledWith(LCP_LICENSE, true); + }); + + it('does not accept the AspNet entitlement when there is no AspNet assertion', () => { + const parseSpy = jest.spyOn(productKeyValidator, 'parseDevExpressProductKey').mockReturnValue({ + kind: TokenKind.verified, + payload: { + customerId: 'test', + maxVersionAllowed: 999, + format: 1, + }, + }); + + assertDevExtremeVersion('DevExtreme.React', fullVersion); + validateLicense(LCP_LICENSE, fullVersion); + + expect(parseSpy).toHaveBeenCalledWith(LCP_LICENSE, false); + }); + + it('does not accept the AspNet entitlement for an unrelated package with a matching prefix', () => { + const parseSpy = jest.spyOn(productKeyValidator, 'parseDevExpressProductKey').mockReturnValue({ + kind: TokenKind.verified, + payload: { + customerId: 'test', + maxVersionAllowed: 999, + format: 1, + }, + }); + + assertDevExtremeVersion('DevExtreme.AspNetFake', fullVersion); + validateLicense(LCP_LICENSE, fullVersion); + + expect(parseSpy).toHaveBeenCalledWith(LCP_LICENSE, false); + }); + + it('keeps version mismatch warnings for other packages when accepting the AspNet entitlement', () => { + const parseSpy = jest.spyOn(productKeyValidator, 'parseDevExpressProductKey').mockReturnValue({ + kind: TokenKind.verified, + payload: { + customerId: 'test', + maxVersionAllowed: 999, + format: 1, + }, + }); + + assertDevExtremeVersion('DevExpress.SomeOtherProduct', '1.2.3'); + assertDevExtremeVersion('DevExtreme.AspNet.Core', fullVersion); + validateLicense(LCP_LICENSE, fullVersion); + + expect(errors.log).toHaveBeenCalledWith( + 'W0023', + expect.stringContaining('DevExpress.SomeOtherProduct'), + ); + expect(parseSpy).toHaveBeenCalledWith(LCP_LICENSE, true); + }); + + it('does not revalidate an early JavaScript component after a later AspNet assertion', () => { + const parseSpy = jest.spyOn(productKeyValidator, 'parseDevExpressProductKey').mockReturnValue({ + kind: TokenKind.corrupted, + error: 'product-kind', + }); + + validateLicense(LCP_LICENSE, fullVersion); + assertDevExtremeVersion('DevExtreme.AspNet.Core', fullVersion); + validateLicense(LCP_LICENSE, fullVersion); + + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(parseSpy).toHaveBeenCalledWith(LCP_LICENSE, false); + }); + + it('does not revalidate an early JavaScript component whose license is valid without the AspNet entitlement', () => { + const parseSpy = jest.spyOn(productKeyValidator, 'parseDevExpressProductKey').mockReturnValue({ + kind: TokenKind.verified, + payload: { + customerId: 'test', + maxVersionAllowed: 999, + format: 1, + }, + }); + + validateLicense(LCP_LICENSE, fullVersion); + assertDevExtremeVersion('DevExtreme.AspNet.Core', fullVersion); + validateLicense(LCP_LICENSE, fullVersion); + + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(parseSpy).toHaveBeenCalledWith(LCP_LICENSE, false); + }); +}); diff --git a/packages/devextreme/js/__internal/core/license/license_validation.ts b/packages/devextreme/js/__internal/core/license/license_validation.ts index 48d75c73d4a4..b5db5f633ecf 100644 --- a/packages/devextreme/js/__internal/core/license/license_validation.ts +++ b/packages/devextreme/js/__internal/core/license/license_validation.ts @@ -5,6 +5,7 @@ import { fullVersion } from '@js/core/version'; import type { Version } from '../../utils/version'; import { assertedVersionsCompatible, + getAssertedVersions, parseVersion, } from '../../utils/version'; import { @@ -24,14 +25,23 @@ import { } from './types'; let validationPerformed = false; +const ASPNET_ASSERTION_PREFIX = 'devextreme.aspnet.'; -export function parseLicenseKey(encodedKey: string | undefined): Token { +function hasAspNetVersionAssertion(): boolean { + return getAssertedVersions() + .some(({ packageName }) => packageName.toLowerCase().startsWith(ASPNET_ASSERTION_PREFIX)); +} + +export function parseLicenseKey( + encodedKey: string | undefined, + acceptAspNetEntitlement = false, +): Token { if (encodedKey === undefined) { return GENERAL_ERROR; } if (isProductOnlyLicense(encodedKey)) { - return parseDevExpressProductKey(encodedKey); + return parseDevExpressProductKey(encodedKey, acceptAspNetEntitlement); } return GENERAL_ERROR; @@ -77,7 +87,8 @@ function getLicenseCheckParams({ return { preview, error: 'W0021', warningType: 'old-devextreme-key' }; } - const license = parseLicenseKey(licenseKey); + const acceptAspNetEntitlement = hasAspNetVersionAssertion(); + const license = parseLicenseKey(licenseKey, acceptAspNetEntitlement); if (license.kind === TokenKind.corrupted) { if (license.error === 'product-kind') { diff --git a/packages/devextreme/js/__internal/core/license/license_validation_internal.ts b/packages/devextreme/js/__internal/core/license/license_validation_internal.ts index df552c7a8878..34bca45e56e6 100644 --- a/packages/devextreme/js/__internal/core/license/license_validation_internal.ts +++ b/packages/devextreme/js/__internal/core/license/license_validation_internal.ts @@ -1,13 +1,18 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { Token } from './types'; -// @ts-expect-error - only for internal usage -export function parseLicenseKey(encodedKey: string | undefined): Token {} +export function parseLicenseKey( + encodedKey: string | undefined, + acceptAspNetEntitlement?: boolean, +): Token { + return undefined as unknown as Token; +} export function validateLicense(licenseKey: string, version?: string): void {} -// @ts-expect-error - only for internal usage -export function peekValidationPerformed(): boolean {} +export function peekValidationPerformed(): boolean { + return false; +} export function setLicenseCheckSkipCondition(): void {}