diff --git a/src/phoenix/init_vfs.js b/src/phoenix/init_vfs.js index e201b692ce..881c74e97a 100644 --- a/src/phoenix/init_vfs.js +++ b/src/phoenix/init_vfs.js @@ -154,6 +154,111 @@ function _setupVFS(fsLib, pathLib){ }); }); }, + /** + * Deletes a file/dir asynchronously. resolves on success or rejects on error. + * + * @function + * @param {string} filePath - The path of the file/dir to be deleted. + * @returns {Promise} A promise that resolves on success or rejects on error. + */ + unlinkAsync: async function (filePath) { + return new Promise((resolve, reject)=>{ + fs.unlink(filePath, (err)=>{ + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }, + /** + * deletes a file/dir asynchronously, always resolves, never rejects. + * + * @function + * @param {string} filePath - The path of the file/dir to be deleted. + * @returns {Promise} A promise that resolves to an object containing either + * an `error` property if there is an error, or just {} on success. + */ + unlinkResolves: async function (filePath) { + return new Promise((resolve)=>{ + fs.unlink(filePath, (error)=>{ + if(error){ + resolve({error: error}); + return; + } + resolve({}); + }); + }); + }, + /** + * Reads the contents of a file asynchronously, always resolves, never rejects. + * Mainly use to read config and other files. + * This should not be used for reading project files that are being edited, for that use file system APIs + * as that apis will be able to deal with files being edited in the editor. + * + * @function + * @param {string} filePath - The path of the file to be read. + * @param {string} encoding - The encoding to use for reading the file. + * @returns {Promise} A promise that resolves to an object containing either + * an `error` property if there is an error, or a `data` property with the file contents. + */ + readFileResolves: function (filePath, encoding) { + return new Promise((resolve)=>{ + fs.readFile(filePath, encoding, function (error, data) { + if(error){ + resolve({error: error}); + return; + } + resolve({data: data}); + }); + }); + }, + /** + * Reads the contents of a file asynchronously, resolves with content or rejects with error. + * Mainly use to read config and other files. + * This should not be used for reading project files that are being edited, for that use file system APIs + * as that apis will be able to deal with files being edited in the editor. + * + * @param {string} filePath - The path to the file to be read. + * @param {string} encoding - The encoding format to use when reading the file. + * @returns {Promise} A promise that resolves with the file data when the read is successful, + * or rejects with an error if the read operation fails. + */ + readFileAsync: function (filePath, encoding) { + return new Promise((resolve, reject)=>{ + fs.readFile(filePath, encoding, function (error, data) { + if(error){ + reject(error); + return; + } + resolve(data); + }); + }); + }, + /** + * Asynchronously writes data to a file, replacing the file if it already exists. + * Mainly use to write config and other files. + * This should not be used for write project files that are being edited, for that use file system APIs + * as that apis will be able to deal with files being edited in the editor. + * + * @param {string} filePath - The path of the file where the data should be written. + * @param {string} content - The data to write into the file. + * @param {string} encoding - The character encoding to use when writing the file. + * @returns {Promise} A promise that resolves when the file has been successfully written, + * or rejects with an error if the operation fails. + */ + writeFileAsync: function (filePath, content, encoding) { + return new Promise((resolve, reject) => { + fs.writeFile(filePath, content, encoding, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }, fs: fsLib, path: pathLib }; diff --git a/src/phoenix/trust_ring.js b/src/phoenix/trust_ring.js index 5858e44624..ed9d04e14c 100644 --- a/src/phoenix/trust_ring.js +++ b/src/phoenix/trust_ring.js @@ -182,26 +182,6 @@ 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 -// core modules securely without worrying about interception by extensions. -// KernalModeTrust should only be available within all code that loads before the first default/any extension. -window.KernalModeTrust = { - CRED_KEY_API, - CRED_KEY_PROMO, - SIGNATURE_SALT_KEY, - aesKeys: { key, iv }, - setCredential, - getCredential, - removeCredential, - AESDecryptString, - generateRandomKeyAndIV, - dismantleKeyring -}; -if(Phoenix.isSpecRunnerWindow){ - window.specRunnerTestKernalModeTrust = window.KernalModeTrust; -} -// key is 64 hex characters, iv is 24 hex characters async function setCredential(credKey, secret) { if(!window.__TAURI__){ @@ -260,5 +240,62 @@ export async function initTrustRing() { if(!window.__TAURI__){ return; } + // this will only work once in a window unless dismantleKeyring is called. So this is safe as + // a public export as essentially this is a fn that only works in the boot and shutdown phase. await window.__TAURI__.tauri.invoke("trust_window_aes_key", {key, iv}); } + +/** + * Generates an SHA-256 hash signature of the provided data string combined with a salt. + * + * @param {string} dataString - The input data string that needs to be signed. + * @param {string} salt - A salt value to combine with the data string for additional uniqueness. + * @return {Promise} A promise that resolves to the generated SHA-256 hash signature as a hexadecimal string. + */ +async function generateDataSignature(dataString, salt) { + const signatureData = dataString + "|" + salt; + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(signatureData); + const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Validates the provided data signature by comparing it to an expected signature. + * + * @param {string} data - The data to validate the signature against. + * @param {string} signature - The actual signature to be validated. + * @param {string} salt - The salt used in generating the expected signature. + * @return {Promise} A promise resolving to true if the signature is valid, otherwise false. + */ +async function validateDataSignature(data, signature, salt) { + if (!signature) { + return false; + } + const expectedSignature = await generateDataSignature(data, salt); + return signature === expectedSignature; +} + +// this key is set at boot time as a trust 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 +// core modules securely without worrying about interception by extensions. +// KernalModeTrust should only be available within all code that loads before the first default/any extension. +window.KernalModeTrust = { + CRED_KEY_API, + CRED_KEY_PROMO, + SIGNATURE_SALT_KEY, + aesKeys: { key, iv }, + setCredential, + getCredential, + removeCredential, + AESDecryptString, + generateRandomKeyAndIV, + dismantleKeyring, + generateDataSignature, + validateDataSignature +}; +if(Phoenix.isSpecRunnerWindow){ + window.specRunnerTestKernalModeTrust = window.KernalModeTrust; +} +// key is 64 hex characters, iv is 24 hex characters diff --git a/src/services/login-service.js b/src/services/login-service.js index 751f33a517..8b1d0f1f1e 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -16,6 +16,8 @@ * */ +/*global path*/ + /** * Shared Login Service * @@ -33,6 +35,17 @@ define(function (require, exports, module) { const MS_IN_DAY = 10 * 24 * 60 * 60 * 1000; const TEN_MINUTES = 10 * 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 affect 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'; + let currentSalt; + + // Cache file path for desktop app entitlements + const CACHED_ENTITLEMENTS_FILE = path.join(Phoenix.app.getApplicationSupportDirectory(), + "cached_entitlements.json"); + // save a copy of window.fetch so that extensions wont tamper with it. let fetchFn = window.fetch; @@ -69,6 +82,132 @@ define(function (require, exports, module) { } + /** + * Get per-user salt for signature generation, creating and persisting one if it doesn't exist + * Used for signing cached data to prevent tampering + */ + async function getSalt() { + // Fallback salt constant for rare circumstances where salt generation fails + if(currentSalt) { + return currentSalt; + } + + 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); + } + currentSalt = salt; + return salt; + } + // In browser app, there is no way to securely store salt without extensions being able to + // read it. Return a static salt for basic integrity checking. + currentSalt = FALLBACK_SALT; + return FALLBACK_SALT; + } catch (error) { + console.error("Error getting signature salt:", error); + // Return a fallback salt to prevent crashes + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "saltGet", "Err"); + currentSalt = FALLBACK_SALT; + return FALLBACK_SALT; + } + } + + /** + * Load cached entitlements from disk with signature validation + * Returns null if no cache, invalid signature, or error + */ + async function _loadCachedEntitlements() { + if (!Phoenix.isNativeApp) { + return null; // No caching for browser app + } + + try { + const fileData = await Phoenix.VFS.readFileResolves(CACHED_ENTITLEMENTS_FILE, 'utf8'); + + if (fileData.error || !fileData.data) { + return null; // No cached file exists + } + + const cachedData = JSON.parse(fileData.data); + if (!cachedData.jsonData || !cachedData.sign) { + console.warn("Invalid cached entitlements format - missing jsonData or sign"); + await _clearCachedEntitlements(); + return null; + } + + // Validate signature + const salt = await getSalt(); + const isValidSignature = await KernalModeTrust.validateDataSignature( + cachedData.jsonData, + cachedData.sign, + salt + ); + + if (!isValidSignature) { + console.warn("Cached entitlements signature validation failed - possible tampering detected"); + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "entCacheLD", "signInvalid"); + await _clearCachedEntitlements(); + return null; + } + + // Parse and return the entitlements + return JSON.parse(cachedData.jsonData); + } catch (error) { + console.error("Error loading cached entitlements:", error); + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "entCacheLD", "error"); + await _clearCachedEntitlements(); // Clear corrupted cache + return null; + } + } + + /** + * Save entitlements to cache with signature + */ + async function _saveCachedEntitlements(entitlements) { + if (!Phoenix.isNativeApp || !entitlements) { + return; // No caching for browser app + } + + try { + const jsonData = JSON.stringify(entitlements); + const salt = await getSalt(); + const signature = await KernalModeTrust.generateDataSignature(jsonData, salt); + + const cacheData = { + jsonData: jsonData, + sign: signature + }; + + await Phoenix.VFS.writeFileAsync(CACHED_ENTITLEMENTS_FILE, JSON.stringify(cacheData), 'utf8'); + console.log("Entitlements cached successfully"); + } catch (error) { + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "entCacheSave", "err"); + console.error("Error saving cached entitlements:", error); + } + } + + /** + * Clear cached entitlements file + */ + async function _clearCachedEntitlements() { + if (!Phoenix.isNativeApp) { + return; // No caching for browser app + } + + try { + await Phoenix.VFS.unlinkResolves(CACHED_ENTITLEMENTS_FILE); + console.log("Cached entitlements cleared"); + } catch (error) { + console.log("Error clearing cached entitlements:", error); + } + } + + /** * Get entitlements from API or cache * Returns null if user is not logged in @@ -84,6 +223,26 @@ define(function (require, exports, module) { return cachedEntitlements; } + if (cachedEntitlements && !navigator.onLine) { + return cachedEntitlements; + } + + async function _processDiscCachedEntitlement() { + const diskCachedEntitlements = await _loadCachedEntitlements(); + if (diskCachedEntitlements) { + console.log("offline/network/server error: Using cached entitlements from disk"); + const entitlementsChanged = + JSON.stringify(cachedEntitlements) !== JSON.stringify(diskCachedEntitlements); + cachedEntitlements = diskCachedEntitlements; + // Trigger event if entitlements changed + if (entitlementsChanged) { + _debounceEntitlementsChanged(); + } + return cachedEntitlements; + } + return null; + } + try { const accountBaseURL = LoginService.getAccountBaseURL(); const language = brackets.getLocale(); @@ -110,6 +269,14 @@ define(function (require, exports, module) { fetchOptions.credentials = 'include'; } + // For desktop app, if offline, try to return disc cached entitlements + if (Phoenix.isNativeApp && !navigator.onLine) { + const processedEntitlement = await _processDiscCachedEntitlement(); + if (processedEntitlement) { + return processedEntitlement; + } + } + const response = await fetchFn(url, fetchOptions); if (response.ok) { @@ -120,6 +287,11 @@ define(function (require, exports, module) { cachedEntitlements = result; + // Save to disk cache for desktop app + if (Phoenix.isNativeApp) { + await _saveCachedEntitlements(result); + } + // Trigger event if entitlements changed if (entitlementsChanged) { _debounceEntitlementsChanged(); @@ -127,9 +299,32 @@ define(function (require, exports, module) { return cachedEntitlements; } + } else if (response.status >= 500 && response.status < 600) { + // Handle 5xx errors by loading from cache. + if (Phoenix.isNativeApp) { + console.warn('Fetch entitlements server error:', response.status); + const processedEntitlement = await _processDiscCachedEntitlement(); + if (processedEntitlement) { + return processedEntitlement; + } + } + } else if (Phoenix.isNativeApp) { + // 4xx errors are genuine auth fail errors, so our cache is not good then + console.warn('Cearing entitlements, entitlements server error:', response.status); + await _clearCachedEntitlements(); } } catch (error) { console.error('Failed to fetch entitlements:', error); + + // errors that happen during the fetch operation itself, which are typically not HTTP errors + // returned by the server, but rather issues at the network or browser level. + // For desktop app, fall back to cached entitlements if available + if (Phoenix.isNativeApp) { + const processedEntitlement = await _processDiscCachedEntitlement(); + if (processedEntitlement) { + return processedEntitlement; + } + } } return null; @@ -139,7 +334,7 @@ define(function (require, exports, module) { * Clear cached entitlements and trigger change event * Called when user logs out */ - function clearEntitlements() { + async function clearEntitlements() { if (cachedEntitlements) { cachedEntitlements = null; _debounceEntitlementsChanged(); @@ -286,9 +481,12 @@ define(function (require, exports, module) { // Logged-in user with trial if (serverEntitlements.plan.paidSubscriber) { // Already a paid subscriber, return as-is + // todo we need to check and filter valid till for each fields that we are interested in. return serverEntitlements; } // Enhance entitlements for trial user + // todo we need to prune and filter serverEntitlements valid till for each fields that we are interested in. + // ie if any entitlement has valid till expired, we need to deactivate that entitlement return { ...serverEntitlements, plan: { @@ -335,6 +533,7 @@ define(function (require, exports, module) { LoginService.getEntitlements = getEntitlements; LoginService.getEffectiveEntitlements = getEffectiveEntitlements; LoginService.clearEntitlements = clearEntitlements; + LoginService.getSalt = getSalt; LoginService.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; // Test-only exports for integration testing diff --git a/src/services/promotions.js b/src/services/promotions.js index 6117d39adf..722d23eb26 100644 --- a/src/services/promotions.js +++ b/src/services/promotions.js @@ -52,26 +52,10 @@ define(function (require, exports, module) { 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 */ @@ -104,46 +88,13 @@ define(function (require, exports, module) { } } - /** - * 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 = await _getSalt(); - const data = proVersion + "|" + endDate + "|" + salt; - - // Use browser crypto API for SHA-256 hashing - const encoder = new TextEncoder(); - const dataBuffer = encoder.encode(data); - const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // hash hex string + const salt = await LoginService.getSalt(); + const data = proVersion + "|" + endDate; + return KernalModeTrust.generateDataSignature(data, salt); } /** @@ -183,14 +134,14 @@ 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 fileData = await _readFileAsync(PROMO_LOCAL_FILE); + const fileData = await Phoenix.VFS.readFileResolves(PROMO_LOCAL_FILE, 'utf8'); - if (!fileData) { + if (fileData.error) { return null; // No data exists - genuine first install } try { - const trialData = JSON.parse(fileData); + const trialData = JSON.parse(fileData.data); const isValid = await _isValidSignature(trialData); if (isValid) { return { data: trialData }; // Valid trial data @@ -458,7 +409,6 @@ define(function (require, exports, module) { ProDialogs: ProDialogs, _getTrialData: _getTrialData, _setTrialData: _setTrialData, - _getSalt: _getSalt, _isTrialClosedForCurrentVersion: _isTrialClosedForCurrentVersion, _cleanTrialData: _clearTrialData, _cleanSaltData: async function() { diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index b84a9fb4f4..b9b828dd66 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -23,6 +23,7 @@ define(function (require, exports, module) { require("spec/Phoenix-platform-test"); require("spec/Tauri-platform-test"); + require("spec/trust-ring-test"); require("spec/utframework-suite-test"); require("spec/Async-test"); require("spec/CommandManager-test"); diff --git a/test/spec/LowLevelFileIO-test.js b/test/spec/LowLevelFileIO-test.js index 7b72eb897c..5fe7063fd2 100644 --- a/test/spec/LowLevelFileIO-test.js +++ b/test/spec/LowLevelFileIO-test.js @@ -19,7 +19,7 @@ * */ -/*global describe, it, expect, beforeEach, afterEach, awaitsFor, awaitsForDone*/ +/*global describe, it, expect, beforeEach, afterEach, awaitsFor, awaitsForDone, fail*/ define(function (require, exports, module) { @@ -641,6 +641,159 @@ define(function (require, exports, module) { }); }); + describe("VFS Async APIs", function () { + + var contents = "This content was generated from LowLevelFileIO-test.js for VFS async APIs"; + + describe("writeFileAsync", function () { + + it("should write file content asynchronously", async function () { + var testFile = baseDir + "/vfs_write_test.txt"; + + try { + await Phoenix.VFS.writeFileAsync(testFile, contents, UTF8); + + var readFileCB = readFileSpy(); + brackets.fs.readFile(testFile, UTF8, readFileCB); + + await awaitsFor(function () { return readFileCB.wasCalled; }, 1000); + + expect(readFileCB.error).toBeFalsy(); + expect(readFileCB.content).toBe(contents); + } catch (error) { + fail("writeFileAsync should not reject: " + error); + } + }); + + it("should reject when writing to invalid path", async function () { + try { + await Phoenix.VFS.writeFileAsync("/invalid/path/file.txt", contents, UTF8); + fail("writeFileAsync should have rejected for invalid path"); + } catch (error) { + expect(error).toBeTruthy(); + } + }); + + }); // describe("writeFileAsync") + + describe("readFileAsync", function () { + + beforeEach(async function () { + var writeFileCB = errSpy(); + brackets.fs.writeFile(testDir + "/vfs_read_test.txt", contents, UTF8, writeFileCB); + await awaitsFor(function () { return writeFileCB.wasCalled; }, 1000); + expect(writeFileCB.error).toBeFalsy(); + }); + + it("should read file content asynchronously", async function () { + try { + var data = await Phoenix.VFS.readFileAsync(testDir + "/vfs_read_test.txt", UTF8); + expect(data).toBe(contents); + } catch (error) { + fail("readFileAsync should not reject: " + error); + } + }); + + it("should reject when reading non-existent file", async function () { + try { + await Phoenix.VFS.readFileAsync("/non/existent/file.txt", UTF8); + fail("readFileAsync should have rejected for non-existent file"); + } catch (error) { + expect(error.code).toBe(brackets.fs.ERR_CODES.ENOENT); + } + }); + + }); // describe("readFileAsync") + + describe("readFileResolves", function () { + + beforeEach(async function () { + var writeFileCB = errSpy(); + brackets.fs.writeFile(testDir + "/vfs_read_resolves_test.txt", contents, UTF8, writeFileCB); + await awaitsFor(function () { return writeFileCB.wasCalled; }, 1000); + expect(writeFileCB.error).toBeFalsy(); + }); + + it("should resolve with data when reading existing file", async function () { + var result = await Phoenix.VFS.readFileResolves(testDir + "/vfs_read_resolves_test.txt", UTF8); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(contents); + }); + + it("should resolve with error when reading non-existent file", async function () { + var result = await Phoenix.VFS.readFileResolves("/non/existent/file.txt", UTF8); + expect(result.error).toBeTruthy(); + expect(result.error.code).toBe(brackets.fs.ERR_CODES.ENOENT); + expect(result.data).toBeUndefined(); + }); + + }); // describe("readFileResolves") + + describe("unlinkAsync", function () { + + it("should delete file asynchronously", async function () { + var testFile = baseDir + "/vfs_unlink_test.txt"; + + var writeFileCB = errSpy(); + brackets.fs.writeFile(testFile, contents, UTF8, writeFileCB); + await awaitsFor(function () { return writeFileCB.wasCalled; }, 1000); + expect(writeFileCB.error).toBeFalsy(); + + try { + await Phoenix.VFS.unlinkAsync(testFile); + + var statCB = statSpy(); + brackets.fs.stat(testFile, statCB); + await awaitsFor(function () { return statCB.wasCalled; }, 1000); + + expect(statCB.error.code).toBe(brackets.fs.ERR_CODES.ENOENT); + } catch (error) { + fail("unlinkAsync should not reject: " + error); + } + }); + + it("should reject when deleting non-existent file", async function () { + try { + await Phoenix.VFS.unlinkAsync("/non/existent/file.txt"); + fail("unlinkAsync should have rejected for non-existent file"); + } catch (error) { + expect(error.code).toBe(brackets.fs.ERR_CODES.ENOENT); + } + }); + + }); // describe("unlinkAsync") + + describe("unlinkResolves", function () { + + it("should resolve with empty object when deleting existing file", async function () { + var testFile = baseDir + "/vfs_unlink_resolves_test.txt"; + + var writeFileCB = errSpy(); + brackets.fs.writeFile(testFile, contents, UTF8, writeFileCB); + await awaitsFor(function () { return writeFileCB.wasCalled; }, 1000); + expect(writeFileCB.error).toBeFalsy(); + + var result = await Phoenix.VFS.unlinkResolves(testFile); + expect(result.error).toBeUndefined(); + expect(Object.keys(result).length).toBe(0); + + var statCB = statSpy(); + brackets.fs.stat(testFile, statCB); + await awaitsFor(function () { return statCB.wasCalled; }, 1000); + + expect(statCB.error.code).toBe(brackets.fs.ERR_CODES.ENOENT); + }); + + it("should resolve with error when deleting non-existent file", async function () { + var result = await Phoenix.VFS.unlinkResolves("/non/existent/file.txt"); + expect(result.error).toBeTruthy(); + expect(result.error.code).toBe(brackets.fs.ERR_CODES.ENOENT); + }); + + }); // describe("unlinkResolves") + + }); // describe("VFS Async APIs") + describe("specialDirectories", function () { it("should have an application support directory", async function () { // these tests are here as these are absolute unchanging dir convention used by phoenix. diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js index 7e6e0d1195..28e5dea41d 100644 --- a/test/spec/promotions-integ-test.js +++ b/test/spec/promotions-integ-test.js @@ -35,7 +35,7 @@ define(function (require, exports, module) { } let testWindow, - LoginService, + LoginServicePromo, ProDialogs, originalAppConfig, originalFetch, @@ -45,16 +45,16 @@ define(function (require, exports, module) { testWindow = await SpecRunnerUtils.createTestWindowAndRun(); // Access modules from test window - LoginService = testWindow._test_promo_login_exports; + LoginServicePromo = 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); + console.log('Debug: Available exports:', Object.keys(LoginServicePromo)); + console.log('Debug: setDateNowFn available?', !!LoginServicePromo.setDateNowFn); // Use the new setDateNowFn injection mechanism - if (LoginService.setDateNowFn) { - LoginService.setDateNowFn(() => { + if (LoginServicePromo.setDateNowFn) { + LoginServicePromo.setDateNowFn(() => { return mockNow; }); } else { @@ -87,7 +87,7 @@ define(function (require, exports, module) { } testWindow = null; - LoginService = null; + LoginServicePromo = null; ProDialogs = null; originalFetch = null; await SpecRunnerUtils.closeTestWindow(); @@ -95,7 +95,7 @@ define(function (require, exports, module) { 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(); + const isLoggedIn = LoginServicePromo.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."); } @@ -107,27 +107,26 @@ define(function (require, exports, module) { 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(); + expect(LoginServicePromo._getTrialData).toBeDefined(); + expect(LoginServicePromo._setTrialData).toBeDefined(); + expect(LoginServicePromo._isTrialClosedForCurrentVersion).toBeDefined(); + expect(LoginServicePromo._cleanTrialData).toBeDefined(); + expect(LoginServicePromo._cleanSaltData).toBeDefined(); + expect(LoginServicePromo.activateProTrial).toBeDefined(); + expect(LoginServicePromo.getProTrialDaysRemaining).toBeDefined(); + expect(LoginServicePromo.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(); + await LoginServicePromo._cleanTrialData(); // Call the function - this simulates first install scenario - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Get the trial data that was actually stored - const storedResult = await LoginService._getTrialData(); + const storedResult = await LoginServicePromo._getTrialData(); // Verify trial data was set correctly expect(storedResult).not.toBeNull(); @@ -135,7 +134,7 @@ define(function (require, exports, module) { 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); + const expectedEndDate = mockNow + (30 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); expect(storedResult.data.endDate).toBe(expectedEndDate); // Verify upgrade dialog appears with correct content @@ -157,17 +156,17 @@ define(function (require, exports, module) { 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 + endDate: mockNow - (1 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), // Expired yesterday signature: "mock_signature" }; // Set up existing trial data first - await LoginService._setTrialData(existingTrial); + await LoginServicePromo._setTrialData(existingTrial); - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Get the updated trial data - const updatedResult = await LoginService._getTrialData(); + const updatedResult = await LoginServicePromo._getTrialData(); // Verify new trial data was set for newer version expect(updatedResult).not.toBeNull(); @@ -175,8 +174,8 @@ define(function (require, exports, module) { 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); + const expectedEndDate = mockNow + (LoginServicePromo.TRIAL_CONSTANTS.SUBSEQUENT_TRIAL_DAYS + * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); expect(updatedResult.data.endDate).toBe(expectedEndDate); // Verify upgrade dialog appears with correct content @@ -196,7 +195,7 @@ define(function (require, exports, module) { }); 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 futureEndDate = mockNow + (10 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); const existingTrial = { proVersion: "3.0.0", endDate: futureEndDate, @@ -204,12 +203,12 @@ define(function (require, exports, module) { }; // Set up existing trial data first - await LoginService._setTrialData(existingTrial); + await LoginServicePromo._setTrialData(existingTrial); - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Get the updated trial data - const updatedResult = await LoginService._getTrialData(); + const updatedResult = await LoginServicePromo._getTrialData(); // Verify existing trial was preserved but version updated expect(updatedResult).not.toBeNull(); @@ -235,17 +234,17 @@ define(function (require, exports, module) { 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), + endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), signature: "mock_signature" }; // Set up existing trial data first - await LoginService._setTrialData(existingTrial); + await LoginServicePromo._setTrialData(existingTrial); - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Get the trial data after activation - const currentResult = await LoginService._getTrialData(); + const currentResult = await LoginServicePromo._getTrialData(); // Verify trial data remains unchanged (same version, same end date) expect(currentResult.data).toBeDefined(); @@ -261,17 +260,17 @@ define(function (require, exports, module) { 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), + endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), signature: "mock_signature" }; // Set up existing trial data first - await LoginService._setTrialData(existingTrial); + await LoginServicePromo._setTrialData(existingTrial); - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Get the trial data after activation - const currentResult = await LoginService._getTrialData(); + const currentResult = await LoginServicePromo._getTrialData(); // Verify trial data remains unchanged (older current version scenario) expect(currentResult.data).toBeDefined(); @@ -290,17 +289,17 @@ define(function (require, exports, module) { 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 + endDate: mockNow - LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday signature: "mock_signature" }; // Set up expired trial data first - await LoginService._setTrialData(expiredTrial); + await LoginServicePromo._setTrialData(expiredTrial); - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Get the updated trial data - const updatedResult = await LoginService._getTrialData(); + const updatedResult = await LoginServicePromo._getTrialData(); // Verify upgrade dialog shown flag was set expect(updatedResult).not.toBeNull(); @@ -376,17 +375,17 @@ define(function (require, exports, module) { 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 + endDate: mockNow - LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday signature: "mock_signature" }; // Set up expired trial data first - await LoginService._setTrialData(expiredTrial); + await LoginServicePromo._setTrialData(expiredTrial); - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Get the updated trial data - const updatedTrialData = await LoginService._getTrialData(); + const updatedTrialData = await LoginServicePromo._getTrialData(); // Verify new trial was granted for version upgrade expect(updatedTrialData).not.toBeNull(); @@ -394,7 +393,7 @@ define(function (require, exports, module) { 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); + const expectedEndDate = mockNow + (7 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); expect(updatedTrialData.data.endDate).toBe(expectedEndDate); // Should show upgrade dialog (not ended dialog) @@ -419,32 +418,32 @@ define(function (require, exports, module) { 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 futureEndDate = mockNow + (5.7 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); const trialData = { proVersion: "3.1.0", endDate: futureEndDate }; // Set up trial data - await LoginService._setTrialData(trialData); + await LoginServicePromo._setTrialData(trialData); - const remainingDays = await LoginService.getProTrialDaysRemaining(); + const remainingDays = await LoginServicePromo.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 pastEndDate = mockNow - (2 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); const trialData = { proVersion: "3.1.0", endDate: pastEndDate }; // Set up expired trial data - await LoginService._setTrialData(trialData); + await LoginServicePromo._setTrialData(trialData); - const remainingDays = await LoginService.getProTrialDaysRemaining(); + const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); expect(remainingDays).toBe(0); }); @@ -468,12 +467,12 @@ define(function (require, exports, module) { // Setup: Create a valid trial first const validTrial = { proVersion: "3.1.0", - endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) }; - await LoginService._setTrialData(validTrial); + await LoginServicePromo._setTrialData(validTrial); // Get the valid trial data (should include signature) - let storedResult = await LoginService._getTrialData(); + let storedResult = await LoginServicePromo._getTrialData(); expect(storedResult).not.toBeNull(); expect(storedResult.data).toBeDefined(); // Should have valid data expect(storedResult.error).toBeUndefined(); // Should not have error @@ -482,25 +481,25 @@ define(function (require, exports, module) { const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; // Manually store the tampered data (bypassing _setTrialData validation) - await LoginService._testSetPromoJSON(tamperedTrial); + await LoginServicePromo._testSetPromoJSON(tamperedTrial); // Verify: _getTrialData should detect corruption - const corruptedResult = await LoginService._getTrialData(); + const corruptedResult = await LoginServicePromo._getTrialData(); expect(corruptedResult).not.toBeNull(); - expect(corruptedResult.error).toBe(LoginService.ERROR_CONSTANTS.ERR_CORRUPTED); + expect(corruptedResult.error).toBe(LoginServicePromo.ERROR_CONSTANTS.ERR_CORRUPTED); // Verify: activateProTrial should create expired trial marker and deny trial - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Should create expired trial marker instead of clearing - const resultAfterSecurity = await LoginService._getTrialData(); + const resultAfterSecurity = await LoginServicePromo._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(); + const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); expect(remainingDays).toBe(0); // Should show trial ended dialog (security notice) @@ -521,16 +520,16 @@ define(function (require, exports, module) { testWindow.AppConfig.apiVersion = "3.2.0"; // Clean any existing salt to simulate fresh install - await LoginService._cleanSaltData(); + await LoginServicePromo._cleanSaltData(); const futureTrial = { proVersion: "3.2.0", - endDate: mockNow + (10 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + endDate: mockNow + (10 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) }; - await LoginService._setTrialData(futureTrial); + await LoginServicePromo._setTrialData(futureTrial); // Verify trial is valid with 3.2.0 - let trialResult = await LoginService._getTrialData(); + let trialResult = await LoginServicePromo._getTrialData(); expect(trialResult).not.toBeNull(); expect(trialResult.data).toBeDefined(); expect(trialResult.error).toBeUndefined(); @@ -541,19 +540,19 @@ define(function (require, exports, module) { 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(); + const downgradeResult = await LoginServicePromo._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(); + const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); expect(remainingDays).toBe(10); // activateProTrial should preserve the existing valid trial - await LoginService.activateProTrial(); - const finalTrial = await LoginService._getTrialData(); + await LoginServicePromo.activateProTrial(); + const finalTrial = await LoginServicePromo._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 @@ -563,27 +562,27 @@ define(function (require, exports, module) { // Setup: Create trial data with missing signature field const trialWithoutSignature = { proVersion: "3.1.0", - endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) // No signature field }; // Manually store data without signature (bypassing _setTrialData) - await LoginService._testSetPromoJSON(trialWithoutSignature); + await LoginServicePromo._testSetPromoJSON(trialWithoutSignature); // Should detect corruption due to missing signature - const result = await LoginService._getTrialData(); - expect(result.error).toBe(LoginService.ERROR_CONSTANTS.ERR_CORRUPTED); + const result = await LoginServicePromo._getTrialData(); + expect(result.error).toBe(LoginServicePromo.ERROR_CONSTANTS.ERR_CORRUPTED); // Should create expired trial marker for security - await LoginService.activateProTrial(); - const afterActivation = await LoginService._getTrialData(); + await LoginServicePromo.activateProTrial(); + const afterActivation = await LoginServicePromo._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(); + const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); expect(remainingDays).toBe(0); // Should show security dialog @@ -598,35 +597,35 @@ define(function (require, exports, module) { it("should persist salt correctly", async function () { // Clean existing salt - await LoginService._cleanSaltData(); + await LoginServicePromo._cleanSaltData(); // Get salt (should generate new one) - const salt1 = await LoginService._getSalt(); + const salt1 = await LoginServicePromo.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(); + const salt2 = await LoginServicePromo.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) + endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) }; - await LoginService._setTrialData(trial); + await LoginServicePromo._setTrialData(trial); // Verify trial is valid - const storedResult = await LoginService._getTrialData(); + const storedResult = await LoginServicePromo._getTrialData(); expect(storedResult.data).toBeDefined(); expect(storedResult.error).toBeUndefined(); - const salt3 = await LoginService._getSalt(); + const salt3 = await LoginServicePromo.LoginService.getSalt(); expect(salt3).toBe(salt1); // Should be persistent // Trial should still be valid after "restart" - const restartResult = await LoginService._getTrialData(); + const restartResult = await LoginServicePromo._getTrialData(); expect(restartResult.data).toBeDefined(); expect(restartResult.error).toBeUndefined(); expect(restartResult.data.proVersion).toBe("3.1.0"); @@ -636,19 +635,19 @@ define(function (require, exports, module) { // Setup: Create a valid trial first const validTrial = { proVersion: "3.1.0", - endDate: mockNow + (5 * LoginService.TRIAL_CONSTANTS.MS_PER_DAY) + endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) }; - await LoginService._setTrialData(validTrial); + await LoginServicePromo._setTrialData(validTrial); // Attack: Corrupt the trial data - const storedResult = await LoginService._getTrialData(); + const storedResult = await LoginServicePromo._getTrialData(); const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; // Manually store the tampered data (bypassing validation) - await LoginService._testSetPromoJSON(tamperedTrial); + await LoginServicePromo._testSetPromoJSON(tamperedTrial); // First activation should create expired marker - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Dismiss the security dialog await testWindow.__PR.waitForModalDialog(".modal"); @@ -656,13 +655,13 @@ define(function (require, exports, module) { await testWindow.__PR.waitForModalDialogClosed(".modal"); // Verify expired marker exists - const expiredResult = await LoginService._getTrialData(); + const expiredResult = await LoginServicePromo._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(); + await LoginServicePromo.activateProTrial(); // Should show trial ended dialog again (since trial is still expired) await testWindow.__PR.waitForModalDialog(".modal"); @@ -670,13 +669,13 @@ define(function (require, exports, module) { await testWindow.__PR.waitForModalDialogClosed(".modal"); // Should still have the expired marker, not a new 30-day trial - const afterRestartResult = await LoginService._getTrialData(); + const afterRestartResult = await LoginServicePromo._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(); + const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); expect(remainingDays).toBe(0); }); @@ -684,40 +683,40 @@ define(function (require, exports, module) { // 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 + endDate: mockNow - (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), // Expired 5 days ago upgradeDialogShownVersion: "3.1.0" // Dialog already shown for current version }; - await LoginService._setTrialData(expiredTrial); + await LoginServicePromo._setTrialData(expiredTrial); // Verify trial is properly expired - const expiredResult = await LoginService._getTrialData(); + const expiredResult = await LoginServicePromo._getTrialData(); expect(expiredResult.data).toBeDefined(); expect(expiredResult.error).toBeUndefined(); // Verify _isTrialClosedForCurrentVersion detects closed trial - const isClosedBefore = await LoginService._isTrialClosedForCurrentVersion(expiredResult.data); + const isClosedBefore = await LoginServicePromo._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 rolledBackTime = mockNow - (10 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); // 10 days ago + LoginServicePromo.setDateNowFn(() => rolledBackTime); - const remainingDaysAfterRollback = await LoginService.getProTrialDaysRemaining(); + const remainingDaysAfterRollback = await LoginServicePromo.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); + const isClosedAfterRollback = await LoginServicePromo._isTrialClosedForCurrentVersion(expiredResult.data); expect(isClosedAfterRollback).toBe(true); // Should still be closed // Reset time - LoginService.setDateNowFn(() => mockNow); + LoginServicePromo.setDateNowFn(() => mockNow); // activateProTrial should not grant new trial even after time manipulation - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Should still have the same expired trial, not a new one - const finalResult = await LoginService._getTrialData(); + const finalResult = await LoginServicePromo._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 @@ -727,29 +726,29 @@ define(function (require, exports, module) { // 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 + endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), // Still has time remaining upgradeDialogShownVersion: "3.1.0" // Dialog was already shown for current version }; - await LoginService._setTrialData(validTrial); + await LoginServicePromo._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(); + const trialResult = await LoginServicePromo._getTrialData(); expect(trialResult.data).toBeDefined(); // _isTrialClosedForCurrentVersion should return true because dialog was shown for current version - const isClosed = await LoginService._isTrialClosedForCurrentVersion(trialResult.data); + const isClosed = await LoginServicePromo._isTrialClosedForCurrentVersion(trialResult.data); expect(isClosed).toBe(true); // getProTrialDaysRemaining should return 0 due to closure flag - const remainingDays = await LoginService.getProTrialDaysRemaining(); + const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); expect(remainingDays).toBe(0); // activateProTrial should not grant new trial due to closure flag - await LoginService.activateProTrial(); + await LoginServicePromo.activateProTrial(); // Should preserve the existing trial with dialog shown flag - const finalResult = await LoginService._getTrialData(); + const finalResult = await LoginServicePromo._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 @@ -758,11 +757,11 @@ define(function (require, exports, module) { 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); + const isClosedAfterUpgrade = await LoginServicePromo._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(); + const remainingAfterUpgrade = await LoginServicePromo.getProTrialDaysRemaining(); expect(remainingAfterUpgrade).toBeGreaterThan(0); // Reset version for cleanup @@ -771,8 +770,8 @@ define(function (require, exports, module) { afterEach(async function() { // Clean up after each security test - await LoginService._cleanTrialData(); - await LoginService._cleanSaltData(); + await LoginServicePromo._cleanTrialData(); + await LoginServicePromo._cleanSaltData(); // Reset app config to default testWindow.AppConfig = { diff --git a/test/spec/trust-ring-test.js b/test/spec/trust-ring-test.js new file mode 100644 index 0000000000..3507327d4c --- /dev/null +++ b/test/spec/trust-ring-test.js @@ -0,0 +1,196 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * Original work Copyright (c) 2013 - 2021 Adobe Systems Incorporated. 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, beforeEach, afterEach, fs, path, jasmine, expectAsync*/ + +define(function (require, exports, module) { + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + describe("unit: Trust Ring Tests", function () { + const TrustRing = window.specRunnerTestKernalModeTrust; + + beforeEach(async function () { + + }); + + afterEach(async function () { + + }); + + describe("generateDataSignature", function () { + it("should generate a consistent signature for the same data and salt", async function () { + const data = "test data"; + const salt = "test salt"; + + const signature1 = await TrustRing.generateDataSignature(data, salt); + const signature2 = await TrustRing.generateDataSignature(data, salt); + + expect(signature1).toBe(signature2); + expect(signature1).toBeDefined(); + expect(typeof signature1).toBe('string'); + expect(signature1.length).toBeGreaterThan(0); + }); + + it("should generate different signatures for different data", async function () { + const salt = "test salt"; + + const signature1 = await TrustRing.generateDataSignature("data1", salt); + const signature2 = await TrustRing.generateDataSignature("data2", salt); + + expect(signature1).not.toBe(signature2); + }); + + it("should generate different signatures for different salts", async function () { + const data = "test data"; + + const signature1 = await TrustRing.generateDataSignature(data, "salt1"); + const signature2 = await TrustRing.generateDataSignature(data, "salt2"); + + expect(signature1).not.toBe(signature2); + }); + + it("should handle empty strings", async function () { + const signature1 = await TrustRing.generateDataSignature("", ""); + const signature2 = await TrustRing.generateDataSignature("", "salt"); + const signature3 = await TrustRing.generateDataSignature("data", ""); + + expect(signature1).toBeDefined(); + expect(signature2).toBeDefined(); + expect(signature3).toBeDefined(); + expect(signature1).not.toBe(signature2); + expect(signature2).not.toBe(signature3); + }); + + it("should generate hexadecimal string output", async function () { + const signature = await TrustRing.generateDataSignature("test", "salt"); + + expect(signature).toMatch(/^[a-f0-9]+$/); + }); + }); + + describe("validateDataSignature", function () { + it("should validate correct signature", async function () { + const data = "test data"; + const salt = "test salt"; + + const signature = await TrustRing.generateDataSignature(data, salt); + const isValid = await TrustRing.validateDataSignature(data, signature, salt); + + expect(isValid).toBe(true); + }); + + it("should reject invalid signature", async function () { + const data = "test data"; + const salt = "test salt"; + const invalidSignature = "invalid signature"; + + const isValid = await TrustRing.validateDataSignature(data, invalidSignature, salt); + + expect(isValid).toBe(false); + }); + + it("should reject signature with wrong data", async function () { + const salt = "test salt"; + + const signature = await TrustRing.generateDataSignature("original data", salt); + const isValid = await TrustRing.validateDataSignature("different data", signature, salt); + + expect(isValid).toBe(false); + }); + + it("should reject signature with wrong salt", async function () { + const data = "test data"; + + const signature = await TrustRing.generateDataSignature(data, "original salt"); + const isValid = await TrustRing.validateDataSignature(data, signature, "different salt"); + + expect(isValid).toBe(false); + }); + + it("should return false for null or undefined data", async function () { + const signature = "some signature"; + const salt = "test salt"; + + const isValid1 = await TrustRing.validateDataSignature(null, signature, salt); + const isValid2 = await TrustRing.validateDataSignature(undefined, signature, salt); + + expect(isValid1).toBe(false); + expect(isValid2).toBe(false); + }); + + it("should return false for null or undefined signature", async function () { + const data = "test data"; + const salt = "test salt"; + + const isValid1 = await TrustRing.validateDataSignature(data, null, salt); + const isValid2 = await TrustRing.validateDataSignature(data, undefined, salt); + + expect(isValid1).toBe(false); + expect(isValid2).toBe(false); + }); + + it("should handle empty strings correctly", async function () { + const signature = await TrustRing.generateDataSignature("", ""); + const isValid = await TrustRing.validateDataSignature("", signature, ""); + + expect(isValid).toBe(true); + }); + }); + + describe("generateDataSignature and validateDataSignature integration", function () { + it("should work with JSON data like entitlements", async function () { + const entitlements = { + premium: true, + features: ["feature1", "feature2"], + expiry: "2024-12-31" + }; + const jsonData = JSON.stringify(entitlements); + const salt = "random-salt-123"; + + const signature = await TrustRing.generateDataSignature(jsonData, salt); + const isValid = await TrustRing.validateDataSignature(jsonData, signature, salt); + + expect(isValid).toBe(true); + }); + + it("should work with unicode characters", async function () { + const data = "测试数据 with émojis 🔒"; + const salt = "unicode-salt-🔑"; + + const signature = await TrustRing.generateDataSignature(data, salt); + const isValid = await TrustRing.validateDataSignature(data, signature, salt); + + expect(isValid).toBe(true); + }); + + it("should work with large data strings", async function () { + const largeData = "x".repeat(10000); + const salt = "large-data-salt"; + + const signature = await TrustRing.generateDataSignature(largeData, salt); + const isValid = await TrustRing.validateDataSignature(largeData, signature, salt); + + expect(isValid).toBe(true); + }); + }); + }); +});