From fcc163bb9148fc522a762a830286878d923ad999 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 10 Sep 2025 16:13:18 +0530 Subject: [PATCH 01/13] test: pro promotions integ tests for non logged in case --- src/services/login-browser.js | 7 +- src/services/login-desktop.js | 9 +- src/services/login-service.js | 5 +- src/services/promotions.js | 50 ++++- test/UnitTestSuite.js | 1 + test/spec/promotions-integ-test.js | 330 +++++++++++++++++++++++++++++ 6 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 test/spec/promotions-integ-test.js diff --git a/src/services/login-browser.js b/src/services/login-browser.js index e6b2351992..b228a92428 100644 --- a/src/services/login-browser.js +++ b/src/services/login-browser.js @@ -100,6 +100,9 @@ define(function (require, exports, module) { const ERR_INVALID = "invalid"; const ERR_NOT_LOGGED_IN = "not_logged_in"; + // save a copy of window.fetch so that extensions wont tamper with it. + let fetchFn = window.fetch; + /** * Resolve browser session using cookies * @return {Promise} A promise resolving to user profile or error object @@ -110,7 +113,7 @@ define(function (require, exports, module) { return {err: ERR_RETRY_LATER}; } try { - const response = await fetch(resolveURL, { + const response = await fetchFn(resolveURL, { method: 'GET', credentials: 'include', // Include cookies headers: { @@ -316,7 +319,7 @@ define(function (require, exports, module) { async function signOutBrowser() { const logoutURL = `${_getAccountBaseURL()}/signOut`; try { - const response = await fetch(logoutURL, { + const response = await fetchFn(logoutURL, { method: 'POST', credentials: 'include', // Include cookies headers: { diff --git a/src/services/login-desktop.js b/src/services/login-desktop.js index 98a2969ab7..c38f4863c6 100644 --- a/src/services/login-desktop.js +++ b/src/services/login-desktop.js @@ -46,6 +46,9 @@ define(function (require, exports, module) { let userProfile = null; let isLoggedInUser = false; + // save a copy of window.fetch so that extensions wont tamper with it. + let fetchFn = window.fetch; + // just used as trigger to notify different windows about user profile changes const PREF_USER_PROFILE_VERSION = "userProfileVersion"; @@ -98,7 +101,7 @@ define(function (require, exports, module) { return {err: ERR_RETRY_LATER}; } try { - const response = await fetch(resolveURL); + const response = await fetchFn(resolveURL); if (response.status === 400 || response.status === 404) { // 404 api key not found and 400 Bad Request, eg: verification code mismatch return {err: ERR_INVALID}; @@ -192,7 +195,7 @@ define(function (require, exports, module) { const resolveURL = `${Phoenix.config.account_url}getAppAuthSession?autoAuthPort=${authPortURL}&appName=${appName}`; // {"isSuccess":true,"appSessionID":"a uuid...","validationCode":"SWXP07"} try { - const response = await fetch(resolveURL); + const response = await fetchFn(resolveURL); if (response.ok) { const {appSessionID, validationCode} = await response.json(); if(!appSessionID || !validationCode) { @@ -345,7 +348,7 @@ define(function (require, exports, module) { appSessionID: userProfile.apiKey }; - const response = await fetch(resolveURL, { + const response = await fetchFn(resolveURL, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/src/services/login-service.js b/src/services/login-service.js index 028759a0be..d31e41f438 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -33,6 +33,9 @@ define(function (require, exports, module) { const MS_IN_DAY = 10 * 24 * 60 * 60 * 1000; const TEN_MINUTES = 10 * 60 * 1000; + // save a copy of window.fetch so that extensions wont tamper with it. + let fetchFn = window.fetch; + const KernalModeTrust = window.KernalModeTrust; if(!KernalModeTrust){ // integrated extensions will have access to kernal mode, but not external extensions @@ -107,7 +110,7 @@ define(function (require, exports, module) { fetchOptions.credentials = 'include'; } - const response = await fetch(url, fetchOptions); + const response = await fetchFn(url, fetchOptions); if (response.ok) { const result = await response.json(); diff --git a/src/services/promotions.js b/src/services/promotions.js index 97263b8702..533a1b1293 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -25,7 +25,7 @@ * Provides loginless pro trials * * - First install: 30-day trial on first usage - * - Subsequent versions: 3-day trial (or remaining from 30-day if still valid) + * - Subsequent versions: 7-day trial (or remaining from 30-day if still valid) * - Older versions: No new trial, but existing 30-day trial remains valid */ @@ -36,6 +36,7 @@ define(function (require, exports, module) { semver = require("thirdparty/semver.browser"), ProDialogs = require("./pro-dialogs"); + let dateNowFn = Date.now; const KernalModeTrust = window.KernalModeTrust; if (!KernalModeTrust) { throw new Error("Promotions service requires access to KernalModeTrust. Cannot boot without trust ring"); @@ -47,7 +48,7 @@ define(function (require, exports, module) { const EVENT_PRO_UPGRADE_ON_INSTALL = "pro_upgrade_on_install"; const TRIAL_POLL_MS = 10 * 1000; // 10 seconds after start, we assign a free trial if possible const FIRST_INSTALL_TRIAL_DAYS = 30; - const SUBSEQUENT_TRIAL_DAYS = 3; + const SUBSEQUENT_TRIAL_DAYS = 7; const MS_PER_DAY = 24 * 60 * 60 * 1000; /** @@ -151,7 +152,7 @@ define(function (require, exports, module) { * Calculate remaining trial days from end date */ function _calculateRemainingTrialDays(existingTrialData) { - const now = Date.now(); + const now = dateNowFn(); const trialEndDate = existingTrialData.endDate; // Calculate days remaining until trial ends @@ -209,7 +210,7 @@ define(function (require, exports, module) { let trialDays = FIRST_INSTALL_TRIAL_DAYS; let endDate; - const now = Date.now(); + const now = dateNowFn(); let metricString = `${currentVersion.replaceAll(".", "_")}`; // 3.1.0 -> 3_1_0 if (existingTrialData) { @@ -250,7 +251,7 @@ define(function (require, exports, module) { endDate = existingTrialData.endDate; metricString = `nD_${metricString}_upgrade`; } else { - // Newer version with shorter existing trial - give 3 days + // Newer version with shorter existing trial - give 7 days console.log(`Newer version - granting ${SUBSEQUENT_TRIAL_DAYS} days trial`); trialDays = SUBSEQUENT_TRIAL_DAYS; endDate = now + (trialDays * MS_PER_DAY); @@ -328,5 +329,44 @@ define(function (require, exports, module) { LoginService.getProTrialDaysRemaining = getProTrialDaysRemaining; LoginService.EVENT_PRO_UPGRADE_ON_INSTALL = EVENT_PRO_UPGRADE_ON_INSTALL; + // Test-only exports for integration testing + if (Phoenix.isTestWindow) { + window._test_login_exports = { + LoginService: LoginService, + ProDialogs: ProDialogs, + _getTrialData: _getTrialData, + _setTrialData: _setTrialData, + _cleanTrialData: async function() { + try { + if (Phoenix.isNativeApp) { + await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS); + } else { + const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json"; + return new Promise((resolve) => { + window.fs.unlink(filePath, () => { + // Always resolve, ignore errors since file might not exist + resolve(); + }); + }); + } + } catch (error) { + // Ignore cleanup errors + console.log("Trial data cleanup completed (ignoring errors)"); + } + }, + activateProTrial: activateProTrial, + getProTrialDaysRemaining: getProTrialDaysRemaining, + setDateNowFn: function _setDdateNowFn(fn) { + dateNowFn = fn; + }, + EVENT_PRO_UPGRADE_ON_INSTALL: EVENT_PRO_UPGRADE_ON_INSTALL, + TRIAL_CONSTANTS: { + FIRST_INSTALL_TRIAL_DAYS, + SUBSEQUENT_TRIAL_DAYS, + MS_PER_DAY + } + }; + } + // no public exports to prevent extension tampering }); diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 468430c71e..d228359a49 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -118,6 +118,7 @@ define(function (require, exports, module) { require("spec/TaskManager-integ-test"); require("spec/Generic-integ-test"); require("spec/spacing-auto-detect-integ-test"); + require("spec/promotions-integ-test"); require("spec/LocalizationUtils-test"); require("spec/ScrollTrackHandler-integ-test"); require("spec/login-utils-test"); diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js new file mode 100644 index 0000000000..4029d22dc3 --- /dev/null +++ b/test/spec/promotions-integ-test.js @@ -0,0 +1,330 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, awaits*/ + +define(function (require, exports, module) { + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + describe("integration:Promotions", function () { + + let testWindow, + LoginService, + ProDialogs, + originalAppConfig, + mockNow = 1000000000000; // Fixed timestamp for consistent testing + + beforeAll(async function () { + testWindow = await SpecRunnerUtils.createTestWindowAndRun(); + + // Access modules from test window + LoginService = testWindow._test_login_exports; + ProDialogs = testWindow._test_login_exports.ProDialogs; + + // Debug: Check what's available in the exports + console.log('Debug: Available exports:', Object.keys(LoginService)); + console.log('Debug: setDateNowFn available?', !!LoginService.setDateNowFn); + + // Use the new setDateNowFn injection mechanism + if (LoginService.setDateNowFn) { + LoginService.setDateNowFn(() => { + return mockNow; + }); + } else { + throw new Error('setDateNowFn not available in test exports'); + } + + // Store original config and mock AppConfig for tests + originalAppConfig = testWindow.AppConfig; + testWindow.AppConfig = { + version: "3.1.0", + apiVersion: "3.1.0" + }; + }, 30000); + + afterAll(async function () { + // Restore original values + if (originalAppConfig) { + testWindow.AppConfig = originalAppConfig; + } + + testWindow = null; + LoginService = null; + ProDialogs = null; + await SpecRunnerUtils.closeTestWindow(); + }, 30000); + + describe("Trial Activation", function () { + + it("should have access to trial functions", function () { + // Basic test to verify our exports work + expect(LoginService._getTrialData).toBeDefined(); + expect(LoginService._setTrialData).toBeDefined(); + expect(LoginService._cleanTrialData).toBeDefined(); + expect(LoginService.activateProTrial).toBeDefined(); + expect(LoginService.getProTrialDaysRemaining).toBeDefined(); + expect(LoginService.setDateNowFn).toBeDefined(); + }); + + it("should activate 30-day trial on first install (not logged in)", async function () { + // Note: This test assumes user is not logged in, so _hasProSubscription will return false + // Clear any existing trial data first + await LoginService._cleanTrialData(); + + // Call the function - this simulates first install scenario + await LoginService.activateProTrial(); + + // Get the trial data that was actually stored + const storedTrialData = await LoginService._getTrialData(); + + // Verify trial data was set correctly + expect(storedTrialData).not.toBeNull(); + expect(storedTrialData.proVersion).toBe("3.1.0"); + + // Check that a 30-day trial was activated with mocked time + const expectedEndDate = mockNow + (30 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); + expect(storedTrialData.endDate).toBe(expectedEndDate); + + // Verify upgrade dialog appears with correct content + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + expect(modalContent.length).toBeGreaterThan(0); + + // Check dialog content + const dialogText = modalContent.text(); + expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); + expect(dialogText).toContain('Phoenix Pro'); + expect(dialogText).toContain('30 days'); + + // Close the dialog + testWindow.$('.modal .btn').first().click(); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + }); + + it("should activate 7-day trial on version upgrade (not logged in)", async function () { + const existingTrial = { + proVersion: "3.0.0", + endDate: mockNow - (1 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY), // Expired yesterday + signature: "mock_signature" + }; + + // Set up existing trial data first + await LoginService._setTrialData(existingTrial); + + await LoginService.activateProTrial(); + + // Get the updated trial data + const updatedTrialData = await LoginService._getTrialData(); + + // Verify new trial data was set for newer version + expect(updatedTrialData).not.toBeNull(); + expect(updatedTrialData.proVersion).toBe("3.1.0"); + + // Check that 3-day trial was granted with mocked time + const expectedEndDate = mockNow + (LoginService.TRIAL_CONSTANTS.SUBSEQUENT_TRIAL_DAYS + * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); + expect(updatedTrialData.endDate).toBe(expectedEndDate); + + // Verify upgrade dialog appears with correct content + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + expect(modalContent.length).toBeGreaterThan(0); + + // Check dialog content + const dialogText = modalContent.text(); + expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); + expect(dialogText).toContain('Phoenix Pro'); + expect(dialogText).toContain('7 days'); + + // Close the dialog + testWindow.$('.modal .btn').first().click(); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + }); + + it("should keep existing trial if longer than 7 days on version upgrade (not logged in)", async function () { + const futureEndDate = mockNow + (10 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); + const existingTrial = { + proVersion: "3.0.0", + endDate: futureEndDate, + signature: "mock_signature" + }; + + // Set up existing trial data first + await LoginService._setTrialData(existingTrial); + + await LoginService.activateProTrial(); + + // Get the updated trial data + const updatedTrialData = await LoginService._getTrialData(); + + // Verify existing trial was preserved but version updated + expect(updatedTrialData).not.toBeNull(); + expect(updatedTrialData.proVersion).toBe("3.1.0"); + expect(updatedTrialData.endDate).toBe(futureEndDate); + + // Skip dialog testing + try { + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + if (modalContent.length > 0) { + testWindow.$('.modal .btn').first().click(); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + } + } catch (e) { + console.log('Dialog test skipped:', e.message); + } + }); + + // Note: Cannot easily test pro user scenarios in integration tests + // since _hasProSubscription is private and depends on actual login state + + it("should not activate trial for same version (not logged in)", async function () { + const existingTrial = { + proVersion: "3.1.0", // Same version + endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY), + signature: "mock_signature" + }; + + // Set up existing trial data first + await LoginService._setTrialData(existingTrial); + + await LoginService.activateProTrial(); + + // Get the trial data after activation + const currentTrialData = await LoginService._getTrialData(); + + // Verify trial data remains unchanged (same version, same end date) + expect(currentTrialData.proVersion).toBe("3.1.0"); + expect(currentTrialData.endDate).toBe(existingTrial.endDate); + + // For same version, no dialog should appear + await awaits(500); + const modalContent = testWindow.$('.modal:visible'); + expect(modalContent.length).toBe(0); + }); + + it("should not activate trial for older current version (not logged in)", async function () { + const existingTrial = { + proVersion: "3.2.0", // Newer than current 3.1.0 + endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY), + signature: "mock_signature" + }; + + // Set up existing trial data first + await LoginService._setTrialData(existingTrial); + + await LoginService.activateProTrial(); + + // Get the trial data after activation + const currentTrialData = await LoginService._getTrialData(); + + // Verify trial data remains unchanged (older current version scenario) + expect(currentTrialData.proVersion).toBe("3.2.0"); // Should preserve original version + expect(currentTrialData.endDate).toBe(existingTrial.endDate); // Should preserve end date + + // For older current version, no dialog should appear + await awaits(500); + const modalContent = testWindow.$('.modal:visible'); + expect(modalContent.length).toBe(0); + }); + }); + + describe("Trial Expiration", function () { + + it("should show promo ended dialog when trial expires (not logged in)", async function () { + const expiredTrial = { + proVersion: "3.0.0", + endDate: mockNow - LoginService.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday + signature: "mock_signature" + }; + + // Set up expired trial data first + await LoginService._setTrialData(expiredTrial); + + await LoginService.activateProTrial(); + + // Get the updated trial data + const updatedTrialData = await LoginService._getTrialData(); + + // Verify upgrade dialog shown flag was set + expect(updatedTrialData).not.toBeNull(); + expect(updatedTrialData.upgradeDialogShownVersion).toBe("3.1.0"); + expect(updatedTrialData.proVersion).toBe("3.0.0"); // Should preserve original version + expect(updatedTrialData.endDate).toBe(expiredTrial.endDate); // Should preserve end date + + // Wait for modal dialog and verify it's the "ended" dialog + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + expect(modalContent.length).toBeGreaterThan(0); + + // Check if it's the "ended" dialog (different text than upgrade) + const dialogText = modalContent.text(); + expect(dialogText).toContain('Phoenix Pro'); + // Note: Ended dialog would have different text than upgrade dialog + + // Close the dialog + testWindow.$('.modal .btn').first().click(); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + }); + + // Note: Additional expiration scenarios (dialog already shown, pro users) + // are difficult to test without mocking private functions + }); + + describe("Trial Days Calculation", function () { + + it("should return remaining trial days", async function () { + const futureEndDate = mockNow + (5.7 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); + const trialData = { + proVersion: "3.1.0", + endDate: futureEndDate + }; + + // Set up trial data + await LoginService._setTrialData(trialData); + + const remainingDays = await LoginService.getProTrialDaysRemaining(); + + // Should round up to 6 days + expect(remainingDays).toBe(6); + }); + + it("should return 0 for expired trials", async function () { + const pastEndDate = mockNow - (2 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); + const trialData = { + proVersion: "3.1.0", + endDate: pastEndDate + }; + + // Set up expired trial data + await LoginService._setTrialData(trialData); + + const remainingDays = await LoginService.getProTrialDaysRemaining(); + + expect(remainingDays).toBe(0); + }); + }); + + // Note: Version comparison, pro subscription checks, and event triggering + // are internal implementation details that are difficult to test reliably + // in integration tests without extensive mocking of private functions + }); +}); From bdf6c83246ef22006e9cb9a72f411d3e4b6d83f5 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 10 Sep 2025 16:19:21 +0530 Subject: [PATCH 02/13] test: pro promotions integ tests for non logged in case --- test/spec/promotions-integ-test.js | 44 ++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index 4029d22dc3..acc6ca312a 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -251,7 +251,7 @@ define(function (require, exports, module) { it("should show promo ended dialog when trial expires (not logged in)", async function () { const expiredTrial = { - proVersion: "3.0.0", + proVersion: "3.1.0", // Same version as current to trigger ended dialog endDate: mockNow - LoginService.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday signature: "mock_signature" }; @@ -267,7 +267,7 @@ define(function (require, exports, module) { // Verify upgrade dialog shown flag was set expect(updatedTrialData).not.toBeNull(); expect(updatedTrialData.upgradeDialogShownVersion).toBe("3.1.0"); - expect(updatedTrialData.proVersion).toBe("3.0.0"); // Should preserve original version + expect(updatedTrialData.proVersion).toBe("3.1.0"); // Should preserve original version expect(updatedTrialData.endDate).toBe(expiredTrial.endDate); // Should preserve end date // Wait for modal dialog and verify it's the "ended" dialog @@ -278,7 +278,45 @@ define(function (require, exports, module) { // Check if it's the "ended" dialog (different text than upgrade) const dialogText = modalContent.text(); expect(dialogText).toContain('Phoenix Pro'); - // Note: Ended dialog would have different text than upgrade dialog + expect(dialogText).toContain('Trial has ended'); + + // Close the dialog + testWindow.$('.modal .btn').first().click(); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + }); + + it("should grant new trial when expired trial is from older version (not logged in)", async function () { + const expiredTrial = { + proVersion: "3.0.0", // Older version than current 3.1.0 + endDate: mockNow - LoginService.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday + signature: "mock_signature" + }; + + // Set up expired trial data first + await LoginService._setTrialData(expiredTrial); + + await LoginService.activateProTrial(); + + // Get the updated trial data + const updatedTrialData = await LoginService._getTrialData(); + + // Verify new trial was granted for version upgrade + expect(updatedTrialData).not.toBeNull(); + expect(updatedTrialData.proVersion).toBe("3.1.0"); // Should update to current version + + // Should grant 7-day trial for version upgrade + const expectedEndDate = mockNow + (7 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); + expect(updatedTrialData.endDate).toBe(expectedEndDate); + + // Should show upgrade dialog (not ended dialog) + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + expect(modalContent.length).toBeGreaterThan(0); + + const dialogText = modalContent.text(); + expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); + expect(dialogText).toContain('Phoenix Pro'); + expect(dialogText).toContain('7 days'); // Close the dialog testWindow.$('.modal .btn').first().click(); From e33bd6653e99ef28c05fdb7084978ca34592a034 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 10 Sep 2025 16:46:04 +0530 Subject: [PATCH 03/13] test: pro promotions integ test fixes --- test/spec/promotions-integ-test.js | 43 +++++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index acc6ca312a..49af0dedfe 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -115,7 +115,7 @@ define(function (require, exports, module) { expect(dialogText).toContain('30 days'); // Close the dialog - testWindow.$('.modal .btn').first().click(); + testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); await testWindow.__PR.waitForModalDialogClosed(".modal"); }); @@ -155,7 +155,7 @@ define(function (require, exports, module) { expect(dialogText).toContain('7 days'); // Close the dialog - testWindow.$('.modal .btn').first().click(); + testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); await testWindow.__PR.waitForModalDialogClosed(".modal"); }); @@ -180,17 +180,16 @@ define(function (require, exports, module) { expect(updatedTrialData.proVersion).toBe("3.1.0"); expect(updatedTrialData.endDate).toBe(futureEndDate); - // Skip dialog testing - try { - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - if (modalContent.length > 0) { - testWindow.$('.modal .btn').first().click(); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - } - } catch (e) { - console.log('Dialog test skipped:', e.message); - } + await testWindow.__PR.waitForModalDialog(".modal"); + // Check dialog content + const modalContent = testWindow.$('.modal'); + const dialogText = modalContent.text(); + expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); + expect(dialogText).toContain('Phoenix Pro'); + expect(dialogText).toContain('10 days'); + + testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); + await testWindow.__PR.waitForModalDialogClosed(".modal"); }); // Note: Cannot easily test pro user scenarios in integration tests @@ -280,8 +279,20 @@ define(function (require, exports, module) { expect(dialogText).toContain('Phoenix Pro'); expect(dialogText).toContain('Trial has ended'); - // Close the dialog - testWindow.$('.modal .btn').first().click(); + // Close the dialog, so here trial expiration has 2 dialogs, either an offline dialog or online depends + // on config. we just close em blindly. depending on config of + // `${brackets.config.promotions_url}app/config.json`. here we just close both blindly; + try{ + // + testWindow.__PR.clickDialogButtonID("secondaryButton"); + } catch (e) { + // ignored + } + try{ + testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_CANCEL); + } catch (e) { + // ignored + } await testWindow.__PR.waitForModalDialogClosed(".modal"); }); @@ -319,7 +330,7 @@ define(function (require, exports, module) { expect(dialogText).toContain('7 days'); // Close the dialog - testWindow.$('.modal .btn').first().click(); + testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); await testWindow.__PR.waitForModalDialogClosed(".modal"); }); From 4af40112f5e5ba82d1597d2318ee5d00da70161f Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 10 Sep 2025 17:07:04 +0530 Subject: [PATCH 04/13] test: remote and local promo dialog after promo end --- src/services/pro-dialogs.js | 13 ++++- src/services/promotions.js | 2 +- test/spec/promotions-integ-test.js | 86 +++++++++++++++++++++++------- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/services/pro-dialogs.js b/src/services/pro-dialogs.js index 236ec5a1ba..5575be3b1e 100644 --- a/src/services/pro-dialogs.js +++ b/src/services/pro-dialogs.js @@ -42,6 +42,9 @@ define(function (require, exports, module) { proUpgradeHTML = require("text!./html/pro-upgrade.html"), proEndedHTML = require("text!./html/promo-ended.html"); + // save a copy of window.fetch so that extensions wont tamper with it. + let fetchFn = window.fetch; + function showProUpgradeDialog(trialDays) { const title = StringUtils.format(Strings.PROMO_UPGRADE_TITLE, proTitle); const message = StringUtils.format(Strings.PROMO_UPGRADE_MESSAGE, trialDays); @@ -113,7 +116,7 @@ define(function (require, exports, module) { try { const configURL = `${brackets.config.promotions_url}app/config.json`; - const response = await fetch(configURL); + const response = await fetchFn(configURL); if (!response.ok) { _showLocalProEndedDialog(); return; @@ -130,6 +133,14 @@ define(function (require, exports, module) { } } + if (Phoenix.isTestWindow) { + window._test_pro_dlg_login_exports = { + setFetchFn: function _setDdateNowFn(fn) { + fetchFn = fn; + } + }; + } + exports.showProUpgradeDialog = showProUpgradeDialog; exports.showProEndedDialog = showProEndedDialog; }); diff --git a/src/services/promotions.js b/src/services/promotions.js index 533a1b1293..0451add62b 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -331,7 +331,7 @@ define(function (require, exports, module) { // Test-only exports for integration testing if (Phoenix.isTestWindow) { - window._test_login_exports = { + window._test_promo_login_exports = { LoginService: LoginService, ProDialogs: ProDialogs, _getTrialData: _getTrialData, diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index 49af0dedfe..2ae9453b6d 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -30,14 +30,15 @@ define(function (require, exports, module) { LoginService, ProDialogs, originalAppConfig, + originalFetch, mockNow = 1000000000000; // Fixed timestamp for consistent testing beforeAll(async function () { testWindow = await SpecRunnerUtils.createTestWindowAndRun(); // Access modules from test window - LoginService = testWindow._test_login_exports; - ProDialogs = testWindow._test_login_exports.ProDialogs; + LoginService = testWindow._test_promo_login_exports; + ProDialogs = testWindow._test_promo_login_exports.ProDialogs; // Debug: Check what's available in the exports console.log('Debug: Available exports:', Object.keys(LoginService)); @@ -52,6 +53,12 @@ define(function (require, exports, module) { throw new Error('setDateNowFn not available in test exports'); } + // Set up fetch mocking for pro dialogs + if (testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { + // Store reference for later restoration + originalFetch = testWindow.fetch; + } + // Store original config and mock AppConfig for tests originalAppConfig = testWindow.AppConfig; testWindow.AppConfig = { @@ -66,9 +73,15 @@ define(function (require, exports, module) { testWindow.AppConfig = originalAppConfig; } + // Restore original fetch if it was mocked + if (originalFetch && testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { + testWindow._test_pro_dlg_login_exports.setFetchFn(originalFetch); + } + testWindow = null; LoginService = null; ProDialogs = null; + originalFetch = null; await SpecRunnerUtils.closeTestWindow(); }, 30000); @@ -248,7 +261,7 @@ define(function (require, exports, module) { describe("Trial Expiration", function () { - it("should show promo ended dialog when trial expires (not logged in)", async function () { + async function setupExpiredTrialAndActivate() { const expiredTrial = { proVersion: "3.1.0", // Same version as current to trigger ended dialog endDate: mockNow - LoginService.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday @@ -269,30 +282,67 @@ define(function (require, exports, module) { expect(updatedTrialData.proVersion).toBe("3.1.0"); // Should preserve original version expect(updatedTrialData.endDate).toBe(expiredTrial.endDate); // Should preserve end date - // Wait for modal dialog and verify it's the "ended" dialog + return { expiredTrial, updatedTrialData }; + } + + it("should show local promo ended dialog when trial expires (offline/fetch fails)", async function () { + // Mock fetch to fail (network error) + if (testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { + testWindow._test_pro_dlg_login_exports.setFetchFn(() => { + return Promise.reject(new Error('Network error')); + }); + } + + // Set up expired trial and activate + await setupExpiredTrialAndActivate(); + + // Wait for modal dialog and verify it's the local "ended" dialog await testWindow.__PR.waitForModalDialog(".modal"); const modalContent = testWindow.$('.modal'); expect(modalContent.length).toBeGreaterThan(0); - // Check if it's the "ended" dialog (different text than upgrade) + // Verify it's the local dialog (has text content, no iframe) const dialogText = modalContent.text(); expect(dialogText).toContain('Phoenix Pro'); expect(dialogText).toContain('Trial has ended'); - // Close the dialog, so here trial expiration has 2 dialogs, either an offline dialog or online depends - // on config. we just close em blindly. depending on config of - // `${brackets.config.promotions_url}app/config.json`. here we just close both blindly; - try{ - // - testWindow.__PR.clickDialogButtonID("secondaryButton"); - } catch (e) { - // ignored - } - try{ - testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_CANCEL); - } catch (e) { - // ignored + // Verify NO iframe present (local dialog) + const iframes = modalContent.find('iframe'); + expect(iframes.length).toBe(0); + + // Close local dialog - simpler button structure + testWindow.__PR.clickDialogButtonID("secondaryButton"); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + }); + + it("should show remote promo ended dialog when trial expires (online)", async function () { + // Mock fetch to succeed with remote config + if (testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { + testWindow._test_pro_dlg_login_exports.setFetchFn(() => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + upsell_after_trial_url: "https://phcode.io", + upsell_purchase_url: "https://phcode.dev/pricing" + }) + }); + }); } + + // Set up expired trial and activate + await setupExpiredTrialAndActivate(); + + // Wait for modal dialog and verify it's the remote dialog + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + expect(modalContent.length).toBeGreaterThan(0); + + // Verify it's the remote dialog (contains iframe) + const iframes = modalContent.find('iframe'); + expect(iframes.length).toBeGreaterThan(0); + + // Close remote dialog - may have complex button structure + testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_CANCEL); await testWindow.__PR.waitForModalDialogClosed(".modal"); }); From c17920912da91f8599266b8cd988bcdd79908310 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 10 Sep 2025 18:01:34 +0530 Subject: [PATCH 05/13] fix: linux github actions integ tests fails as secrets store not in actions env --- test/spec/promotions-integ-test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index 2ae9453b6d..14416438a7 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -26,6 +26,14 @@ define(function (require, exports, module) { describe("integration:Promotions", function () { + if (Phoenix.isTestWindowGitHubActions && Phoenix.platform === "linux") { + // Credentials test doesn't work in GitHub actions in linux desktop as the runner cant reach key ring. + it("Should not run in github actions in linux desktop", async function () { + expect(1).toEqual(1); + }); + return; + } + let testWindow, LoginService, ProDialogs, From 801310a48c3daf53189c69d85f6c592555b662f1 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 14 Sep 2025 12:45:48 +0530 Subject: [PATCH 06/13] chore: add browser login auth metrics and refactors --- src/services/login-browser.js | 46 ++++++++++++++++++++++++----------- src/services/login-desktop.js | 2 +- src/services/login-service.js | 10 ++++++++ src/services/profile-menu.js | 2 +- src/services/promotions.js | 4 +-- 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/services/login-browser.js b/src/services/login-browser.js index b228a92428..75fdeb7506 100644 --- a/src/services/login-browser.js +++ b/src/services/login-browser.js @@ -151,6 +151,12 @@ define(function (require, exports, module) { PreferencesManager.stateManager.set(PREF_USER_PROFILE_VERSION, crypto.randomUUID()); } + /** + * Calls remote resolveBrowserSession endpoint to verify login status. should not be used frequently. + * @param silentCheck + * @returns {Promise} + * @private + */ async function _verifyBrowserLogin(silentCheck = false) { console.log("Verifying browser login status..."); @@ -161,28 +167,36 @@ define(function (require, exports, module) { isLoggedInUser = true; ProfileMenu.setLoggedIn(userProfile.profileIcon.initials, userProfile.profileIcon.color); console.log("Browser login verified for:", userProfile.email); + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "OKLogin"); return; } - // User is not logged in or error occurred + // User is not logged in or error occurred if here if(resolveResponse.err === ERR_NOT_LOGGED_IN) { console.log("No browser session found. Not logged in"); - // Only reset UI state if this is not a silent background check - if (!silentCheck) { - _resetBrowserLogin(); - } else { - // For silent checks, just update the internal state - isLoggedInUser = false; - userProfile = null; - } + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "NotLoggedIn"); + _handleLoginError(silentCheck); + return; + } + + if (resolveResponse.err === ERR_INVALID) { + console.log("Invalid auth token, resetting login state"); + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "invalidLogin"); + _handleLoginError(silentCheck); return; } // Other errors (network, retry later, etc.) - console.log("Browser login verification failed:", resolveResponse.err); + console.log("Browser login verification failed (temporary):", resolveResponse.err); + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "RetryLogin"); + // Don't reset login state for temporary errors, regardless of silent check + } + + function _handleLoginError(silentCheck) { if (!silentCheck) { _resetBrowserLogin(); } else { + // For silent checks, just update the internal state isLoggedInUser = false; userProfile = null; } @@ -379,13 +393,15 @@ define(function (require, exports, module) { return; } - // Always verify login on browser app start (silent check to avoid closing popups) - _verifyBrowserLogin(true).catch(console.error); + // Always verify login on browser app start + _verifyBrowserLogin().catch(console.error); // Watch for profile changes from other windows/tabs const pref = PreferencesManager.stateManager.definePreference(PREF_USER_PROFILE_VERSION, 'string', '0'); pref.watchExternalChanges(); - pref.on('change', _verifyBrowserLogin); + pref.on('change', ()=>{ + _verifyBrowserLogin(true).catch(console.error); + }); // Note: We don't do automatic verification on page focus to avoid server overload. // Automatic checks are only done during the login waiting dialog period. @@ -402,7 +418,9 @@ define(function (require, exports, module) { LoginService.signInToAccount = signInToBrowser; LoginService.signOutAccount = signOutBrowser; LoginService.getProfile = getProfile; - LoginService.verifyLoginStatus = () => _verifyBrowserLogin(false); + // verifyLoginStatus Calls remote resolveBrowserSession endpoint to verify. should not be used frequently. + // All users are required to use isLoggedIn API instead. + LoginService._verifyLoginStatus = () => _verifyBrowserLogin(false); LoginService.getAccountBaseURL = _getAccountBaseURL; init(); } diff --git a/src/services/login-desktop.js b/src/services/login-desktop.js index c38f4863c6..f0e9e70fcd 100644 --- a/src/services/login-desktop.js +++ b/src/services/login-desktop.js @@ -415,7 +415,7 @@ define(function (require, exports, module) { LoginService.signInToAccount = signInToAccount; LoginService.signOutAccount = signOutAccount; LoginService.getProfile = getProfile; - LoginService.verifyLoginStatus = () => _verifyLogin(false); + LoginService._verifyLoginStatus = () => _verifyLogin(false); LoginService.getAccountBaseURL = getAccountBaseURL; init(); } diff --git a/src/services/login-service.js b/src/services/login-service.js index d31e41f438..751f33a517 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -337,6 +337,16 @@ define(function (require, exports, module) { LoginService.clearEntitlements = clearEntitlements; LoginService.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; + // Test-only exports for integration testing + if (Phoenix.isTestWindow) { + window._test_login_service_exports = { + LoginService, + setFetchFn: function _setFetchFn(fn) { + fetchFn = fn; + } + }; + } + // Start the entitlements monitor timer startEntitlementsMonitor(); }); diff --git a/src/services/profile-menu.js b/src/services/profile-menu.js index c582234f0c..81d61ceb88 100644 --- a/src/services/profile-menu.js +++ b/src/services/profile-menu.js @@ -541,7 +541,7 @@ define(function (require, exports, module) { // Set flag to indicate this is a background refresh isBackgroundRefresh = true; - KernalModeTrust.loginService.verifyLoginStatus().then(() => { + KernalModeTrust.loginService._verifyLoginStatus().then(() => { // Clear the background refresh flag isBackgroundRefresh = false; diff --git a/src/services/promotions.js b/src/services/promotions.js index 0451add62b..283ce4421d 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -174,13 +174,13 @@ define(function (require, exports, module) { } /** - * Check if user has active pro subscription + * Check if user has active pro subscription. this calls actual login endpoint and is not to be used frequently!. * Returns true if user is logged in and has a paid subscription */ async function _hasProSubscription() { try { // First verify login status to ensure login state is properly resolved - await LoginService.verifyLoginStatus(); + await LoginService._verifyLoginStatus(); // getEntitlements() returns null if not logged in const entitlements = await LoginService.getEntitlements(); From 5336a69053ae5202927bd70b949de3fef3842734 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 15 Sep 2025 14:09:20 +0530 Subject: [PATCH 07/13] fix: trial data handling logic for promo and pro activation 1. Corrected trial activation logic to properly detect corrupted data, handle existing trials, and avoid granting trials on tampering or invalid state. 2. Enhanced robustness by explicitly handling failure cases (e.g., JSON parse errors, invalid signatures). --- src/phoenix/trust_ring.js | 6 +- src/services/promotions.js | 231 ++++++++++++++++++++++++++++--------- 2 files changed, 181 insertions(+), 56 deletions(-) diff --git a/src/phoenix/trust_ring.js b/src/phoenix/trust_ring.js index 019d47f4e3..bb7054a2ac 100644 --- a/src/phoenix/trust_ring.js +++ b/src/phoenix/trust_ring.js @@ -179,7 +179,8 @@ function _selectKeys() { } const CRED_KEY_API = "API_KEY"; -const CRED_KEY_ENTITLEMENTS = "ENTITLEMENTS_GRANT_KEY"; +const CRED_KEY_PROMO = "PROMO_GRANT_KEY"; +const SIGNATURE_SALT_KEY = "SIGNATURE_SALT_KEY"; const { key, iv } = _selectKeys(); // this key is set at boot time as a truct base for all the core components before any extensions are loaded. // just before extensions are loaded, this key is blanked. This can be used by core modules to talk with other @@ -187,7 +188,8 @@ const { key, iv } = _selectKeys(); // KernalModeTrust should only be available within all code that loads before the first default/any extension. window.KernalModeTrust = { CRED_KEY_API, - CRED_KEY_ENTITLEMENTS, + CRED_KEY_PROMO, + SIGNATURE_SALT_KEY, aesKeys: { key, iv }, setCredential, getCredential, diff --git a/src/services/promotions.js b/src/services/promotions.js index 283ce4421d..fda471df77 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -46,16 +46,96 @@ define(function (require, exports, module) { // Constants const EVENT_PRO_UPGRADE_ON_INSTALL = "pro_upgrade_on_install"; + const PROMO_LOCAL_FILE = "entitlements_promo.json"; const TRIAL_POLL_MS = 10 * 1000; // 10 seconds after start, we assign a free trial if possible const FIRST_INSTALL_TRIAL_DAYS = 30; const SUBSEQUENT_TRIAL_DAYS = 7; const MS_PER_DAY = 24 * 60 * 60 * 1000; + // 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 breal the large majority of users and + // for the ones who are affected, the app will reset the signed data with new salt but will not grant ant trial + // when tampering is detected. + const FALLBACK_SALT = 'fallback-salt-2f309322-b32d-4d59-85b4-2baef666a9f4'; + + // Error constants for _getTrialData + const ERR_CORRUPTED = "corrupted"; + + /** + * Async wrapper for fs.readFile in browser + */ + function _readFileAsync(filePath) { + return new Promise((resolve) => { + window.fs.readFile(filePath, 'utf8', function (err, data) { + resolve(err ? null : data); + }); + }); + } + + /** + * Async wrapper for fs.writeFile in browser + */ + function _writeFileAsync(filePath, data) { + return new Promise((resolve, reject) => { + window.fs.writeFile(filePath, data, 'utf8', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Clear trial data from storage (reusable function) + */ + async function _clearTrialData() { + try { + if (Phoenix.isNativeApp) { + await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_PROMO); + } else { + const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE; + await new Promise((resolve) => { + window.fs.unlink(filePath, () => resolve()); // Always resolve, ignore errors + }); + } + } catch (error) { + console.log("Error clearing trial data:", error); + } + } + + /** + * Get per-user salt for signature generation, creating and persisting one if it doesn't exist + */ + async function _getSalt() { + try { + if (Phoenix.isNativeApp) { + // Native app: use KernalModeTrust credential store + let salt = await KernalModeTrust.getCredential(KernalModeTrust.SIGNATURE_SALT_KEY); + if (!salt) { + // Generate and store new salt + salt = crypto.randomUUID(); + await KernalModeTrust.setCredential(KernalModeTrust.SIGNATURE_SALT_KEY, salt); + } + return salt; + } + // in browser app, there is no way to securely store salt without the extensions also being able to + // read it. So we will just return a static salt. In future, we will need to vend trials strongly tied + // to user logins for the browser app, and for desktop app, the current cred storage would work as is. + return FALLBACK_SALT; + } catch (error) { + console.error("Error getting signature salt:", error); + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "corrupt", "saltErr"); + // Return a fallback salt to prevent crashes + return FALLBACK_SALT; + } + } /** * Generate SHA-256 signature for trial data integrity */ async function _generateSignature(proVersion, endDate) { - const salt = window.AppConfig ? window.AppConfig.version : "default-salt"; + const salt = await _getSalt(); const data = proVersion + "|" + endDate + "|" + salt; // Use browser crypto API for SHA-256 hashing @@ -79,42 +159,51 @@ define(function (require, exports, module) { } /** - * Get stored trial data with validation + * Get stored trial data with validation and corruption detection + * Returns: {data: {...}} for valid data, {error: ERR_CORRUPTED} for errors, or null for no data */ async function _getTrialData() { try { if (Phoenix.isNativeApp) { // Native app: use KernalModeTrust credential store - const data = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS); + const data = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_PROMO); if (!data) { - return null; + return null; // No data exists - genuine first install + } + try { + const trialData = JSON.parse(data); + const isValid = await _isValidSignature(trialData); + if (isValid) { + return { data: trialData }; // Valid trial data + } + return { error: ERR_CORRUPTED }; // Data exists but signature invalid + } catch (e) { + return { error: ERR_CORRUPTED }; // JSON parse error } - const parsed = JSON.parse(data); - return (await _isValidSignature(parsed)) ? parsed : null; } else { - // Browser app: use virtual filesystem - return new Promise((resolve) => { - // app support dir in browser is /fs/app/ - const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json"; - window.fs.readFile(filePath, 'utf8', function (err, data) { - if (err || !data) { - resolve(null); - return; - } - try { - const parsed = JSON.parse(data); - _isValidSignature(parsed).then(isValid => { - resolve(isValid ? parsed : null); - }).catch(() => resolve(null)); - } catch (e) { - resolve(null); - } - }); - }); + // Browser app: use virtual filesystem. in future we need to always fetch from remote about trial + // entitlements for browser app. + const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE; + const fileData = await _readFileAsync(filePath); + + if (!fileData) { + return null; // No data exists - genuine first install + } + + try { + const trialData = JSON.parse(fileData); + const isValid = await _isValidSignature(trialData); + if (isValid) { + return { data: trialData }; // Valid trial data + } + return { error: ERR_CORRUPTED }; // Data exists but signature invalid + } catch (e) { + return { error: ERR_CORRUPTED }; // JSON parse error + } } } catch (error) { console.error("Error getting trial data:", error); - return null; + return { error: ERR_CORRUPTED }; // Treat error as corrupted/tampered data } } @@ -127,20 +216,11 @@ define(function (require, exports, module) { try { if (Phoenix.isNativeApp) { // Native app: use KernalModeTrust credential store - await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS, JSON.stringify(trialData)); + await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(trialData)); } else { // Browser app: use virtual filesystem - return new Promise((resolve, reject) => { - const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json"; - window.fs.writeFile(filePath, JSON.stringify(trialData), 'utf8', (writeErr) => { - if (writeErr) { - console.error("Error storing trial data:", writeErr); - reject(writeErr); - } else { - resolve(); - } - }); - }); + const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE; + await _writeFileAsync(filePath, JSON.stringify(trialData)); } } catch (error) { console.error("Error setting trial data:", error); @@ -191,28 +271,72 @@ define(function (require, exports, module) { } } + function _isTrialClosedForCurrentVersion(currentTrialData) { + if(!currentTrialData) { + return false; + } + const currentVersion = window.AppConfig ? window.AppConfig.apiVersion : "1.0.0"; + const remainingDays = _calculateRemainingTrialDays(currentTrialData); + const trialVersion = currentTrialData.proVersion; + const isNewerVersion = _isNewerVersion(currentVersion, trialVersion); + const trialClosedDialogShown = currentTrialData.upgradeDialogShownVersion === currentVersion; + // if isCurrentVersionTrialClosed and if remainingDays > 0, it means that user put back system time to + // before trial end. in this case we should not grant any trial. + return trialClosedDialogShown || (remainingDays <= 0 && !isNewerVersion); + } + /** * Get remaining pro trial days * Returns 0 if no trial or trial expired */ async function getProTrialDaysRemaining() { - const trialData = await _getTrialData(); - if (!trialData) { + const result = await _getTrialData(); + if (!result || result.error || _isTrialClosedForCurrentVersion(result.data)) { return 0; } - return _calculateRemainingTrialDays(trialData); + return _calculateRemainingTrialDays(result.data); } async function activateProTrial() { const currentVersion = window.AppConfig ? window.AppConfig.apiVersion : "1.0.0"; - const existingTrialData = await _getTrialData(); + const result = await _getTrialData(); let trialDays = FIRST_INSTALL_TRIAL_DAYS; let endDate; const now = dateNowFn(); let metricString = `${currentVersion.replaceAll(".", "_")}`; // 3.1.0 -> 3_1_0 + // Handle corrupted or parse failed data - reset trial state and deny any trial grants + if (result && result.error) { + console.warn(`Trial data error detected (${result.error}) - resetting trial state without granting trial`); + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trial", "corrupt"); + + // Check if user has pro subscription + const hasProSubscription = await _hasProSubscription(); + if (hasProSubscription) { + console.log("User has pro subscription - resetting corrupted trial marker"); + await _setTrialData({ + proVersion: currentVersion, + endDate: now // Expires immediately + }); + return; + } + + // For corruption, show trial ended dialog and create expired marker + // Do not grant any new trial as possible tampering. + console.warn("trial data corrupted"); + ProDialogs.showProEndedDialog(); // Show ended dialog for security + + // Create expired trial marker to prevent future trial grants + await _setTrialData({ + proVersion: currentVersion, + endDate: now // Expires immediately + }); + return; + } + + const existingTrialData = result ? result.data : null; if (existingTrialData) { // Existing trial found const remainingDays = _calculateRemainingTrialDays(existingTrialData); @@ -220,7 +344,7 @@ define(function (require, exports, module) { const isNewerVersion = _isNewerVersion(currentVersion, trialVersion); // Check if we should grant any trial - if (remainingDays <= 0 && !isNewerVersion) { + if (_isTrialClosedForCurrentVersion(existingTrialData)) { // Check if promo ended dialog was already shown for this version if (existingTrialData.upgradeDialogShownVersion !== currentVersion) { // Check if user has pro subscription before showing promo dialog @@ -336,22 +460,18 @@ define(function (require, exports, module) { ProDialogs: ProDialogs, _getTrialData: _getTrialData, _setTrialData: _setTrialData, - _cleanTrialData: async function() { + _getSalt: _getSalt, + _cleanTrialData: _clearTrialData, + _cleanSaltData: async function() { try { if (Phoenix.isNativeApp) { - await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS); - } else { - const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json"; - return new Promise((resolve) => { - window.fs.unlink(filePath, () => { - // Always resolve, ignore errors since file might not exist - resolve(); - }); - }); + await KernalModeTrust.removeCredential(KernalModeTrust.SIGNATURE_SALT_KEY); + console.log("Salt data cleanup completed"); } + // in browser app we always return a static salt, so no need to clear it } catch (error) { // Ignore cleanup errors - console.log("Trial data cleanup completed (ignoring errors)"); + console.log("Salt data cleanup completed (ignoring errors)"); } }, activateProTrial: activateProTrial, @@ -364,6 +484,9 @@ define(function (require, exports, module) { FIRST_INSTALL_TRIAL_DAYS, SUBSEQUENT_TRIAL_DAYS, MS_PER_DAY + }, + ERROR_CONSTANTS: { + ERR_CORRUPTED } }; } From 327bec96c190ad30c8b76059f320ded5bae582a5 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 15 Sep 2025 14:48:05 +0530 Subject: [PATCH 08/13] test: promotions integ tests robustness --- src/services/promotions.js | 1 + test/spec/promotions-integ-test.js | 417 +++++++++++++++++++++++++++-- 2 files changed, 392 insertions(+), 26 deletions(-) diff --git a/src/services/promotions.js b/src/services/promotions.js index fda471df77..7f8e5a8c40 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -461,6 +461,7 @@ define(function (require, exports, module) { _getTrialData: _getTrialData, _setTrialData: _setTrialData, _getSalt: _getSalt, + _isTrialClosedForCurrentVersion: _isTrialClosedForCurrentVersion, _cleanTrialData: _clearTrialData, _cleanSaltData: async function() { try { diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index 14416438a7..f063a66fed 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -99,7 +99,10 @@ define(function (require, exports, module) { // Basic test to verify our exports work expect(LoginService._getTrialData).toBeDefined(); expect(LoginService._setTrialData).toBeDefined(); + expect(LoginService._getSalt).toBeDefined(); + expect(LoginService._isTrialClosedForCurrentVersion).toBeDefined(); expect(LoginService._cleanTrialData).toBeDefined(); + expect(LoginService._cleanSaltData).toBeDefined(); expect(LoginService.activateProTrial).toBeDefined(); expect(LoginService.getProTrialDaysRemaining).toBeDefined(); expect(LoginService.setDateNowFn).toBeDefined(); @@ -114,15 +117,16 @@ define(function (require, exports, module) { await LoginService.activateProTrial(); // Get the trial data that was actually stored - const storedTrialData = await LoginService._getTrialData(); + const storedResult = await LoginService._getTrialData(); // Verify trial data was set correctly - expect(storedTrialData).not.toBeNull(); - expect(storedTrialData.proVersion).toBe("3.1.0"); + expect(storedResult).not.toBeNull(); + expect(storedResult.data).toBeDefined(); + expect(storedResult.data.proVersion).toBe("3.1.0"); // Check that a 30-day trial was activated with mocked time const expectedEndDate = mockNow + (30 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); - expect(storedTrialData.endDate).toBe(expectedEndDate); + expect(storedResult.data.endDate).toBe(expectedEndDate); // Verify upgrade dialog appears with correct content await testWindow.__PR.waitForModalDialog(".modal"); @@ -153,16 +157,17 @@ define(function (require, exports, module) { await LoginService.activateProTrial(); // Get the updated trial data - const updatedTrialData = await LoginService._getTrialData(); + const updatedResult = await LoginService._getTrialData(); // Verify new trial data was set for newer version - expect(updatedTrialData).not.toBeNull(); - expect(updatedTrialData.proVersion).toBe("3.1.0"); + expect(updatedResult).not.toBeNull(); + expect(updatedResult.data).toBeDefined(); + expect(updatedResult.data.proVersion).toBe("3.1.0"); // Check that 3-day trial was granted with mocked time const expectedEndDate = mockNow + (LoginService.TRIAL_CONSTANTS.SUBSEQUENT_TRIAL_DAYS * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); - expect(updatedTrialData.endDate).toBe(expectedEndDate); + expect(updatedResult.data.endDate).toBe(expectedEndDate); // Verify upgrade dialog appears with correct content await testWindow.__PR.waitForModalDialog(".modal"); @@ -194,12 +199,13 @@ define(function (require, exports, module) { await LoginService.activateProTrial(); // Get the updated trial data - const updatedTrialData = await LoginService._getTrialData(); + const updatedResult = await LoginService._getTrialData(); // Verify existing trial was preserved but version updated - expect(updatedTrialData).not.toBeNull(); - expect(updatedTrialData.proVersion).toBe("3.1.0"); - expect(updatedTrialData.endDate).toBe(futureEndDate); + expect(updatedResult).not.toBeNull(); + expect(updatedResult.data).toBeDefined(); + expect(updatedResult.data.proVersion).toBe("3.1.0"); + expect(updatedResult.data.endDate).toBe(futureEndDate); await testWindow.__PR.waitForModalDialog(".modal"); // Check dialog content @@ -229,11 +235,12 @@ define(function (require, exports, module) { await LoginService.activateProTrial(); // Get the trial data after activation - const currentTrialData = await LoginService._getTrialData(); + const currentResult = await LoginService._getTrialData(); // Verify trial data remains unchanged (same version, same end date) - expect(currentTrialData.proVersion).toBe("3.1.0"); - expect(currentTrialData.endDate).toBe(existingTrial.endDate); + expect(currentResult.data).toBeDefined(); + expect(currentResult.data.proVersion).toBe("3.1.0"); + expect(currentResult.data.endDate).toBe(existingTrial.endDate); // For same version, no dialog should appear await awaits(500); @@ -254,11 +261,12 @@ define(function (require, exports, module) { await LoginService.activateProTrial(); // Get the trial data after activation - const currentTrialData = await LoginService._getTrialData(); + const currentResult = await LoginService._getTrialData(); // Verify trial data remains unchanged (older current version scenario) - expect(currentTrialData.proVersion).toBe("3.2.0"); // Should preserve original version - expect(currentTrialData.endDate).toBe(existingTrial.endDate); // Should preserve end date + expect(currentResult.data).toBeDefined(); + expect(currentResult.data.proVersion).toBe("3.2.0"); // Should preserve original version + expect(currentResult.data.endDate).toBe(existingTrial.endDate); // Should preserve end date // For older current version, no dialog should appear await awaits(500); @@ -282,15 +290,16 @@ define(function (require, exports, module) { await LoginService.activateProTrial(); // Get the updated trial data - const updatedTrialData = await LoginService._getTrialData(); + const updatedResult = await LoginService._getTrialData(); // Verify upgrade dialog shown flag was set - expect(updatedTrialData).not.toBeNull(); - expect(updatedTrialData.upgradeDialogShownVersion).toBe("3.1.0"); - expect(updatedTrialData.proVersion).toBe("3.1.0"); // Should preserve original version - expect(updatedTrialData.endDate).toBe(expiredTrial.endDate); // Should preserve end date + expect(updatedResult).not.toBeNull(); + expect(updatedResult.data).toBeDefined(); + expect(updatedResult.data.upgradeDialogShownVersion).toBe("3.1.0"); + expect(updatedResult.data.proVersion).toBe("3.1.0"); // Should preserve original version + expect(updatedResult.data.endDate).toBe(expiredTrial.endDate); // Should preserve end date - return { expiredTrial, updatedTrialData }; + return { expiredTrial, updatedTrialData: updatedResult.data }; } it("should show local promo ended dialog when trial expires (offline/fetch fails)", async function () { @@ -371,11 +380,12 @@ define(function (require, exports, module) { // Verify new trial was granted for version upgrade expect(updatedTrialData).not.toBeNull(); - expect(updatedTrialData.proVersion).toBe("3.1.0"); // Should update to current version + expect(updatedTrialData.data).toBeDefined(); + expect(updatedTrialData.data.proVersion).toBe("3.1.0"); // Should update to current version // Should grant 7-day trial for version upgrade const expectedEndDate = mockNow + (7 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); - expect(updatedTrialData.endDate).toBe(expectedEndDate); + expect(updatedTrialData.data.endDate).toBe(expectedEndDate); // Should show upgrade dialog (not ended dialog) await testWindow.__PR.waitForModalDialog(".modal"); @@ -433,5 +443,360 @@ define(function (require, exports, module) { // Note: Version comparison, pro subscription checks, and event triggering // are internal implementation details that are difficult to test reliably // in integration tests without extensive mocking of private functions + + describe("Security Tests", function () { + + it("should detect and prevent signature tampering attacks", async function () { + // Setup: Create a valid trial first + const validTrial = { + proVersion: "3.1.0", + endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + }; + await LoginService._setTrialData(validTrial); + + // Get the valid trial data (should include signature) + let storedResult = await LoginService._getTrialData(); + expect(storedResult).not.toBeNull(); + expect(storedResult.data).toBeDefined(); // Should have valid data + expect(storedResult.error).toBeUndefined(); // Should not have error + + // Attack: Tamper with the signature + const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; + + // Manually store the tampered data (bypassing _setTrialData validation) + if (testWindow.Phoenix.isNativeApp) { + await testWindow.KernalModeTrust.setCredential(testWindow.KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(tamperedTrial)); + } else { + await new Promise((resolve, reject) => { + const filePath = testWindow.Phoenix.app.getApplicationSupportDirectory() + "entitlements_promo.json"; + testWindow.fs.writeFile(filePath, JSON.stringify(tamperedTrial), 'utf8', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + // Verify: _getTrialData should detect corruption + const corruptedResult = await LoginService._getTrialData(); + expect(corruptedResult).not.toBeNull(); + expect(corruptedResult.error).toBe(LoginService.ERROR_CONSTANTS.ERR_CORRUPTED); + + // Verify: activateProTrial should create expired trial marker and deny trial + await LoginService.activateProTrial(); + + // Should create expired trial marker instead of clearing + const resultAfterSecurity = await LoginService._getTrialData(); + expect(resultAfterSecurity).not.toBeNull(); // Should have expired trial data + expect(resultAfterSecurity.data).toBeDefined(); + expect(resultAfterSecurity.data.proVersion).toBe("3.1.0"); + expect(resultAfterSecurity.data.endDate).toBe(mockNow); // Should be expired immediately (endDate: now) + + // Should return 0 remaining days (expired trial) + const remainingDays = await LoginService.getProTrialDaysRemaining(); + expect(remainingDays).toBe(0); + + // Should show trial ended dialog (security notice) + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + expect(modalContent.length).toBeGreaterThan(0); + const dialogText = modalContent.text(); + expect(dialogText).toContain('Trial has ended'); + + // Close dialog + testWindow.__PR.clickDialogButtonID("secondaryButton"); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + }); + + it("should handle version downgrade without losing valid trials", async function () { + // Setup: Create trial with newer app version salt + testWindow.AppConfig.version = "3.2.0"; + testWindow.AppConfig.apiVersion = "3.2.0"; + + // Clean any existing salt to simulate fresh install + await LoginService._cleanSaltData(); + + const futureTrial = { + proVersion: "3.2.0", + endDate: mockNow + (10 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + }; + await LoginService._setTrialData(futureTrial); + + // Verify trial is valid with 3.2.0 + let trialResult = await LoginService._getTrialData(); + expect(trialResult).not.toBeNull(); + expect(trialResult.data).toBeDefined(); + expect(trialResult.error).toBeUndefined(); + expect(trialResult.data.proVersion).toBe("3.2.0"); + + // Simulate version downgrade - change app version + testWindow.AppConfig.version = "3.1.0"; + testWindow.AppConfig.apiVersion = "3.1.0"; + + // The per-user salt should remain the same, so signature should still be valid + const downgradeResult = await LoginService._getTrialData(); + expect(downgradeResult).not.toBeNull(); + expect(downgradeResult.data).toBeDefined(); // Should have valid data + expect(downgradeResult.error).toBeUndefined(); // Should NOT have error + expect(downgradeResult.data.proVersion).toBe("3.2.0"); // Should preserve original version + + // Should still have valid remaining days + const remainingDays = await LoginService.getProTrialDaysRemaining(); + expect(remainingDays).toBe(10); + + // activateProTrial should preserve the existing valid trial + await LoginService.activateProTrial(); + const finalTrial = await LoginService._getTrialData(); + expect(finalTrial.data).toBeDefined(); + expect(finalTrial.data.proVersion).toBe("3.2.0"); // Should preserve newer version + expect(finalTrial.data.endDate).toBe(futureTrial.endDate); // Should preserve end date + }); + + it("should handle missing signature fields gracefully", async function () { + // Setup: Create trial data with missing signature field + const trialWithoutSignature = { + proVersion: "3.1.0", + endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + // No signature field + }; + + // Manually store data without signature (bypassing _setTrialData) + if (testWindow.Phoenix.isNativeApp) { + await testWindow.KernalModeTrust.setCredential(testWindow.KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(trialWithoutSignature)); + } else { + await new Promise((resolve, reject) => { + const filePath = testWindow.Phoenix.app.getApplicationSupportDirectory() + "entitlements_promo.json"; + testWindow.fs.writeFile(filePath, JSON.stringify(trialWithoutSignature), 'utf8', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + // Should detect corruption due to missing signature + const result = await LoginService._getTrialData(); + expect(result.error).toBe(LoginService.ERROR_CONSTANTS.ERR_CORRUPTED); + + // Should create expired trial marker for security + await LoginService.activateProTrial(); + const afterActivation = await LoginService._getTrialData(); + expect(afterActivation).not.toBeNull(); // Should have expired trial data + expect(afterActivation.data).toBeDefined(); + expect(afterActivation.data.proVersion).toBe("3.1.0"); + expect(afterActivation.data.endDate).toBe(mockNow); // Should be expired immediately (endDate: now) + + // Should return 0 remaining days (expired trial) + const remainingDays = await LoginService.getProTrialDaysRemaining(); + expect(remainingDays).toBe(0); + + // Should show security dialog + await testWindow.__PR.waitForModalDialog(".modal"); + const modalContent = testWindow.$('.modal'); + expect(modalContent.length).toBeGreaterThan(0); + + // Close dialog + testWindow.__PR.clickDialogButtonID("secondaryButton"); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + }); + + it("should persist salt across app restarts", async function () { + // Clean existing salt + await LoginService._cleanSaltData(); + + // Get salt (should generate new one) + const salt1 = await LoginService._getSalt(); + expect(salt1).toBeDefined(); + expect(typeof salt1).toBe('string'); + expect(salt1.length).toBeGreaterThan(10); // Should be substantial UUID + + // Get salt again (should return same one) + const salt2 = await LoginService._getSalt(); + expect(salt2).toBe(salt1); + + // Create and store trial with this salt + const trial = { + proVersion: "3.1.0", + endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + }; + await LoginService._setTrialData(trial); + + // Verify trial is valid + const storedResult = await LoginService._getTrialData(); + expect(storedResult.data).toBeDefined(); + expect(storedResult.error).toBeUndefined(); + + // Simulate "app restart" - get salt again + const salt3 = await LoginService._getSalt(); + expect(salt3).toBe(salt1); // Should be persistent + + // Trial should still be valid after "restart" + const restartResult = await LoginService._getTrialData(); + expect(restartResult.data).toBeDefined(); + expect(restartResult.error).toBeUndefined(); + expect(restartResult.data.proVersion).toBe("3.1.0"); + }); + + it("should prevent future trial grants after corruption creates expired marker", async function () { + // Setup: Create a valid trial first + const validTrial = { + proVersion: "3.1.0", + endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + }; + await LoginService._setTrialData(validTrial); + + // Attack: Corrupt the trial data + const storedResult = await LoginService._getTrialData(); + const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; + + if (testWindow.Phoenix.isNativeApp) { + await testWindow.KernalModeTrust.setCredential(testWindow.KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(tamperedTrial)); + } else { + await new Promise((resolve, reject) => { + const filePath = testWindow.Phoenix.app.getApplicationSupportDirectory() + "entitlements_promo.json"; + testWindow.fs.writeFile(filePath, JSON.stringify(tamperedTrial), 'utf8', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + // First activation should create expired marker + await LoginService.activateProTrial(); + + // Dismiss the security dialog + await testWindow.__PR.waitForModalDialog(".modal"); + testWindow.__PR.clickDialogButtonID("secondaryButton"); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + + // Verify expired marker exists + const expiredResult = await LoginService._getTrialData(); + expect(expiredResult.data).toBeDefined(); + expect(expiredResult.data.endDate).toBe(mockNow); // Should be expired immediately + + // Simulate app restart by calling activateProTrial again + // This should NOT grant a new 30-day trial + await LoginService.activateProTrial(); + + // Should still have the expired marker, not a new 30-day trial + const afterRestartResult = await LoginService._getTrialData(); + expect(afterRestartResult.data).toBeDefined(); + expect(afterRestartResult.data.endDate).toBe(mockNow); // Still expired immediately + expect(afterRestartResult.data.endDate).toBe(expiredResult.data.endDate); // Same end date + + // Should still return 0 days + const remainingDays = await LoginService.getProTrialDaysRemaining(); + expect(remainingDays).toBe(0); + }); + + it("should detect time manipulation attacks (system clock rollback)", async function () { + // Setup: Create an expired trial with dialog shown flag + const expiredTrial = { + proVersion: "3.1.0", + endDate: mockNow - (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY), // Expired 5 days ago + upgradeDialogShownVersion: "3.1.0" // Dialog already shown for current version + }; + await LoginService._setTrialData(expiredTrial); + + // Verify trial is properly expired + const expiredResult = await LoginService._getTrialData(); + expect(expiredResult.data).toBeDefined(); + expect(expiredResult.error).toBeUndefined(); + + // Verify _isTrialClosedForCurrentVersion detects closed trial + const isClosedBefore = await LoginService._isTrialClosedForCurrentVersion(expiredResult.data); + expect(isClosedBefore).toBe(true); + + // Attack: User rolls back system time to make trial appear valid + const rolledBackTime = mockNow - (10 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); // 10 days ago + LoginService.setDateNowFn(() => rolledBackTime); + + // Despite rollback, _calculateRemainingTrialDays would show positive days + const remainingDaysAfterRollback = await LoginService.getProTrialDaysRemaining(); + // But getProTrialDaysRemaining should still return 0 due to closure detection + expect(remainingDaysAfterRollback).toBe(0); + + // Verify _isTrialClosedForCurrentVersion still detects closed trial despite time manipulation + const isClosedAfterRollback = await LoginService._isTrialClosedForCurrentVersion(expiredResult.data); + expect(isClosedAfterRollback).toBe(true); // Should still be closed + + // Reset time + LoginService.setDateNowFn(() => mockNow); + + // activateProTrial should not grant new trial even after time manipulation + await LoginService.activateProTrial(); + + // Should still have the same expired trial, not a new one + const finalResult = await LoginService._getTrialData(); + expect(finalResult.data).toBeDefined(); + expect(finalResult.data.endDate).toBe(expiredTrial.endDate); // Same end date + expect(finalResult.data.upgradeDialogShownVersion).toBe("3.1.0"); // Flag preserved + }); + + it("should respect trial closure flags across version changes", async function () { + // Setup: Create a trial that's expired for current version but not time-expired + const validTrial = { + proVersion: "3.0.0", // Older version + endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY), // Still has time remaining + upgradeDialogShownVersion: "3.1.0" // Dialog was already shown for current version + }; + await LoginService._setTrialData(validTrial); + + // Current version is 3.1.0, which is newer than trial version 3.0.0 + // Trial has remaining time but dialog was shown for current version + const trialResult = await LoginService._getTrialData(); + expect(trialResult.data).toBeDefined(); + + // _isTrialClosedForCurrentVersion should return true because dialog was shown for current version + const isClosed = await LoginService._isTrialClosedForCurrentVersion(trialResult.data); + expect(isClosed).toBe(true); + + // getProTrialDaysRemaining should return 0 due to closure flag + const remainingDays = await LoginService.getProTrialDaysRemaining(); + expect(remainingDays).toBe(0); + + // activateProTrial should not grant new trial due to closure flag + await LoginService.activateProTrial(); + + // Should preserve the existing trial with dialog shown flag + const finalResult = await LoginService._getTrialData(); + expect(finalResult.data).toBeDefined(); + expect(finalResult.data.endDate).toBe(validTrial.endDate); // Same end date + expect(finalResult.data.upgradeDialogShownVersion).toBe("3.1.0"); // Flag preserved + + // Test version upgrade scenario - newer version should work + testWindow.AppConfig.apiVersion = "3.2.0"; // Upgrade to newer version + + // Now _isTrialClosedForCurrentVersion should return false for the newer version + const isClosedAfterUpgrade = await LoginService._isTrialClosedForCurrentVersion(finalResult.data); + expect(isClosedAfterUpgrade).toBe(false); // Should not be closed for newer version + + // Should now have remaining days since it's a newer version + const remainingAfterUpgrade = await LoginService.getProTrialDaysRemaining(); + expect(remainingAfterUpgrade).toBeGreaterThan(0); + + // Reset version for cleanup + testWindow.AppConfig.apiVersion = "3.1.0"; + }); + + afterEach(async function() { + // Clean up after each security test + await LoginService._cleanTrialData(); + await LoginService._cleanSaltData(); + + // Reset app config to default + testWindow.AppConfig = { + version: "3.1.0", + apiVersion: "3.1.0" + }; + }); + }); }); }); From 4d642f58422ee8cc92c22c8db7d87aa36539d455 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 15 Sep 2025 14:52:22 +0530 Subject: [PATCH 09/13] test: promotions integ tests passing in browser --- test/spec/promotions-integ-test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index f063a66fed..b6d8d02423 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -446,6 +446,14 @@ define(function (require, exports, module) { describe("Security Tests", function () { + beforeEach(async function() { + // Restore original fetch to ensure clean state between tests + // This prevents fetch mocks from previous tests affecting security tests + if (originalFetch && testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { + testWindow._test_pro_dlg_login_exports.setFetchFn(originalFetch); + } + }); + it("should detect and prevent signature tampering attacks", async function () { // Setup: Create a valid trial first const validTrial = { From 5533fd23b0d08c6ead628a15d002d1aa22515cb7 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 15 Sep 2025 15:05:25 +0530 Subject: [PATCH 10/13] test: promotions integ tests working in desktop and browser app --- src/services/promotions.js | 21 ++++++++------ test/spec/promotions-integ-test.js | 46 +++--------------------------- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/src/services/promotions.js b/src/services/promotions.js index 7f8e5a8c40..4b2a518c68 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -16,7 +16,7 @@ * */ -/*global logger*/ +/*global logger, path*/ /** * Promotions Service @@ -46,7 +46,7 @@ define(function (require, exports, module) { // Constants const EVENT_PRO_UPGRADE_ON_INSTALL = "pro_upgrade_on_install"; - const PROMO_LOCAL_FILE = "entitlements_promo.json"; + const PROMO_LOCAL_FILE = path.join(Phoenix.app.getApplicationSupportDirectory(), "entitlements_promo.json"); const TRIAL_POLL_MS = 10 * 1000; // 10 seconds after start, we assign a free trial if possible const FIRST_INSTALL_TRIAL_DAYS = 30; const SUBSEQUENT_TRIAL_DAYS = 7; @@ -94,9 +94,8 @@ define(function (require, exports, module) { if (Phoenix.isNativeApp) { await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_PROMO); } else { - const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE; await new Promise((resolve) => { - window.fs.unlink(filePath, () => resolve()); // Always resolve, ignore errors + window.fs.unlink(PROMO_LOCAL_FILE, () => resolve()); // Always resolve, ignore errors }); } } catch (error) { @@ -183,8 +182,7 @@ define(function (require, exports, module) { } else { // Browser app: use virtual filesystem. in future we need to always fetch from remote about trial // entitlements for browser app. - const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE; - const fileData = await _readFileAsync(filePath); + const fileData = await _readFileAsync(PROMO_LOCAL_FILE); if (!fileData) { return null; // No data exists - genuine first install @@ -219,8 +217,7 @@ define(function (require, exports, module) { await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(trialData)); } else { // Browser app: use virtual filesystem - const filePath = Phoenix.app.getApplicationSupportDirectory() + PROMO_LOCAL_FILE; - await _writeFileAsync(filePath, JSON.stringify(trialData)); + await _writeFileAsync(PROMO_LOCAL_FILE, JSON.stringify(trialData)); } } catch (error) { console.error("Error setting trial data:", error); @@ -475,6 +472,14 @@ define(function (require, exports, module) { console.log("Salt data cleanup completed (ignoring errors)"); } }, + // Test-only functions for manipulating credentials directly (bypassing validation) + _testSetRawCredential: async function(data) { + if (Phoenix.isNativeApp) { + await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(data)); + } else { + await _writeFileAsync(PROMO_LOCAL_FILE, JSON.stringify(data)); + } + }, activateProTrial: activateProTrial, getProTrialDaysRemaining: getProTrialDaysRemaining, setDateNowFn: function _setDdateNowFn(fn) { diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index b6d8d02423..53147a272b 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -472,20 +472,7 @@ define(function (require, exports, module) { const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; // Manually store the tampered data (bypassing _setTrialData validation) - if (testWindow.Phoenix.isNativeApp) { - await testWindow.KernalModeTrust.setCredential(testWindow.KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(tamperedTrial)); - } else { - await new Promise((resolve, reject) => { - const filePath = testWindow.Phoenix.app.getApplicationSupportDirectory() + "entitlements_promo.json"; - testWindow.fs.writeFile(filePath, JSON.stringify(tamperedTrial), 'utf8', (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } + await LoginService._testSetRawCredential(tamperedTrial); // Verify: _getTrialData should detect corruption const corruptedResult = await LoginService._getTrialData(); @@ -571,20 +558,7 @@ define(function (require, exports, module) { }; // Manually store data without signature (bypassing _setTrialData) - if (testWindow.Phoenix.isNativeApp) { - await testWindow.KernalModeTrust.setCredential(testWindow.KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(trialWithoutSignature)); - } else { - await new Promise((resolve, reject) => { - const filePath = testWindow.Phoenix.app.getApplicationSupportDirectory() + "entitlements_promo.json"; - testWindow.fs.writeFile(filePath, JSON.stringify(trialWithoutSignature), 'utf8', (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } + await LoginService._testSetRawCredential(trialWithoutSignature); // Should detect corruption due to missing signature const result = await LoginService._getTrialData(); @@ -661,20 +635,8 @@ define(function (require, exports, module) { const storedResult = await LoginService._getTrialData(); const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; - if (testWindow.Phoenix.isNativeApp) { - await testWindow.KernalModeTrust.setCredential(testWindow.KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(tamperedTrial)); - } else { - await new Promise((resolve, reject) => { - const filePath = testWindow.Phoenix.app.getApplicationSupportDirectory() + "entitlements_promo.json"; - testWindow.fs.writeFile(filePath, JSON.stringify(tamperedTrial), 'utf8', (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } + // Manually store the tampered data (bypassing validation) + await LoginService._testSetRawCredential(tamperedTrial); // First activation should create expired marker await LoginService.activateProTrial(); From 1fc6355c5c1ec2053be61f3601a34ecd611ac31e Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 15 Sep 2025 15:17:46 +0530 Subject: [PATCH 11/13] test: promotions integ tests works only when user is not logged in --- test/spec/promotions-integ-test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index 53147a272b..f3d2b873d9 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -93,6 +93,16 @@ define(function (require, exports, module) { await SpecRunnerUtils.closeTestWindow(); }, 30000); + it("should require user to be logged out for promotion tests to work", async function () { + // Check if user is logged in - these tests only work for non-logged-in users + const isLoggedIn = LoginService.LoginService.isLoggedIn(); + if (isLoggedIn) { + throw new Error("Promotion tests require user to be logged out. Please log out before running these tests. Logged-in users with pro subscriptions will not trigger trial activation logic."); + } + // If we reach here, user is not logged in - tests should work + expect(isLoggedIn).toBe(false); + }); + describe("Trial Activation", function () { it("should have access to trial functions", function () { @@ -655,6 +665,11 @@ define(function (require, exports, module) { // This should NOT grant a new 30-day trial await LoginService.activateProTrial(); + // Should show trial ended dialog again (since trial is still expired) + await testWindow.__PR.waitForModalDialog(".modal"); + testWindow.__PR.clickDialogButtonID("secondaryButton"); + await testWindow.__PR.waitForModalDialogClosed(".modal"); + // Should still have the expired marker, not a new 30-day trial const afterRestartResult = await LoginService._getTrialData(); expect(afterRestartResult.data).toBeDefined(); From 182526a5a25324b9f0f0c995316f5fa4f61effa2 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 15 Sep 2025 15:24:16 +0530 Subject: [PATCH 12/13] test: promotions integ tests seperate test cred storage --- src/phoenix/trust_ring.js | 6 +++--- src/services/promotions.js | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/phoenix/trust_ring.js b/src/phoenix/trust_ring.js index bb7054a2ac..5858e44624 100644 --- a/src/phoenix/trust_ring.js +++ b/src/phoenix/trust_ring.js @@ -178,9 +178,9 @@ function _selectKeys() { return generateRandomKeyAndIV(); } -const CRED_KEY_API = "API_KEY"; -const CRED_KEY_PROMO = "PROMO_GRANT_KEY"; -const SIGNATURE_SALT_KEY = "SIGNATURE_SALT_KEY"; +const CRED_KEY_API = Phoenix.isTestWindow ? "API_KEY_TEST" : "API_KEY"; +const CRED_KEY_PROMO = Phoenix.isTestWindow ? "PROMO_GRANT_KEY_TEST" : "PROMO_GRANT_KEY"; +const SIGNATURE_SALT_KEY = Phoenix.isTestWindow ? "SIGNATURE_SALT_KEY_TEST" : "SIGNATURE_SALT_KEY"; const { key, iv } = _selectKeys(); // this key is set at boot time as a truct base for all the core components before any extensions are loaded. // just before extensions are loaded, this key is blanked. This can be used by core modules to talk with other diff --git a/src/services/promotions.js b/src/services/promotions.js index 4b2a518c68..a4dcd8c2b3 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -46,7 +46,8 @@ define(function (require, exports, module) { // Constants const EVENT_PRO_UPGRADE_ON_INSTALL = "pro_upgrade_on_install"; - const PROMO_LOCAL_FILE = path.join(Phoenix.app.getApplicationSupportDirectory(), "entitlements_promo.json"); + const PROMO_LOCAL_FILE = path.join(Phoenix.app.getApplicationSupportDirectory(), + Phoenix.isTestWindow ? "entitlements_promo_test.json" : "entitlements_promo.json"); const TRIAL_POLL_MS = 10 * 1000; // 10 seconds after start, we assign a free trial if possible const FIRST_INSTALL_TRIAL_DAYS = 30; const SUBSEQUENT_TRIAL_DAYS = 7; From 6493bb97f23cda7bc837538345ba42ee85090891 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 15 Sep 2025 16:01:52 +0530 Subject: [PATCH 13/13] test: minor refactor of promotions tests --- src/services/promotions.js | 2 +- test/spec/promotions-integ-test.js | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/services/promotions.js b/src/services/promotions.js index a4dcd8c2b3..6117d39adf 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -474,7 +474,7 @@ define(function (require, exports, module) { } }, // Test-only functions for manipulating credentials directly (bypassing validation) - _testSetRawCredential: async function(data) { + _testSetPromoJSON: async function(data) { if (Phoenix.isNativeApp) { await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(data)); } else { diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index f3d2b873d9..542bd519e8 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -482,7 +482,7 @@ define(function (require, exports, module) { const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; // Manually store the tampered data (bypassing _setTrialData validation) - await LoginService._testSetRawCredential(tamperedTrial); + await LoginService._testSetPromoJSON(tamperedTrial); // Verify: _getTrialData should detect corruption const corruptedResult = await LoginService._getTrialData(); @@ -559,7 +559,7 @@ define(function (require, exports, module) { expect(finalTrial.data.endDate).toBe(futureTrial.endDate); // Should preserve end date }); - it("should handle missing signature fields gracefully", async function () { + it("should handle missing signature fields as tampered", async function () { // Setup: Create trial data with missing signature field const trialWithoutSignature = { proVersion: "3.1.0", @@ -568,7 +568,7 @@ define(function (require, exports, module) { }; // Manually store data without signature (bypassing _setTrialData) - await LoginService._testSetRawCredential(trialWithoutSignature); + await LoginService._testSetPromoJSON(trialWithoutSignature); // Should detect corruption due to missing signature const result = await LoginService._getTrialData(); @@ -596,7 +596,7 @@ define(function (require, exports, module) { await testWindow.__PR.waitForModalDialogClosed(".modal"); }); - it("should persist salt across app restarts", async function () { + it("should persist salt correctly", async function () { // Clean existing salt await LoginService._cleanSaltData(); @@ -622,7 +622,6 @@ define(function (require, exports, module) { expect(storedResult.data).toBeDefined(); expect(storedResult.error).toBeUndefined(); - // Simulate "app restart" - get salt again const salt3 = await LoginService._getSalt(); expect(salt3).toBe(salt1); // Should be persistent @@ -646,7 +645,7 @@ define(function (require, exports, module) { const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; // Manually store the tampered data (bypassing validation) - await LoginService._testSetRawCredential(tamperedTrial); + await LoginService._testSetPromoJSON(tamperedTrial); // First activation should create expired marker await LoginService.activateProTrial(); @@ -703,7 +702,6 @@ define(function (require, exports, module) { const rolledBackTime = mockNow - (10 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY); // 10 days ago LoginService.setDateNowFn(() => rolledBackTime); - // Despite rollback, _calculateRemainingTrialDays would show positive days const remainingDaysAfterRollback = await LoginService.getProTrialDaysRemaining(); // But getProTrialDaysRemaining should still return 0 due to closure detection expect(remainingDaysAfterRollback).toBe(0);