From 4c56dfa589baa33ca167e27241ad421f1e10f7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Mon, 31 Aug 2026 11:02:13 +0200 Subject: [PATCH 1/5] Add regression test for sync() reporting pending update as UP_TO_DATE (#46) Add a scenario that calls sync() twice with ON_NEXT_RESTART, without any restart in between, and assert both calls report UPDATE_INSTALLED. This covers the case getUpdateMetadata()/getCurrentPackage().isPending must reflect a still-pending install, which was silently broken by the attachLocalPackageMethods regression (see PR #39 review comment). --- .../scenarios/scenarioSyncRestart2x.js | 22 ++++++++++++++++ test/test.ts | 26 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 test/template/scenarios/scenarioSyncRestart2x.js diff --git a/test/template/scenarios/scenarioSyncRestart2x.js b/test/template/scenarios/scenarioSyncRestart2x.js new file mode 100644 index 00000000..9094a0d9 --- /dev/null +++ b/test/template/scenarios/scenarioSyncRestart2x.js @@ -0,0 +1,22 @@ +var CodePushWrapper = require("../codePushWrapper.js"); +import CodePush from "@bitrise/code-push-sdk"; + +module.exports = { + startTest: function (testApp) { + testApp.sendCurrentAndPendingPackage() + .then(() => { + CodePushWrapper.sync(testApp, (status) => { + if (status === CodePush.SyncStatus.UPDATE_INSTALLED) { + testApp.sendCurrentAndPendingPackage().then(() => { + // Call sync() again without restarting: the update from the first call is still pending. + CodePushWrapper.sync(testApp, () => {}, undefined, { installMode: CodePush.InstallMode.ON_NEXT_RESTART }); + }); + } + }, undefined, { installMode: CodePush.InstallMode.ON_NEXT_RESTART }); + }); + }, + + getScenarioName: function () { + return "Sync Restart 2x (no restart in between)"; + } +}; diff --git a/test/test.ts b/test/test.ts index c117014f..c4dd91ec 100644 --- a/test/test.ts +++ b/test/test.ts @@ -616,6 +616,7 @@ const ScenarioSyncResumeDelay = "scenarioSyncResumeDelay.js"; const ScenarioSyncRestartDelay = "scenarioSyncRestartDelay.js"; const ScenarioSyncSuspendDelay = "scenarioSyncSuspendDelay.js"; const ScenarioSync2x = "scenarioSync2x.js"; +const ScenarioSyncRestart2x = "scenarioSyncRestart2x.js"; const ScenarioRestart = "scenarioRestart.js"; const ScenarioRestart2x = "scenarioRestart2x.js"; const ScenarioSyncMandatoryDefault = "scenarioSyncMandatoryDefault.js"; @@ -1538,6 +1539,31 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl }, ScenarioSync2x); }); + TestBuilder.describe("#window.codePush.sync restart 2x", + () => { + // Regression test: a sync() called again while a previous ON_NEXT_RESTART install is + // still pending (i.e. no actual restart happened in between) must report UPDATE_INSTALLED, + // not UP_TO_DATE. getCurrentPackage().isPending must reflect that pending state. + TestBuilder.it("window.codePush.sync.restart2x.stillpending", false, + (done: Mocha.Done) => { + ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; + + setupUpdateScenario(projectManager, targetPlatform, UpdateDeviceReady, "Update 1 (good update)") + .then((updatePath: string) => { + ServerUtil.updatePackagePath = updatePath; + projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); + return ServerUtil.expectTestMessages([ + new ServerUtil.AppMessage(ServerUtil.TestMessage.PENDING_PACKAGE, [null]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.CURRENT_PACKAGE, [null]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.SYNC_STATUS, [ServerUtil.TestMessage.SYNC_UPDATE_INSTALLED]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.PENDING_PACKAGE, [ServerUtil.updateResponse.update_info.package_hash]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.CURRENT_PACKAGE, [null]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.SYNC_STATUS, [ServerUtil.TestMessage.SYNC_UPDATE_INSTALLED])]); + }) + .done(() => { done(); }, (e) => { done(e); }); + }); + }, ScenarioSyncRestart2x); + TestBuilder.describe("#window.codePush.sync minimum background duration tests", () => { TestBuilder.it("defaults to no minimum for Resume mode", false, From e27ed10f7d22c30ff44a12c6d5f40fdbf4d29645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 1 Sep 2026 12:24:32 +0200 Subject: [PATCH 2/5] Fix missing hash verification (#49) * Fix missing hash verification * Simplify hash handling in test harness --- .../codepush/react/CodePushUpdateManager.java | 42 ++++---- .../script/serverUtil.js | 29 ++++++ .../code-push-plugin-testing-framework.d.ts | 6 ++ ios/CodePush/CodePushPackage.m | 98 ++++++++----------- test/test.ts | 66 +++++++++++-- 5 files changed, 150 insertions(+), 91 deletions(-) diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index 0bbe38cb..f5e859a0 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -270,30 +270,26 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN String signaturePath = CodePushUpdateUtils.getSignatureFilePath(newUpdateFolderPath); boolean isSignatureAppearedInBundle = FileUtils.fileAtPathExists(signaturePath); + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + throw new CodePushInvalidUpdateException( + "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + + "Possible reasons, why that might happen: \n" + + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + + "2. You've been released CodePush bundle update without providing --privateKeyPath option." + ); + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CodePushUtils.log( + "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + + "Please ensure that public key is properly configured within your application." + ); + } + + CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); - } else { - throw new CodePushInvalidUpdateException( - "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + - "Possible reasons, why that might happen: \n" + - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + - "2. You've been released CodePush bundle update without providing --privateKeyPath option." - ); - } - } else { - if (isSignatureAppearedInBundle) { - CodePushUtils.log( - "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + - "Please ensure that public key is properly configured within your application." - ); - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } else { - if (isDiffUpdate) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } - } + CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); } CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath); diff --git a/code-push-plugin-testing-framework/script/serverUtil.js b/code-push-plugin-testing-framework/script/serverUtil.js index 30ff5ef3..bc3fafec 100644 --- a/code-push-plugin-testing-framework/script/serverUtil.js +++ b/code-push-plugin-testing-framework/script/serverUtil.js @@ -22,6 +22,7 @@ function setupServer(targetPlatform) { }); app.get("/v0.1/public/codepush/update_check", function (req, res) { exports.updateCheckCallback && exports.updateCheckCallback(req); + applyKnownPackageHash(); res.send(exports.updateResponse); console.log("Update check called from the app."); console.log("Request: " + JSON.stringify(req.query)); @@ -53,6 +54,34 @@ function setupServer(targetPlatform) { exports.server = app.listen(+targetPlatform.getServerUrl().match(serverPortRegEx)[1]); } exports.setupServer = setupServer; +/** + * The real content hash of the update archive most recently built during this test scenario. + */ +var knownPackageHash; +var _updatePackagePath; +Object.defineProperty(exports, "updatePackagePath", { + enumerable: true, + configurable: true, + get: function () { return _updatePackagePath; }, + set: function (value) { + _updatePackagePath = value; + applyKnownPackageHash(); + } +}); +/** + * Records the real content hash for the update archive that will be served next, so that + * any update_check response gets the matching package_hash instead of the one filled in + * by default. + */ +function setKnownPackageHash(packageHash) { + knownPackageHash = packageHash; +} +exports.setKnownPackageHash = setKnownPackageHash; +function applyKnownPackageHash() { + if (knownPackageHash && exports.updateResponse && exports.updateResponse.update_info) { + exports.updateResponse.update_info.package_hash = knownPackageHash; + } +} /** * Closes the server. */ diff --git a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts index 0692e505..7502411f 100644 --- a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts +++ b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts @@ -290,6 +290,12 @@ declare module 'code-push-plugin-testing-framework/script/serverUtil' { * Closes the server. */ export function cleanupServer(): void; + /** + * Records the real content hash for the update archive that will be served next, so the + * next update_check response gets a matching package_hash. Pass a falsy packageHash to + * clear it. + */ + export function setKnownPackageHash(packageHash: string): void; /** * Class used to mock the codePush.checkForUpdate() response from the server. */ diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index 992b651f..6f1ca287 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -243,69 +243,51 @@ + (void)downloadPackage:(NSDictionary *)updatePackage NSString *signatureFilePath = [CodePushUpdateUtils getSignatureFilePath:newUpdateFolderPath]; BOOL isSignatureAppearedInBundle = [[NSFileManager defaultManager] fileExistsAtPath:signatureFilePath]; + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + error = [CodePushErrorUtils errorWithMessage: + @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ + "Possible reasons, why that might happen: \n" \ + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ + "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; + failCallback(error); + return; + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ + " because there is no public key configured. " \ + "Please ensure that public key is properly configured within your application."); + } + + BOOL isHashValid = [CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath + expectedHash:newUpdateHash + error:&error]; + if (!isHashValid) { + CPLog(@"The update contents failed the data integrity check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; + } + + failCallback(error); + return; + } else { + CPLog(@"The update contents succeeded the data integrity check."); + } + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); + BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath + expectedHash:newUpdateHash + withPublicKey:publicKey + error:&error]; + if (!isSignatureValid) { + CPLog(@"The update contents failed code signing check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; } - BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath - expectedHash:newUpdateHash - withPublicKey:publicKey - error:&error]; - if (!isSignatureValid) { - CPLog(@"The update contents failed code signing check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; - } - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the code signing check."); - } - } else { - error = [CodePushErrorUtils errorWithMessage: - @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ - "Possible reasons, why that might happen: \n" \ - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ - "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; failCallback(error); return; - } - - } else { - BOOL needToVerifyHash; - if (isSignatureAppearedInBundle) { - CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ - " because there is no public key configured. " \ - "Please ensure that public key is properly configured within your application."); - needToVerifyHash = true; } else { - needToVerifyHash = isDiffUpdate; - } - if(needToVerifyHash){ - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); - } + CPLog(@"The update contents succeeded the code signing check."); } } } else { diff --git a/test/test.ts b/test/test.ts index c4dd91ec..83cbf274 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,6 +2,7 @@ import assert = require("assert"); import childProcess = require("child_process"); +import crypto = require("crypto"); import fs = require("fs"); import mkdirp = require("mkdirp"); import os = require("os"); @@ -68,6 +69,47 @@ function installExpoBundleTooling(projectPath: string): Q.Promise { ).then(() => { return null; }); } +const CODEPUSH_METADATA_FILE_NAME = ".codepushrelease"; + +function isHashIgnored(relativePath: string): boolean { + return relativePath.startsWith("__MACOSX/") + || relativePath === ".DS_Store" + || relativePath.endsWith("/.DS_Store") + || relativePath === CODEPUSH_METADATA_FILE_NAME + || relativePath.endsWith(`/${CODEPUSH_METADATA_FILE_NAME}`); +} + +/** + * Computes the same content hash that the native SDKs compute over an installed update folder, so the mock server + * can hand back a package_hash that will actually match what the client expects. + */ +function computeUpdateContentsHash(folderPath: string): string { + const manifest: string[] = []; + + const walk = (currentPath: string, relativePrefix: string) => { + for (const entryName of fs.readdirSync(currentPath)) { + const entryPath = path.join(currentPath, entryName); + const relativePath = relativePrefix ? `${relativePrefix}/${entryName}` : entryName; + + if (isHashIgnored(relativePath)) { + continue; + } + + if (fs.statSync(entryPath).isDirectory()) { + walk(entryPath, relativePath); + } else { + const fileHash = crypto.createHash("sha256").update(fs.readFileSync(entryPath)).digest("hex"); + manifest.push(`${relativePath}:${fileHash}`); + } + } + }; + + walk(folderPath, ""); + manifest.sort(); + + return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); +} + ////////////////////////////////////////////////////////////////////////////////////////// // Create the platforms to run the tests on. @@ -498,16 +540,27 @@ class RNProjectManager extends ProjectManager { .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) + .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } else { return deferred.promise .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) + .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } } + // Records the real hash of bundleFolder of an archive, so the mock server can hand back a + // package_hash that matches what the client's verifyFolderHash integrity check will compute. + private updateMockPackageHash(bundleFolder: string, isDiff: boolean, archivePath: string): string { + // TODO(RA-4875): Diff updates clear it instead, since they are poorly implemented in the entire test harness. + // It's going to be a bigger refactor, so for now we just clear the known package hash to avoid using a stale value in diff tests. + ServerUtil.setKnownPackageHash(isDiff ? undefined : computeUpdateContentsHash(bundleFolder)); + return archivePath; + } + /** JSON file containing the platforms the plugin is currently installed for. * Keys must match targetPlatform.getName()! * @@ -1014,16 +1067,9 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); }) .then(() => { - /* restart the app to ensure it was reverted and send it another update */ - ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; - targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); - return ServerUtil.expectTestMessages([ - ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, - ServerUtil.TestMessage.DOWNLOAD_SUCCEEDED, - ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); - }) - .then(() => { - /* restart the app again to ensure it was reverted again and send the same update and expect it to reject it */ + /* restart the app to ensure it was reverted; the native rollback path marks + the failed update's hash as failed immediately, so the same update should + now be rejected outright rather than being re-downloaded and retried */ targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); return ServerUtil.expectTestMessages([ServerUtil.TestMessage.UPDATE_FAILED_PREVIOUSLY]); }) From 70a9ef28f7cf0bce1b9cb5819b589a6348b62942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Wed, 2 Sep 2026 13:58:25 +0200 Subject: [PATCH 3/5] Test coverage for codesigned updates (#51) * Test coverage for codesigned updates * Don't duplicate public key in tests --- expo.js | 6 + test/codesign.ts | 96 ++++++++++++++++ .../fixtures/codesigning/test-private-key.pem | 27 +++++ test/fixtures/codesigning/test-public-key.pem | 9 ++ test/template/app.json | 6 +- test/test.ts | 105 ++++++++---------- 6 files changed, 186 insertions(+), 63 deletions(-) create mode 100644 test/codesign.ts create mode 100644 test/fixtures/codesigning/test-private-key.pem create mode 100644 test/fixtures/codesigning/test-public-key.pem diff --git a/expo.js b/expo.js index d453377f..49b3cf25 100644 --- a/expo.js +++ b/expo.js @@ -89,6 +89,9 @@ const withCodePushInfoPlist = (config, options = {}) => { if (options.ios && options.ios.CodePushServerURL) { config.modResults.CodePushServerURL = options.ios.CodePushServerURL; } + if (options.ios && options.ios.CodePushPublicKey) { + config.modResults.CodePushPublicKey = options.ios.CodePushPublicKey; + } return config; }); }; @@ -324,6 +327,9 @@ const withAndroidStrings = (config, options) => { if (options.android?.CodePushServerURL) { setString('CodePushServerUrl', options.android.CodePushServerURL); } + if (options.android?.CodePushPublicKey) { + setString('CodePushPublicKey', options.android.CodePushPublicKey); + } return config; }); }; diff --git a/test/codesign.ts b/test/codesign.ts new file mode 100644 index 00000000..8f222e17 --- /dev/null +++ b/test/codesign.ts @@ -0,0 +1,96 @@ +"use strict"; + +import crypto = require("crypto"); +import fs = require("fs"); +import mkdirp = require("mkdirp"); +import path = require("path"); + +import { Platform, ProjectManager, ServerUtil, setupUpdateScenario, TestConfig, TestUtil } from "code-push-plugin-testing-framework"; + +const CODEPUSH_METADATA_FILE_NAME = ".codepushrelease"; + +function isHashIgnored(relativePath: string): boolean { + return relativePath.startsWith("__MACOSX/") + || relativePath === ".DS_Store" + || relativePath.endsWith("/.DS_Store") + || relativePath === CODEPUSH_METADATA_FILE_NAME + || relativePath.endsWith(`/${CODEPUSH_METADATA_FILE_NAME}`); +} + +/** + * Computes the same content hash that the native SDKs compute over an installed update folder, so the mock server + * can hand back a package_hash that will actually match what the client expects. + */ +export function computeUpdateContentsHash(folderPath: string): string { + const manifest: string[] = []; + + const walk = (currentPath: string, relativePrefix: string) => { + for (const entryName of fs.readdirSync(currentPath)) { + const entryPath = path.join(currentPath, entryName); + const relativePath = relativePrefix ? `${relativePrefix}/${entryName}` : entryName; + + if (isHashIgnored(relativePath)) { + continue; + } + + if (fs.statSync(entryPath).isDirectory()) { + walk(entryPath, relativePath); + } else { + const fileHash = crypto.createHash("sha256").update(fs.readFileSync(entryPath)).digest("hex"); + manifest.push(`${relativePath}:${fileHash}`); + } + } + }; + + walk(folderPath, ""); + manifest.sort(); + + return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); +} + +const codeSigningPrivateKey = fs.readFileSync(path.join(__dirname, "../test/fixtures/codesigning/test-private-key.pem"), "utf8"); +export const codeSigningPublicKey = fs.readFileSync(path.join(__dirname, "../test/fixtures/codesigning/test-public-key.pem"), "utf8").trim(); + +function base64UrlEncode(input: Buffer): string { + return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Builds the RS256-signed ".codepushrelease" JWT that the native SDKs look for inside an update + * archive's "CodePush/" folder. + */ +function signUpdateContentsHash(contentHash: string): string { + const header = base64UrlEncode(Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" }))); + const payload = base64UrlEncode(Buffer.from(JSON.stringify({ contentHash }))); + const signature = base64UrlEncode(crypto.sign("RSA-SHA256", Buffer.from(`${header}.${payload}`), codeSigningPrivateKey)); + return `${header}.${payload}.${signature}`; +} + +/** + * Code-signs the update contents with the test key pair and records the real hash, so the mock + * server hands back a package_hash that matches what the client's data-integrity check computes. + */ +export function signAndRecordUpdateArchive(bundleFolder: string, isDiff: boolean): void { + // TODO(RA-4875): Diff updates clear it instead, since they are poorly implemented in the entire test harness. + // It's going to be a bigger refactor, so for now we just skip signing/hashing to avoid using a stale value in diff tests. + if (isDiff) { + ServerUtil.setKnownPackageHash(undefined); + return; + } + + const contentHash = computeUpdateContentsHash(bundleFolder); + const signatureFolder = path.join(bundleFolder, "CodePush"); + mkdirp.sync(signatureFolder); + fs.writeFileSync(path.join(signatureFolder, CODEPUSH_METADATA_FILE_NAME), signUpdateContentsHash(contentHash)); + ServerUtil.setKnownPackageHash(contentHash); +} + +export async function setupTamperedSignatureUpdateScenario(projectManager: ProjectManager, targetPlatform: Platform.IPlatform, scenarioJsPath: string, version: string): Promise { + const updatePath = await setupUpdateScenario(projectManager, targetPlatform, scenarioJsPath, version); + + const bundleFolder = path.join(TestConfig.updatesDirectory, TestConfig.TestAppName, "CodePush/"); + const tamperedHash = "0".repeat(64); + fs.writeFileSync(path.join(bundleFolder, "CodePush", CODEPUSH_METADATA_FILE_NAME), signUpdateContentsHash(tamperedHash)); + + return await TestUtil.archiveFolder(bundleFolder, "", updatePath, false); +} diff --git a/test/fixtures/codesigning/test-private-key.pem b/test/fixtures/codesigning/test-private-key.pem new file mode 100644 index 00000000..57c07622 --- /dev/null +++ b/test/fixtures/codesigning/test-private-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEApZtuvtGcQmmeUh81n/jAjeDkktkX1QryINqRZrcoofjw+w89 +FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGivmW1O0HBuCsAeiVHyiTq/t+sEiKP +0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3KEZy+Zhx54gaM5iz7ErqavpDADaW +DWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHTi6Whq/YUPWs2+7KXyLnvKyO+33YF +WnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0OcEagfdlM8YwfyOUSWn7LB0VCJaz +ltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj7wIDAQABAoIBAAIEOH7+UmbEnnbl +hmOiRcX0fRQErLOdZIFd5/NWO5ptS5HjB51ics8nkV22yCkaVbwgHBQFyBQQoVAb +rOPeJrsmxeQo0tEJRQI3vf4KIQplctTtss6bvJNrwVkzmDWU5eWuTzzM4TGJpo0T +nlta8L9+zBuZ7ZkiIR+LtnUkHGKdEHKFD4melmeZrvMCDNTGrpa2Y2bP9I11a6xZ +V1XJIxvAQRftorz03vsQXFiIcscEaho65DiAObSqpn1tfFnTbPOl+z4XjKncAVF8 +5vmkVHvERnvUuuBmSTEgGrEcCgwmYKxtOGqWw3MpeP9DWroU5T1N/Mx5xTr3GID5 +6T3+bMECgYEA6Kyg43UCouS7LD/lB5z0dflK8Wu0hzdYUR58r4e2S/WhzkiyX1rZ +aJNkrxyC8/39lGrYdfJHc+RNbViC1TMfH7Zao1lNFVP7vF70v2HzishCL95wSXNz +KZnSY3Yac1ONVP8ZbxX20QBXh9aoTsQxtOs15+XBvOXA5kJwtaaPgTECgYEAtjWW +UiLznuODzIyOQugysXN9bU0UTqUxr3QDt0yqCjA0URVlf3Ehi7OVUPerVSwgCD5m +4RXltW8pqMCLY/qPLkVFH+uZJ/Oo495TEMbyk2/4GXEX4klBMW40YI5srFtP95k+ +HrW5+gFOoVLcpwxRHcW6JateA7RlZDReruAt7x8CgYEAgcQdqx4MTVsyRNiR3LAd +61oRARpnwe4NFJjjQ2Z2NmEVUB5dVS8vB9MEmWFWa8whTFBWz1lDnpAa2rw9o7hy +SFaEsIvSoO2I/aMb700q7iEIQPhXOa/o76+5lf09fUqBDYGE5t6iHCiLqNgAYIWt +j1CLbP1IExk0f3dYswblDFECgYEAkib5pHiUoWYtWe2ETvahcuUIPpwNJegrqmiM +coL0AagYztEy0L6WAdDSfFes/myeZP5o1zMRRi8cY1fOdyuLnbnCcJAyEXHIjr7O +Mi7idJDjmMS2O7Q2rsePC8QyNy4nPpuU0F1EB9z0jUJB61xd1Fu9rGmAx8fzbCT1 +rZ/0OFECgYEAwQHaF2oxQMxd/WzuTHtKYuPAJDvONQj+hcEAEVFUGEVnauRoUuta +rhgnWrD2rte/6JqGPnfJouW+w1Y+2Mnfp4io8/cyysiEX7VWTeZL+nUERczTserl +cyujq12LNdD+YPQwJTxHguP0rbMsQ2lHlxwys1+74GGq7GqR3dIJlqc= +-----END RSA PRIVATE KEY----- diff --git a/test/fixtures/codesigning/test-public-key.pem b/test/fixtures/codesigning/test-public-key.pem new file mode 100644 index 00000000..9137b5e4 --- /dev/null +++ b/test/fixtures/codesigning/test-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZtuvtGcQmmeUh81n/jA +jeDkktkX1QryINqRZrcoofjw+w89FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGi +vmW1O0HBuCsAeiVHyiTq/t+sEiKP0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3 +KEZy+Zhx54gaM5iz7ErqavpDADaWDWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHT +i6Whq/YUPWs2+7KXyLnvKyO+33YFWnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0 +OcEagfdlM8YwfyOUSWn7LB0VCJazltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj +7wIDAQAB +-----END PUBLIC KEY----- diff --git a/test/template/app.json b/test/template/app.json index faf5779d..a4b29d25 100644 --- a/test/template/app.json +++ b/test/template/app.json @@ -19,11 +19,13 @@ { "ios": { "CodePushDeploymentKey": "mock-ios-deployment-key", - "CodePushServerURL": "http://127.0.0.1:3000" + "CodePushServerURL": "http://127.0.0.1:3000", + "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}" }, "android": { "CodePushDeploymentKey": "mock-android-deployment-key", - "CodePushServerURL": "http://10.0.2.2:3001" + "CodePushServerURL": "http://10.0.2.2:3001", + "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}" } } ] diff --git a/test/test.ts b/test/test.ts index 83cbf274..579e4c60 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,12 +2,12 @@ import assert = require("assert"); import childProcess = require("child_process"); -import crypto = require("crypto"); import fs = require("fs"); import mkdirp = require("mkdirp"); import os = require("os"); import path = require("path"); import slash = require("slash"); +import { promisify } from "util"; import { Platform, PluginTestingFramework, ProjectManager, setupTestRunScenario, setupUpdateScenario, ServerUtil, TestBuilder, TestConfig, TestUtil } from "code-push-plugin-testing-framework"; @@ -15,6 +15,11 @@ import Q = require("q"); import del = require("del"); +import { codeSigningPublicKey, signAndRecordUpdateArchive, setupTamperedSignatureUpdateScenario } from "./codesign"; + +// Used in test/template/app.json to avoid duplicating the PEM fixture in two places (ios and android plugin config). +const CODE_SIGNING_PUBLIC_KEY_PLACEHOLDER = "{{CODE_SIGNING_PUBLIC_KEY}}"; + function ensureAndroidCleartextTraffic(androidManifestPath: string): void { const androidManifestContents = fs.readFileSync(androidManifestPath, "utf8"); @@ -37,6 +42,10 @@ function ensureAndroidCleartextTraffic(androidManifestPath: string): void { } } +async function setPlistStringValue(plistPath: string, key: string, value: string): Promise { + await promisify(childProcess.execFile)("plutil", ["-replace", key, "-string", value, plistPath]); +} + /** * Returns a " --platform " flag for `expo prebuild` when exactly one platform is * under test in this mocha run, so prebuild only regenerates that platform's native project @@ -69,47 +78,6 @@ function installExpoBundleTooling(projectPath: string): Q.Promise { ).then(() => { return null; }); } -const CODEPUSH_METADATA_FILE_NAME = ".codepushrelease"; - -function isHashIgnored(relativePath: string): boolean { - return relativePath.startsWith("__MACOSX/") - || relativePath === ".DS_Store" - || relativePath.endsWith("/.DS_Store") - || relativePath === CODEPUSH_METADATA_FILE_NAME - || relativePath.endsWith(`/${CODEPUSH_METADATA_FILE_NAME}`); -} - -/** - * Computes the same content hash that the native SDKs compute over an installed update folder, so the mock server - * can hand back a package_hash that will actually match what the client expects. - */ -function computeUpdateContentsHash(folderPath: string): string { - const manifest: string[] = []; - - const walk = (currentPath: string, relativePrefix: string) => { - for (const entryName of fs.readdirSync(currentPath)) { - const entryPath = path.join(currentPath, entryName); - const relativePath = relativePrefix ? `${relativePrefix}/${entryName}` : entryName; - - if (isHashIgnored(relativePath)) { - continue; - } - - if (fs.statSync(entryPath).isDirectory()) { - walk(entryPath, relativePath); - } else { - const fileHash = crypto.createHash("sha256").update(fs.readFileSync(entryPath)).digest("hex"); - manifest.push(`${relativePath}:${fileHash}`); - } - } - }; - - walk(folderPath, ""); - manifest.sort(); - - return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); -} - ////////////////////////////////////////////////////////////////////////////////////////// // Create the platforms to run the tests on. @@ -209,6 +177,7 @@ class RNAndroid extends Platform.Android implements RNPlatform { const string = path.join(innerprojectDirectory, "android", "app", "src", "main", "res", "values", "strings.xml"); TestUtil.replaceString(string, TestUtil.SERVER_URL_PLACEHOLDER, this.getServerUrl()); TestUtil.replaceString(string, TestUtil.ANDROID_KEY_PLACEHOLDER, this.getDefaultDeploymentKey()); + TestUtil.replaceString(string, "", `${codeSigningPublicKey}\n`); TestUtil.replaceString(AndroidManifest, "\\${usesCleartextTraffic}", "true"); @@ -280,14 +249,10 @@ class RNIOS extends Platform.IOS implements RNPlatform { // Install the Podfile return TestUtil.copyFile(path.join(TestConfig.templatePath, "ios", "Podfile"), podfilePath, true) .then(() => TestUtil.getProcessOutput(`pod install`, { cwd: iOSProject, noLogStdOut: true })) - // Put the IOS deployment key in the Info.plist - .then(TestUtil.replaceString.bind(undefined, infoPlistPath, - "\n", - "CodePushDeploymentKey\n\t" + this.getDefaultDeploymentKey() + "\n\tCodePushServerURL\n\t" + this.getServerUrl() + "\n\t\n")) - // Set the app version to 1.0.0 instead of 1.0 in the Info.plist - .then(TestUtil.replaceString.bind(undefined, infoPlistPath, "1.0", "1.0.0")) - // Remove dependence of CFBundleShortVersionString from project.pbxproj - .then(TestUtil.replaceString.bind(undefined, infoPlistPath, "\\$\\(MARKETING_VERSION\\)", "1.0.0")) + .then(() => setPlistStringValue(infoPlistPath, "CFBundleShortVersionString", "1.0.0")) + .then(() => setPlistStringValue(infoPlistPath, "CodePushDeploymentKey", this.getDefaultDeploymentKey())) + .then(() => setPlistStringValue(infoPlistPath, "CodePushServerURL", this.getServerUrl())) + .then(() => setPlistStringValue(infoPlistPath, "CodePushPublicKey", codeSigningPublicKey)) // Fix the linker flag list in project.pbxproj (pod install adds an extra comma) .then(TestUtil.replaceString.bind(undefined, path.join(iOSProject, TestConfig.TestAppName + ".xcodeproj", "project.pbxproj"), "\"[$][(]inherited[)]\",\\s*[)];", "\"$(inherited)\"\n\t\t\t\t);")) @@ -440,6 +405,12 @@ class RNProjectManager extends ProjectManager { return TestUtil.getProcessOutput(`npx create-expo-app@latest ${appName} --template blank@sdk-57`, { cwd: projectDirectory, timeout: 30 * 60 * 1000, noLogStdOut: true }) .then((e) => { console.log(`"npx expo init ${appName}" success. cwd=${projectDirectory}`); return e; }) .then(this.copyTemplate.bind(this, templatePath, projectDirectory)) + .then(() => { + const appJsonPath = path.join(projectDirectory, TestConfig.TestAppName, "app.json"); + // app.json is JSON, so the PEM's line breaks must stay escaped rather than literal. + const escapedPublicKey = codeSigningPublicKey.replace(/\n/g, "\\n"); + TestUtil.replaceString(appJsonPath, CODE_SIGNING_PUBLIC_KEY_PLACEHOLDER, escapedPublicKey); + }) .then(TestUtil.getProcessOutput.bind(undefined, TestConfig.thisPluginInstallString, { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true, noLogStdErr: true })) .then(installExpoBundleTooling.bind(undefined, path.join(projectDirectory, TestConfig.TestAppName))) // create-expo-app's blank template ships without a metro.config.js. react-native-xcode.sh's @@ -539,28 +510,19 @@ class RNProjectManager extends ProjectManager { .then(TestUtil.getProcessOutput.bind(undefined, "npx expo prebuild --platform " + targetPlatform.getName(), { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) + .then(() => signAndRecordUpdateArchive(bundleFolder, isDiff)) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) - .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } else { return deferred.promise .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) + .then(() => signAndRecordUpdateArchive(bundleFolder, isDiff)) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) - .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } } - // Records the real hash of bundleFolder of an archive, so the mock server can hand back a - // package_hash that matches what the client's verifyFolderHash integrity check will compute. - private updateMockPackageHash(bundleFolder: string, isDiff: boolean, archivePath: string): string { - // TODO(RA-4875): Diff updates clear it instead, since they are poorly implemented in the entire test harness. - // It's going to be a bigger refactor, so for now we just clear the known package hash to avoid using a stale value in diff tests. - ServerUtil.setKnownPackageHash(isDiff ? undefined : computeUpdateContentsHash(bundleFolder)); - return archivePath; - } - /** JSON file containing the platforms the plugin is currently installed for. * Keys must match targetPlatform.getName()! * @@ -1050,6 +1012,27 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl }); }, ScenarioInstall); + TestBuilder.describe("#localPackage.install.codeSigning", + () => { + TestBuilder.it("localPackage.install.codeSigning.tamperedSignature", false, + async (done: Mocha.Done) => { + try { + ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; + + /* create a normal update, then tamper with its signature after it's been signed */ + const updatePath = await setupTamperedSignatureUpdateScenario(projectManager, targetPlatform, UpdateNotifyApplicationReady, "Tampered Update"); + ServerUtil.updatePackagePath = updatePath; + projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); + await ServerUtil.expectTestMessages([ + ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, + ServerUtil.TestMessage.DOWNLOAD_ERROR]); + done(); + } catch (e) { + done(e); + } + }); + }, ScenarioInstall); + TestBuilder.describe("#localPackage.install.revert", () => { TestBuilder.it("localPackage.install.revert.dorevert", false, From b1444381f2d7eb729ea09b763a41f1eab27093dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Thu, 3 Sep 2026 10:08:29 +0200 Subject: [PATCH 4/5] Android: more meaningful errors for non-200 HTTP responses (#52) --- .../codepush/react/CodePushUpdateManager.java | 14 ++++++++++++++ .../com/microsoft/codepush/react/NetworkUtils.kt | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 android/app/src/main/java/com/microsoft/codepush/react/NetworkUtils.kt diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index f5e859a0..0bd72a72 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -178,6 +178,13 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN } connection.setRequestProperty("Accept-Encoding", "identity"); + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + throw new CodePushUnknownException("Error downloading update package. Response code: " + + responseCode + ". Response body: " + NetworkUtils.readStreamToString(connection.getErrorStream())); + } + bin = new BufferedInputStream(connection.getInputStream()); long totalBytes = connection.getContentLength(); @@ -349,6 +356,13 @@ public void downloadAndReplaceCurrentBundle(String remoteBundleUrl, String bundl try { downloadUrl = new URL(remoteBundleUrl); connection = (HttpURLConnection) (downloadUrl.openConnection()); + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + throw new CodePushUnknownException("Error downloading update package. Response code: " + + responseCode + ". Response body: " + NetworkUtils.readStreamToString(connection.getErrorStream())); + } + bin = new BufferedInputStream(connection.getInputStream()); File downloadFile = new File(getCurrentPackageBundlePath(bundleFileName)); downloadFile.delete(); diff --git a/android/app/src/main/java/com/microsoft/codepush/react/NetworkUtils.kt b/android/app/src/main/java/com/microsoft/codepush/react/NetworkUtils.kt new file mode 100644 index 00000000..2c360b39 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/NetworkUtils.kt @@ -0,0 +1,9 @@ +@file:JvmName("NetworkUtils") + +package com.microsoft.codepush.react + +import java.io.InputStream + +fun readStreamToString(inputStream: InputStream?): String { + return inputStream?.bufferedReader()?.use { it.readText() } ?: "" +} From 1794c83e1d39b5d91b1205e2acc43e83b41a5fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Fri, 28 Aug 2026 16:52:59 +0200 Subject: [PATCH 5/5] Android: unit tests for package install codepaths --- android/app/build.gradle | 2 + .../codepush/react/CodePushUpdateManager.java | 9 + .../react/CodePushUpdateManagerTest.kt | 251 ++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index a1879ecc..47c673e4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,6 +96,8 @@ dependencies { implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20231013' + testImplementation 'org.mockito:mockito-core:5.14.2' androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index 0bd72a72..e1c989c3 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -234,6 +234,15 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN } } + installDownloadedUpdate(updatePackage, expectedBundleFileName, stringPublicKey, + downloadFile, isZip, newUpdateFolderPath, newUpdateMetadataPath); + } + + void installDownloadedUpdate(JSONObject updatePackage, String expectedBundleFileName, + String stringPublicKey, File downloadFile, boolean isZip, + String newUpdateFolderPath, String newUpdateMetadataPath) throws IOException { + String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null); + if (isZip) { // Unzip the downloaded file and then delete the zip String unzippedFolderPath = getUnzippedFolderPath(); diff --git a/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt new file mode 100644 index 00000000..3bcec452 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt @@ -0,0 +1,251 @@ +package com.microsoft.codepush.react + +import android.util.Log +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.MockedStatic +import org.mockito.Mockito +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class CodePushUpdateManagerTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var logMock: MockedStatic + + @Before + fun mockAndroidLog() { + // CodePushUtils.log() is used deep inside the SDK classes, which isn't stubbed for plain JVM unit tests. + // We'd rather hack around this (as long there is nothing else to mock) than moving these tests to instrumented Android tests. + logMock = Mockito.mockStatic(Log::class.java) + } + + @After + fun unmockAndroidLog() { + logMock.close() + } + + private fun manager() = CodePushUpdateManager(tempFolder.newFolder("documents").absolutePath) + + private fun updatePackage(hash: String) = JSONObject().apply { + put(CodePushConstants.PACKAGE_HASH_KEY, hash) + } + + private fun zipOf(vararg entries: Pair): File { + val zipFile = tempFolder.newFile("download.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + for ((path, content) in entries) { + zip.putNextEntry(ZipEntry(path)) + zip.write(content.toByteArray()) + zip.closeEntry() + } + } + return zipFile + } + + private fun rawBundleFile(content: String): File { + val file = tempFolder.newFile("download.bundle") + file.writeText(content) + return file + } + + // Registers `hash` as the currently installed package, with the given file contents, so that + // getCurrentPackageFolderPath() resolves to it. Needed to set up diff-update scenarios. + private fun installCurrentPackage(update: CodePushUpdateManager, hash: String, files: Map): String { + val folderPath = update.getPackageFolderPath(hash) + File(folderPath).mkdirs() + for ((relativePath, content) in files) { + val file = File(folderPath, relativePath) + file.parentFile?.mkdirs() + file.writeText(content) + } + update.updateCurrentPackageInfo(JSONObject().apply { put(CodePushConstants.CURRENT_PACKAGE_KEY, hash) }) + return folderPath + } + + @Test + fun installDownloadedUpdate_rawBundle_movesFileIntoPlaceAndWritesMetadataWithoutBundlePath() { + // Given + val update = manager() + val pkg = updatePackage("hash1") + val downloadFile = rawBundleFile("raw jsbundle contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash1") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, false, newUpdateFolderPath, newUpdateMetadataPath) + + // Then + val installedBundle = File(newUpdateFolderPath, "index.android.bundle") + assertTrue(installedBundle.exists()) + assertEquals("raw jsbundle contents", installedBundle.readText()) + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) + assertEquals("hash1", metadata.getString(CodePushConstants.PACKAGE_HASH_KEY)) + assertFalse("raw bundle updates never set a bundlePath", metadata.has(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY)) + } + + @Test + fun installDownloadedUpdate_zipFullUpdate_findsBundleInNestedFolderAndRecordsItsRelativePath() { + // Given + val update = manager() + val entries = arrayOf( + "sub/index.android.bundle" to "new bundle contents", + "sub/asset.png" to "fake asset bytes", + ) + val downloadFile = zipOf(*entries) + val pkg = updatePackage("ff53f424bd583841638ff4e65f32dd71944ba72022d27ad6b8d8db8401b5bbf2") + val newUpdateFolderPath = update.getPackageFolderPath("hash2") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + + // Then + assertEquals("new bundle contents", File(newUpdateFolderPath, "sub/index.android.bundle").readText()) + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) + assertEquals( + CodePushUtils.appendPathComponent("sub", "index.android.bundle"), + metadata.getString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY), + ) + } + + @Test + fun installDownloadedUpdate_zipMissingExpectedBundle_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("other.txt" to "not a bundle") + val pkg = updatePackage("hash3") + val newUpdateFolderPath = update.getPackageFolderPath("hash3") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("A JS bundle file named \"index.android.bundle\" could not be found")) + } + } + + @Test + fun installDownloadedUpdate_zipFullUpdateWithNoPublicKeyAndNoSignatureAndWrongHash_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash4") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_publicKeyConfiguredButNoSignatureInBundle_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") + val pkg = updatePackage("hash5") + val newUpdateFolderPath = update.getPackageFolderPath("hash5") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("Error! Public key was provided but there is no JWT signature within app bundle to verify.")) + } + } + + @Test + fun installDownloadedUpdate_publicKeyConfiguredAndSignaturePresentButHashMismatch_throwsBeforeSignatureCheck() { + // Given + val update = manager() + val downloadFile = zipOf( + "index.android.bundle" to "new bundle contents", + "CodePush/.codepushrelease" to "not-a-real-jwt", + ) + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash6") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_noPublicKeyButSignaturePresentInBundle_stillVerifiesFolderHash() { + // Given + val update = manager() + val downloadFile = zipOf( + "index.android.bundle" to "new bundle contents", + "CodePush/.codepushrelease" to "not-a-real-jwt", + ) + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash7") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_versionOneDiffUpdate_carriesOverKeptFilesDeletesRemovedOnesAndAppliesNewOnes() { + // Given + val update = manager() + installCurrentPackage(update, "current-hash", mapOf( + "kept.txt" to "kept contents", + "old_extra.txt" to "stale contents", + )) + val downloadFile = zipOf( + CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":1,"deletedFiles":["old_extra.txt"],"patchedFiles":{}}""", + "index.android.bundle" to "new bundle contents", + ) + // Deliberately wrong, so the folder-hash check at the end of the diff-update path throws - + // but only after the merge below has already run, so we can still assert on its result. + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("new-hash") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException from the folder hash check") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + + // Then (the merge above already ran, so its filesystem side effects are still checkable) + assertEquals("kept contents", File(newUpdateFolderPath, "kept.txt").readText()) + assertFalse("deletedFiles entry should have been removed", File(newUpdateFolderPath, "old_extra.txt").exists()) + assertEquals("new bundle contents", File(newUpdateFolderPath, "index.android.bundle").readText()) + assertFalse("the manifest itself should not be carried into the installed package", File(newUpdateFolderPath, CodePushConstants.DIFF_MANIFEST_FILE_NAME).exists()) + } +}