From 67dd49272a17f9d5b43a6c244bc3560f8468d3ae Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 18 Sep 2025 15:27:08 +0530 Subject: [PATCH 1/3] chore: add entitlment validations and deactivate invalid entitlements --- src/nls/root/strings.js | 3 ++- src/services/login-service.js | 44 ++++++++++++++++++++++++++++++----- src/services/login-utils.js | 24 ++++++++++++++----- src/services/profile-menu.js | 2 +- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index b279e1ad16..2e0e1d7556 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1681,5 +1681,6 @@ define({ "PROMO_GET_APP_UPSELL_BUTTON": "Get {0}", "PROMO_PRO_ENDED_TITLE": "Your {0} Trial has ended", "PROMO_PRO_TRIAL_DAYS_LEFT": "Phoenix Pro Trial ({0} days left)", - "GET_PHOENIX_PRO": "Get Phoenix Pro" + "GET_PHOENIX_PRO": "Get Phoenix Pro", + "USER_FREE_PLAN_NAME": "Free Plan" }); diff --git a/src/services/login-service.js b/src/services/login-service.js index 8b1d0f1f1e..c88d946299 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -28,12 +28,14 @@ define(function (require, exports, module) { require("./setup-login-service"); // this adds loginService to KernalModeTrust require("./promotions"); + require("./login-utils"); - const Metrics = require("utils/Metrics"); - const LoginUtils = require("./login-utils"); + const Metrics = require("utils/Metrics"), + Strings = require("strings"); const MS_IN_DAY = 10 * 24 * 60 * 60 * 1000; const TEN_MINUTES = 10 * 60 * 1000; + const FREE_PLAN_VALIDITY_DAYS = 10000; // the fallback salt is always a constant as this will only fail in rare circumstatnces and it needs to // be exactly same across versions of the app. Changing this will not affect the large majority of users and @@ -351,8 +353,8 @@ define(function (require, exports, module) { const current = await getEffectiveEntitlements(false); // Get effective entitlements // Check if we need to refresh - const expiredPlanName = LoginUtils.validTillExpired(current, lastRecordedState); - const hasChanged = LoginUtils.haveEntitlementsChanged(current, lastRecordedState); + const expiredPlanName = KernalModeTrust.LoginUtils.validTillExpired(current, lastRecordedState); + const hasChanged = KernalModeTrust.LoginUtils.haveEntitlementsChanged(current, lastRecordedState); if (expiredPlanName || hasChanged) { console.log(`Entitlements monitor detected changes, Expired: ${expiredPlanName},` + @@ -376,6 +378,37 @@ define(function (require, exports, module) { console.log('Entitlements monitor started (10-minute interval)'); } + function _validateAndFilterEntitlements(entitlements) { + if (!entitlements) { + return; + } + + const currentDate = Date.now(); + + if(entitlements.plan && (!entitlements.plan.validTill || currentDate > entitlements.plan.validTill)) { + entitlements.plan = { + ...entitlements.plan, + paidSubscriber: false, + name: Strings.USER_FREE_PLAN_NAME, + validTill: Date.now() + (FREE_PLAN_VALIDITY_DAYS * MS_IN_DAY) + }; + } + + const featureEntitlements = entitlements.entitlements; + if (!featureEntitlements) { + return; + } + + for(const featureName in featureEntitlements) { + const feature = featureEntitlements[featureName]; + if(feature && feature.validTill && currentDate > feature.validTill) { + feature.activated = false; + feature.upgradeToPlan = feature.upgradeToPlan || brackets.config.main_pro_plan; + feature.subscribeURL = feature.subscribeURL || brackets.config.purchase_url; + } + } + } + /** * Get effective entitlements for determining feature availability throughout the app. * This is the primary API that should be used across Phoenix to check entitlements and enable/disable features. @@ -479,13 +512,12 @@ define(function (require, exports, module) { // User has active trial if (serverEntitlements && serverEntitlements.plan) { // Logged-in user with trial + _validateAndFilterEntitlements(serverEntitlements); // will prune invalid entitlements if (serverEntitlements.plan.paidSubscriber) { // Already a paid subscriber, return as-is - // todo we need to check and filter valid till for each fields that we are interested in. return serverEntitlements; } // Enhance entitlements for trial user - // todo we need to prune and filter serverEntitlements valid till for each fields that we are interested in. // ie if any entitlement has valid till expired, we need to deactivate that entitlement return { ...serverEntitlements, diff --git a/src/services/login-utils.js b/src/services/login-utils.js index 6c306cc807..cc6d0d02ab 100644 --- a/src/services/login-utils.js +++ b/src/services/login-utils.js @@ -25,9 +25,15 @@ define(function (require, exports, module) { + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + // integrated extensions will have access to kernal mode, but not external extensions + throw new Error("Login utils should have access to KernalModeTrust. Cannot boot without trust ring"); + } + /** * Check if any validTill time has expired - * + * * @param {Object|null} entitlements - Current entitlements object * @param {Object|null} lastRecordedEntitlement - Previously recorded entitlements * @returns {string|null} - Name of expired plan/entitlement or null if none expired @@ -85,7 +91,7 @@ define(function (require, exports, module) { /** * Check if entitlements have changed from last recorded state - * + * * @param {Object|null} current - Current entitlements object * @param {Object|null} last - Last recorded entitlements object * @returns {boolean} - True if entitlements have changed, false otherwise @@ -129,7 +135,13 @@ define(function (require, exports, module) { return false; } - // Export functions - exports.validTillExpired = validTillExpired; - exports.haveEntitlementsChanged = haveEntitlementsChanged; -}); \ No newline at end of file + KernalModeTrust.LoginUtils = { + validTillExpired, + haveEntitlementsChanged + }; + // Test only Export functions + if(Phoenix.isTestWindow) { + exports.validTillExpired = validTillExpired; + exports.haveEntitlementsChanged = haveEntitlementsChanged; + } +}); diff --git a/src/services/profile-menu.js b/src/services/profile-menu.js index 7b918bc0c8..113e9431e6 100644 --- a/src/services/profile-menu.js +++ b/src/services/profile-menu.js @@ -436,7 +436,7 @@ define(function (require, exports, module) { initials: profileData.profileIcon.initials, avatarColor: profileData.profileIcon.color, planClass: "user-plan-free", - planName: "Free Plan", + planName: Strings.USER_FREE_PLAN_NAME, titleText: "Ai Quota Used", usageText: "100 / 200 credits", usedPercent: 0, From 3426dc6f0efd33b7ce8b800e934c4c4cc2c42c19 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 18 Sep 2025 16:23:05 +0530 Subject: [PATCH 2/3] test: tests for entitlement validations and deactivation on expired validtill --- src/services/login-service.js | 22 ++-- test/spec/promotions-integ-test.js | 166 +++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src/services/login-service.js b/src/services/login-service.js index c88d946299..2bf846a781 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -50,6 +50,7 @@ define(function (require, exports, module) { // save a copy of window.fetch so that extensions wont tamper with it. let fetchFn = window.fetch; + let dateNowFn = Date.now; const KernalModeTrust = window.KernalModeTrust; if(!KernalModeTrust){ @@ -383,14 +384,14 @@ define(function (require, exports, module) { return; } - const currentDate = Date.now(); + const currentDate = dateNowFn(); if(entitlements.plan && (!entitlements.plan.validTill || currentDate > entitlements.plan.validTill)) { entitlements.plan = { ...entitlements.plan, paidSubscriber: false, name: Strings.USER_FREE_PLAN_NAME, - validTill: Date.now() + (FREE_PLAN_VALIDITY_DAYS * MS_IN_DAY) + validTill: currentDate + (FREE_PLAN_VALIDITY_DAYS * MS_IN_DAY) }; } @@ -401,10 +402,11 @@ define(function (require, exports, module) { for(const featureName in featureEntitlements) { const feature = featureEntitlements[featureName]; - if(feature && feature.validTill && currentDate > feature.validTill) { + if(feature && (!feature.validTill || currentDate > feature.validTill)) { feature.activated = false; feature.upgradeToPlan = feature.upgradeToPlan || brackets.config.main_pro_plan; feature.subscribeURL = feature.subscribeURL || brackets.config.purchase_url; + feature.validTill = feature.validTill || (currentDate - MS_IN_DAY); } } } @@ -525,7 +527,7 @@ define(function (require, exports, module) { ...serverEntitlements.plan, paidSubscriber: true, name: brackets.config.main_pro_plan, - validTill: Date.now() + trialDaysRemaining * MS_IN_DAY + validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY }, isInProTrial: true, trialDaysRemaining: trialDaysRemaining, @@ -535,7 +537,7 @@ define(function (require, exports, module) { activated: true, subscribeURL: brackets.config.purchase_url, upgradeToPlan: brackets.config.main_pro_plan, - validTill: Date.now() + trialDaysRemaining * MS_IN_DAY + validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY } } }; @@ -546,7 +548,7 @@ define(function (require, exports, module) { plan: { paidSubscriber: true, name: brackets.config.main_pro_plan, - validTill: Date.now() + trialDaysRemaining * MS_IN_DAY + validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY }, isInProTrial: true, trialDaysRemaining: trialDaysRemaining, @@ -555,7 +557,7 @@ define(function (require, exports, module) { activated: true, subscribeURL: brackets.config.purchase_url, upgradeToPlan: brackets.config.main_pro_plan, - validTill: Date.now() + trialDaysRemaining * MS_IN_DAY + validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY } } }; @@ -574,7 +576,11 @@ define(function (require, exports, module) { LoginService, setFetchFn: function _setFetchFn(fn) { fetchFn = fn; - } + }, + setDateNowFn: function _setDdateNowFn(fn) { + dateNowFn = fn; + }, + _validateAndFilterEntitlements: _validateAndFilterEntitlements }; } diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index 69fe3a55cb..a643679fef 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -782,5 +782,171 @@ define(function (require, exports, module) { }; }); }); + + describe("Entitlements Validation", function () { + let LoginServiceExports; + + beforeEach(function() { + // Access login service exports + LoginServiceExports = testWindow._test_login_service_exports; + + // Set up time mocking + if (LoginServiceExports.setDateNowFn) { + LoginServiceExports.setDateNowFn(() => mockNow); + } + }); + + afterEach(function() { + // Reset time function + if (LoginServiceExports.setDateNowFn) { + LoginServiceExports.setDateNowFn(Date.now); + } + }); + + it("should handle expired plans correctly", function () { + + // Test expired plan gets reset to free plan + const expiredPlanEntitlements = { + plan: { + paidSubscriber: true, + name: "Phoenix Pro", + validTill: mockNow - 86400000 // 1 day ago + }, + entitlements: {} + }; + + LoginServiceExports._validateAndFilterEntitlements(expiredPlanEntitlements); + + expect(expiredPlanEntitlements.plan.paidSubscriber).toBe(false); + expect(expiredPlanEntitlements.plan.name).toBe(testWindow.Strings.USER_FREE_PLAN_NAME); + expect(expiredPlanEntitlements.plan.validTill).toBeGreaterThan(mockNow); + + // Test valid plan remains unchanged + const validPlanEntitlements = { + plan: { + paidSubscriber: true, + name: "Phoenix Pro", + validTill: mockNow + 86400000 // 1 day from now + }, + entitlements: {} + }; + const originalPlan = JSON.parse(JSON.stringify(validPlanEntitlements.plan)); + + LoginServiceExports._validateAndFilterEntitlements(validPlanEntitlements); + + expect(validPlanEntitlements.plan).toEqual(originalPlan); + + // Test missing validTill gets reset + const noValidTillEntitlements = { + plan: { + paidSubscriber: true, + name: "Phoenix Pro" + }, + entitlements: {} + }; + + LoginServiceExports._validateAndFilterEntitlements(noValidTillEntitlements); + + expect(noValidTillEntitlements.plan.paidSubscriber).toBe(false); + expect(noValidTillEntitlements.plan.name).toBe(testWindow.Strings.USER_FREE_PLAN_NAME); + }); + + it("should validate and filter expired feature entitlements", function () { + // Test expired features get deactivated + const entitlementsWithExpiredFeatures = { + plan: { + paidSubscriber: true, + name: "Phoenix Pro", + validTill: mockNow + 86400000 + }, + entitlements: { + liveEdit: { + activated: true, + validTill: mockNow - 86400000 // expired + }, + liveEditAI: { + activated: true, + validTill: mockNow + 86400000 // valid + } + } + }; + + LoginServiceExports._validateAndFilterEntitlements(entitlementsWithExpiredFeatures); + + expect(entitlementsWithExpiredFeatures.entitlements.liveEdit.activated).toBe(false); + expect(entitlementsWithExpiredFeatures.entitlements.liveEditAI.activated) + .toBe(true); // should remain unchanged + expect(entitlementsWithExpiredFeatures.entitlements.liveEdit.upgradeToPlan) + .toBe(testWindow.brackets.config.main_pro_plan); + expect(entitlementsWithExpiredFeatures.entitlements.liveEdit.subscribeURL) + .toBe(testWindow.brackets.config.purchase_url); + + // Test features without validTill get deactivated (treated as expired) + const entitlementsNoValidTill = { + plan: { + paidSubscriber: true, + name: "Phoenix Pro", + validTill: mockNow + 86400000 + }, + entitlements: { + liveEdit: { + activated: true + // no validTill property - should be treated as expired + } + } + }; + + LoginServiceExports._validateAndFilterEntitlements(entitlementsNoValidTill); + + expect(entitlementsNoValidTill.entitlements.liveEdit.activated) + .toBe(false); // should be deactivated + expect(entitlementsNoValidTill.entitlements.liveEdit.upgradeToPlan) + .toBe(testWindow.brackets.config.main_pro_plan); + expect(entitlementsNoValidTill.entitlements.liveEdit.subscribeURL) + .toBe(testWindow.brackets.config.purchase_url); + expect(entitlementsNoValidTill.entitlements.liveEdit.validTill < mockNow) + .toBeTrue(); // should be set to past date + }); + + it("should handle null and edge cases safely", function () { + const validateFn = LoginServiceExports._validateAndFilterEntitlements; + + // Test null entitlements + expect(function() { + validateFn(null); + }).not.toThrow(); + + // Test undefined entitlements + expect(function() { + validateFn(undefined); + }).not.toThrow(); + + // Test entitlements without plan + const noPlanEntitlements = { + entitlements: {} + }; + expect(function() { + validateFn(noPlanEntitlements); + }).not.toThrow(); + + // Test entitlements without entitlements object + const noEntitlementsObj = { + plan: { + paidSubscriber: true, + name: "Phoenix Pro", + validTill: mockNow + 86400000 + } + }; + expect(function() { + validateFn(noEntitlementsObj); + }).not.toThrow(); + + // Test empty entitlements object + const emptyEntitlements = {}; + expect(function() { + validateFn(emptyEntitlements); + }).not.toThrow(); + }); + }); }); }); From 6ce8934fe75846cffc018c9395368e239d971b51 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 18 Sep 2025 16:46:06 +0530 Subject: [PATCH 3/3] test: integ tests for entitlement validations and deactivation on expired --- src/services/login-service.js | 2 +- test/spec/login-browser-integ-test.js | 10 +++- test/spec/login-desktop-integ-test.js | 10 +++- test/spec/login-shared.js | 84 +++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/services/login-service.js b/src/services/login-service.js index 2bf846a781..75fe439749 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -502,6 +502,7 @@ define(function (require, exports, module) { async function getEffectiveEntitlements(forceRefresh = false) { // Get raw server entitlements const serverEntitlements = await getEntitlements(forceRefresh); + _validateAndFilterEntitlements(serverEntitlements); // will prune invalid entitlements // Get trial days remaining const trialDaysRemaining = await LoginService.getProTrialDaysRemaining(); @@ -514,7 +515,6 @@ define(function (require, exports, module) { // User has active trial if (serverEntitlements && serverEntitlements.plan) { // Logged-in user with trial - _validateAndFilterEntitlements(serverEntitlements); // will prune invalid entitlements if (serverEntitlements.plan.paidSubscriber) { // Already a paid subscriber, return as-is return serverEntitlements; diff --git a/test/spec/login-browser-integ-test.js b/test/spec/login-browser-integ-test.js index f058a5e3d6..d0992d83f7 100644 --- a/test/spec/login-browser-integ-test.js +++ b/test/spec/login-browser-integ-test.js @@ -126,7 +126,7 @@ define(function (require, exports, module) { // Note: We can't easily reset login state, so tests should handle this }); - function setupProUserMock(hasActiveSubscription = true) { + function setupProUserMock(hasActiveSubscription = true, expiredEntitlements = false) { let userSignedOut = false; // Set fetch mock on both browser and service exports @@ -174,15 +174,19 @@ define(function (require, exports, module) { }; if (hasActiveSubscription) { + const validTill = expiredEntitlements ? + Date.now() - 86400000 : // expired yesterday + Date.now() + 30 * 24 * 60 * 60 * 1000; // valid for 30 days + entitlementsResponse.plan = { paidSubscriber: true, name: "Phoenix Pro", - validTill: Date.now() + 30 * 24 * 60 * 60 * 1000 + validTill: validTill }; entitlementsResponse.entitlements = { liveEdit: { activated: true, - validTill: Date.now() + 30 * 24 * 60 * 60 * 1000 + validTill: validTill } }; } else { diff --git a/test/spec/login-desktop-integ-test.js b/test/spec/login-desktop-integ-test.js index bf5daa4cd6..014cdff84b 100644 --- a/test/spec/login-desktop-integ-test.js +++ b/test/spec/login-desktop-integ-test.js @@ -136,7 +136,7 @@ define(function (require, exports, module) { // Note: We can't easily reset login state, so tests should handle this }); - function setupProUserMock(hasActiveSubscription = true) { + function setupProUserMock(hasActiveSubscription = true, expiredEntitlements = false) { let userSignedOut = false; // Set fetch mock on desktop exports @@ -196,15 +196,19 @@ define(function (require, exports, module) { }; if (hasActiveSubscription) { + const validTill = expiredEntitlements ? + Date.now() - 86400000 : // expired yesterday + Date.now() + 30 * 24 * 60 * 60 * 1000; // valid for 30 days + entitlementsResponse.plan = { paidSubscriber: true, name: "Phoenix Pro", - validTill: Date.now() + 30 * 24 * 60 * 60 * 1000 + validTill: validTill }; entitlementsResponse.entitlements = { liveEdit: { activated: true, - validTill: Date.now() + 30 * 24 * 60 * 60 * 1000 + validTill: validTill } }; } else { diff --git a/test/spec/login-shared.js b/test/spec/login-shared.js index ca8842c765..291a3acb08 100644 --- a/test/spec/login-shared.js +++ b/test/spec/login-shared.js @@ -432,6 +432,90 @@ define(function (require, exports, module) { // Close popup $profileButton.trigger('click'); }); + + it("should show free user popup when entitlements are expired (no trial)", async function () { + console.log("llgT: Starting expired entitlements without trial test"); + + // Setup: Expired pro subscription + no trial + setupProUserMock(true, true); + await cleanupTrialState(); // Ensure no trial is active + + // Verify initial state (no pro branding due to expired entitlements) + await verifyProBranding(false, "no pro branding initially due to expired entitlements"); + + // Perform login + await performFullLoginFlow(); + + // Verify pro branding remains false after login (expired entitlements filtered to free) + await verifyProBranding(false, "no pro branding after login with expired entitlements"); + + // Check profile popup shows free plan status + const $profileButton = testWindow.$("#user-profile-button"); + $profileButton.trigger('click'); + await popupToAppear(PROFILE_POPUP); + await verifyProfilePopupContent(VIEW_PHOENIX_FREE, + "free plan user profile popup for user with expired entitlements"); + + // Close popup + $profileButton.trigger('click'); + + // Perform logout + await performFullLogoutFlow(); + + // Verify pro branding remains false after logout + await verifyProBranding(false, "no pro branding after logout with expired entitlements"); + + // Check profile popup (signed out state) + $profileButton.trigger('click'); + await popupToAppear(SIGNIN_POPUP); + // Not logged in user with no trial - no special branding expected + expect(testWindow.$(`.profile-popup .trial-plan-info`).length).toBe(0); + + // Close popup + $profileButton.trigger('click'); + }); + + it("should show trial user popup when entitlements are expired (active trial)", async function () { + console.log("llgT: Starting expired entitlements with active trial test"); + + // Setup: Expired pro subscription + active trial (10 days) + setupProUserMock(true, true); + await setupTrialState(10); + + // Verify initial state shows pro branding due to trial (overrides expired entitlements) + await verifyProBranding(true, "pro branding initially due to active trial"); + + // Perform login + await performFullLoginFlow(); + + // Verify pro branding remains after login (trial overrides expired server entitlements) + await verifyProBranding(true, "pro branding after login - trial overrides expired entitlements"); + + // Check profile popup shows trial status (not expired server entitlements) + const $profileButton = testWindow.$("#user-profile-button"); + $profileButton.trigger('click'); + await popupToAppear(PROFILE_POPUP); + await verifyProfilePopupContent(VIEW_TRIAL_DAYS_LEFT, + "trial user profile popup - trial overrides expired server entitlements"); + + // Close popup + $profileButton.trigger('click'); + + // Perform logout + await performFullLogoutFlow(); + + // Verify pro branding remains after logout (trial continues) + await verifyProBranding(true, "pro branding after logout - trial still active"); + + // Check profile popup still shows trial status + $profileButton.trigger('click'); + await popupToAppear(SIGNIN_POPUP); + await verifyProfilePopupContent(VIEW_TRIAL_DAYS_LEFT, + "trial user profile popup for logged out user"); + + // Close popup + $profileButton.trigger('click'); + }); } exports.setup = setup;