diff --git a/src/phoenix/trust_ring.js b/src/phoenix/trust_ring.js index 019d47f4e3..5858e44624 100644 --- a/src/phoenix/trust_ring.js +++ b/src/phoenix/trust_ring.js @@ -178,8 +178,9 @@ function _selectKeys() { return generateRandomKeyAndIV(); } -const CRED_KEY_API = "API_KEY"; -const CRED_KEY_ENTITLEMENTS = "ENTITLEMENTS_GRANT_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 @@ -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/login-browser.js b/src/services/login-browser.js index e6b2351992..75fdeb7506 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: { @@ -148,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..."); @@ -158,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; } @@ -316,7 +333,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: { @@ -376,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. @@ -399,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 98a2969ab7..f0e9e70fcd 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' @@ -412,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 028759a0be..751f33a517 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(); @@ -334,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/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/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 97263b8702..6117d39adf 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -16,7 +16,7 @@ * */ -/*global logger*/ +/*global logger, path*/ /** * Promotions Service @@ -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"); @@ -45,16 +46,96 @@ 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(), + 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 = 3; + 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 { + await new Promise((resolve) => { + window.fs.unlink(PROMO_LOCAL_FILE, () => 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 @@ -78,42 +159,50 @@ 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 fileData = await _readFileAsync(PROMO_LOCAL_FILE); + + 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 } } @@ -126,20 +215,10 @@ 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(); - } - }); - }); + await _writeFileAsync(PROMO_LOCAL_FILE, JSON.stringify(trialData)); } } catch (error) { console.error("Error setting trial data:", error); @@ -151,7 +230,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 @@ -173,13 +252,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(); @@ -190,28 +269,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 = Date.now(); + 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); @@ -219,7 +342,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 @@ -250,7 +373,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 +451,52 @@ 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_promo_login_exports = { + LoginService: LoginService, + ProDialogs: ProDialogs, + _getTrialData: _getTrialData, + _setTrialData: _setTrialData, + _getSalt: _getSalt, + _isTrialClosedForCurrentVersion: _isTrialClosedForCurrentVersion, + _cleanTrialData: _clearTrialData, + _cleanSaltData: async function() { + try { + if (Phoenix.isNativeApp) { + 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("Salt data cleanup completed (ignoring errors)"); + } + }, + // Test-only functions for manipulating credentials directly (bypassing validation) + _testSetPromoJSON: 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) { + dateNowFn = fn; + }, + EVENT_PRO_UPGRADE_ON_INSTALL: EVENT_PRO_UPGRADE_ON_INSTALL, + TRIAL_CONSTANTS: { + FIRST_INSTALL_TRIAL_DAYS, + SUBSEQUENT_TRIAL_DAYS, + MS_PER_DAY + }, + ERROR_CONSTANTS: { + ERR_CORRUPTED + } + }; + } + // 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..542bd519e8 --- /dev/null +++ b/test/spec/promotions-integ-test.js @@ -0,0 +1,785 @@ +/* + * 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 () { + + 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, + originalAppConfig, + originalFetch, + mockNow = 1000000000000; // Fixed timestamp for consistent testing + + beforeAll(async function () { + testWindow = await SpecRunnerUtils.createTestWindowAndRun(); + + // Access modules from test window + 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)); + 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'); + } + + // 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 = { + version: "3.1.0", + apiVersion: "3.1.0" + }; + }, 30000); + + afterAll(async function () { + // Restore original values + if (originalAppConfig) { + 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); + + 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 () { + // 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(); + }); + + 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 storedResult = await LoginService._getTrialData(); + + // Verify trial data was set correctly + 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(storedResult.data.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.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); + 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 updatedResult = await LoginService._getTrialData(); + + // Verify new trial data was set for newer version + 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(updatedResult.data.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.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); + 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 updatedResult = await LoginService._getTrialData(); + + // Verify existing trial was preserved but version updated + 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 + 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 + // 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 currentResult = await LoginService._getTrialData(); + + // Verify trial data remains unchanged (same version, same end date) + 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); + 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 currentResult = await LoginService._getTrialData(); + + // Verify trial data remains unchanged (older current version scenario) + 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); + const modalContent = testWindow.$('.modal:visible'); + expect(modalContent.length).toBe(0); + }); + }); + + describe("Trial Expiration", 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 + signature: "mock_signature" + }; + + // Set up expired trial data first + await LoginService._setTrialData(expiredTrial); + + await LoginService.activateProTrial(); + + // Get the updated trial data + const updatedResult = await LoginService._getTrialData(); + + // Verify upgrade dialog shown flag was set + 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: updatedResult.data }; + } + + 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); + + // 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'); + + // 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"); + }); + + 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.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.data.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.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); + 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 + + 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 = { + 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) + await LoginService._testSetPromoJSON(tamperedTrial); + + // 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 as tampered", 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) + await LoginService._testSetPromoJSON(trialWithoutSignature); + + // 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 correctly", 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(); + + 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" }; + + // Manually store the tampered data (bypassing validation) + await LoginService._testSetPromoJSON(tamperedTrial); + + // 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 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(); + 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); + + 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" + }; + }); + }); + }); +});