diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonData.kt index 3eb685d2e624..1e3b4c8ac2f9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonData.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonData.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 Mani - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Mani package com.ichi2.anki.jsaddons @@ -40,6 +27,11 @@ import kotlin.jvm.Throws * @param name name of npm package, it unique for each package listed on npm * @param addonTitle for showing in AnkiDroid * @param icon only required for note editor (single character recommended) + * @param description commonly absent from real package.json files; absence never invalidates the addon + * @param author commonly absent from real package.json files; absence never invalidates the addon + * @param license commonly absent from real package.json files; absence never invalidates the addon + * @param dist only exists in the npm registry API response, not in the package.json inside a tarball; + * absence never invalidates the addon */ @Serializable @@ -64,87 +56,103 @@ data class DistInfo( val tarball: String, ) +/** Parsing contract for package.json: unknown fields must never fail parsing */ +private val json = Json { ignoreUnknownKeys = true } + +/** + * Result of validating a manifest ([AddonData]) as an AnkiDroid addon + */ +sealed interface AddonValidationResult { + /** The manifest maps to a usable [AddonModel] */ + data class Valid( + val addonModel: AddonModel, + ) : AddonValidationResult + + /** The manifest is not a valid AnkiDroid addon, for the reasons in [errors] */ + data class Invalid( + val errors: List, + ) : AddonValidationResult +} + /** * Check if npm package is valid or not by fields ankidroidJsApi, keywords (ankidroid-js-addon) and * addon_type (reviewer or note editor) in addonData * - * For valid addon the error list will be empty, - * for not valid addon the error list will contain the error related to the checks + * Unreadable and unparseable files are reported as [AddonValidationResult.Invalid]: a caller + * listing an addons directory must not be aborted by one corrupt addon * - * @param packageJsonPath package.json file path - * @return Pair with addonModel and error list + * @param packageJson package.json file + * @return [AddonValidationResult.Valid] with the mapped model, + * or [AddonValidationResult.Invalid] with the errors related to the failed checks */ -@Throws(IOException::class) -fun getAddonModelFromJson(packageJsonPath: String): Pair> { - val data = File(packageJsonPath).readBytes().decodeToString() - return try { - val json = Json { ignoreUnknownKeys = true } +fun getAddonModelFromJson(packageJson: File): AddonValidationResult = + try { + val data = packageJson.readBytes().decodeToString() getAddonModelFromAddonData(json.decodeFromString(data)) } catch (exc: SerializationException) { - return Pair(null, listOf("Unable to parse manifest: $exc")) + AddonValidationResult.Invalid(listOf("Unable to parse manifest: $exc")) + } catch (exc: IOException) { + AddonValidationResult.Invalid(listOf("Unable to read manifest: $exc")) } -} /** * Get addonModel from addonData * * @param addonData - * @return pair of valid addon model and errors list + * @return [AddonValidationResult.Valid] with the mapped model, + * or [AddonValidationResult.Invalid] with the errors related to the failed checks */ -fun getAddonModelFromAddonData(addonData: AddonData): Pair> { - var errorStr: String - val errorList: MutableList = ArrayList() - +fun getAddonModelFromAddonData(addonData: AddonData): AddonValidationResult { // either fields not present in package.json or failed to parse the fields if (addonData.name.isNullOrBlank() || addonData.addonTitle.isNullOrBlank() || addonData.main.isNullOrBlank() || + addonData.version.isNullOrBlank() || addonData.ankidroidJsApi.isNullOrBlank() || addonData.addonType.isNullOrBlank() || addonData.homepage.isNullOrBlank() || addonData.keywords.isNullOrEmpty() ) { - errorStr = "Invalid addon package: fields in package.json are empty or null" - errorList.add(errorStr) + // the checks below dereference these fields, so they cannot run safely + return AddonValidationResult.Invalid(listOf("Invalid addon package: fields in package.json are empty or null")) } + val errorList: MutableList = ArrayList() + // check if name is safe and valid - if (!validateName(addonData.name!!)) { - errorStr = "Invalid addon package: package name failed validation" - errorList.add(errorStr) + if (!validateName(addonData.name)) { + errorList.add("Invalid addon package: package name failed validation") } if (addonData.addonType != REVIEWER_ADDON && addonData.addonType != NOTE_EDITOR_ADDON) { - errorStr = "Invalid addon package: ${addonData.addonType} is not valid addon type, " + - "package.json must have 'addonType' fields of 'reviewer' or 'note-editor'" - errorList.add(errorStr) + errorList.add( + "Invalid addon package: ${addonData.addonType} is not valid addon type, " + + "package.json must have 'addonType' fields of 'reviewer' or 'note-editor'", + ) } // if addon type is note editor then it must have icon if (addonData.addonType == NOTE_EDITOR_ADDON && addonData.icon.isNullOrBlank()) { - errorStr = "Invalid addon package: note editor addon must have 'icon' fields in package.json" - errorList.add(errorStr) + errorList.add("Invalid addon package: note editor addon must have 'icon' fields in package.json") } // check if ankidroid-js-addon present or not in mapped addonData - val jsAddonKeywordsPresent = addonData.keywords?.any { it == ANKIDROID_JS_ADDON_KEYWORDS } - if (!jsAddonKeywordsPresent!!) { - errorStr = "Invalid addon package: package.json does not have 'ankidroid-js-addon' in ${addonData.keywords} keywords" - errorList.add(errorStr) + val jsAddonKeywordsPresent = addonData.keywords.any { it == ANKIDROID_JS_ADDON_KEYWORDS } + if (!jsAddonKeywordsPresent) { + errorList.add("Invalid addon package: package.json does not have 'ankidroid-js-addon' in ${addonData.keywords} keywords") } // Check supplied api and current api if (addonData.ankidroidJsApi != CURRENT_JS_API_VERSION) { - errorStr = "Invalid addon package: supplied js api version ${addonData.ankidroidJsApi} must " + - "be equal to current js api version $CURRENT_JS_API_VERSION" - errorList.add(errorStr) + errorList.add( + "Invalid addon package: supplied js api version ${addonData.ankidroidJsApi} must " + + "be equal to current js api version $CURRENT_JS_API_VERSION", + ) } - val immutableList: List = ArrayList(errorList) - - // there are errors in package.json so return null and errors list + // there are errors in package.json so return the errors list if (errorList.isNotEmpty()) { - return Pair(null, immutableList) + return AddonValidationResult.Invalid(errorList) } val icon = if (addonData.addonType == NOTE_EDITOR_ADDON) addonData.icon!! else "" @@ -153,21 +161,21 @@ fun getAddonModelFromAddonData(addonData: AddonData): Pair, List< HttpFetcher.fetchThroughHttp(packageJsonUrl.toString()) } val errorList: MutableList = ArrayList() - val json = Json { ignoreUnknownKeys = true } val addonsData = json.decodeFromString>(urlData) val addonsModelList = mutableListOf() for (addon in addonsData) { - val result = getAddonModelFromAddonData(addon) - - if (result.first == null) { - Timber.i("Not a valid addon for AnkiDroid, the errors for the addon:\n %s", result.second) - errorList.addAll(result.second) - continue + when (val result = getAddonModelFromAddonData(addon)) { + is AddonValidationResult.Valid -> addonsModelList.add(result.addonModel) + is AddonValidationResult.Invalid -> { + Timber.i("Not a valid addon for AnkiDroid, the errors for the addon:\n %s", result.errors) + errorList.addAll(result.errors) + } } - addonsModelList.add(result.first!!) } return Pair(addonsModelList, errorList.toList()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonModel.kt index 0374a8059c3f..9a23dd1dcc17 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonModel.kt @@ -1,24 +1,8 @@ -/* - * Copyright (c) 2021 Mani - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Mani package com.ichi2.anki.jsaddons -import android.content.SharedPreferences -import androidx.core.content.edit - data class AddonModel( val name: String, val addonTitle: String, @@ -32,35 +16,6 @@ data class AddonModel( val author: Map, val license: String, val homepage: String, - val dist: DistInfo, -) { - /** - * Update preferences for addons with boolean remove, the preferences will be used to store the information about - * enabled and disabled addon. So, that other method will return content of script to reviewer or note editor - * - * @param preferences - * @param jsAddonKey REVIEWER_ADDON_KEY - * @param remove true for removing from prefs - * - * Android returns a reference to StringSet in SharedPreferences but does not mark it as modified if any changes made, - * so commit/apply won't persist it. It needs to make a copy and set the new copy in to persist any StringSet changes - * in SharedPreferences. - * https://stackoverflow.com/questions/19949182/android-sharedpreferences-string-set-some-items-are-removed-after-app-restart/19949833 - */ - fun updatePrefs( - preferences: SharedPreferences, - jsAddonKey: String, - remove: Boolean, - ) { - val reviewerEnabledAddonSet = preferences.getStringSet(jsAddonKey, HashSet()) - val newStrSet: MutableSet = reviewerEnabledAddonSet?.toHashSet()!! - - if (remove) { - newStrSet.remove(name) - } else { - newStrSet.add(name) - } - - preferences.edit { putStringSet(jsAddonKey, newStrSet) } - } -} + /** Tarball location from the npm registry API; null for locally installed addons */ + val dist: DistInfo?, +) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonsConst.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonsConst.kt index 3ca7e36bbc78..e504defa434d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonsConst.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/AddonsConst.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 Mani - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Mani package com.ichi2.anki.jsaddons diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/NpmUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/NpmUtils.kt index 29b148bda9dc..b030b74b0e11 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/NpmUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/NpmUtils.kt @@ -1,18 +1,5 @@ -/* - * Copyright (c) 2021 Mani - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2021 Mani package com.ichi2.anki.jsaddons diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/TgzPackageExtract.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/TgzPackageExtract.kt index 9d9a06c6797d..067b0e2e1563 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/TgzPackageExtract.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsaddons/TgzPackageExtract.kt @@ -36,7 +36,6 @@ package com.ichi2.anki.jsaddons import android.content.Context import android.text.format.Formatter import com.ichi2.anki.R -import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.compat.CompatHelper.Companion.compat import com.ichi2.utils.FileUtil import org.apache.commons.compress.archivers.ArchiveException @@ -135,9 +134,8 @@ class TgzPackageExtract( try { compat.createDirectories(addonsPackageDir) } catch (e: IOException) { - showThemedToast(context, context.getString(R.string.could_not_create_dir, addonsPackageDir.absolutePath), false) Timber.w(e) - return + throw IOException(context.getString(R.string.could_not_create_dir, addonsPackageDir.absolutePath)) } // Make sure we have 2x the tar file size in free space (1x for tar file, 1x for unarchived tar file contents @@ -156,9 +154,12 @@ class TgzPackageExtract( try { // If space available then unTar it unTar(tarTempFile, addonsPackageDir) - } catch (e: IOException) { - Timber.w("Failed to unTar file") + } catch (e: Exception) { + // ArchiveException/IllegalStateException from unTar need the same cleanup as + // IOException: without it, a failed extraction leaves a partial addon behind + Timber.w(e, "Failed to unTar file") safeDeleteAddonsPackageDir(addonsPackageDir) + throw e } finally { tarTempFile.delete() } @@ -421,8 +422,12 @@ class TgzPackageExtract( } } + /** + * Recursively delete [addonsPackageDir], but only if it is inside an `addons` directory: + * a defence against deleting an arbitrary directory if a bad path is passed in + */ private fun safeDeleteAddonsPackageDir(addonsPackageDir: AddonsPackageDir) { - if (addonsPackageDir.parent != "addons") { + if (addonsPackageDir.parentFile?.name != "addons") { return } addonsPackageDir.deleteRecursively() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt index 55800e143038..dab90f52381a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt @@ -16,57 +16,46 @@ package com.ichi2.anki.jsaddons -import android.content.SharedPreferences -import android.os.Looper.getMainLooper import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.AnkiDroidJsAPIConstants.CURRENT_JS_API_VERSION import com.ichi2.anki.RobolectricTest -import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.jsaddons.AddonsConst.ANKIDROID_JS_ADDON_KEYWORDS import com.ichi2.anki.jsaddons.AddonsConst.REVIEWER_ADDON import com.ichi2.utils.FileOperation import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertTrue import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.core.StringEndsWith.endsWith import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.Shadows.shadowOf import java.io.File import java.io.IOException -import kotlin.collections.HashSet +import kotlin.test.assertIs @RunWith(AndroidJUnit4::class) class AddonModelTest : RobolectricTest() { - private lateinit var validNpmPackageJson: String - private lateinit var notValidNpmPackageJson: String + private lateinit var validNpmPackageJson: File + private lateinit var notValidNpmPackageJson: File private lateinit var addonsPackageListTestJson: String - private lateinit var prefs: SharedPreferences - - @Before - fun before() { - prefs = targetContext.sharedPrefs() - } @Before override fun setUp() { super.setUp() - validNpmPackageJson = FileOperation.getFileResource("valid-ankidroid-js-addon-test.json") - notValidNpmPackageJson = FileOperation.getFileResource("not-valid-ankidroid-js-addon-test.json") + validNpmPackageJson = File(FileOperation.getFileResource("valid-ankidroid-js-addon-test.json")) + notValidNpmPackageJson = File(FileOperation.getFileResource("not-valid-ankidroid-js-addon-test.json")) addonsPackageListTestJson = FileOperation.getFileResource("test-js-addon.json") } @Test @Throws(IOException::class) fun isValidAnkiDroidAddonTest() { - // test addon is valid or not, for valid addon the result string will be empty - val result: Pair> = getAddonModelFromJson(validNpmPackageJson) - assertTrue(result.second.isEmpty()) - assertTrue("package.json contains required fields", result.first != null) + // test addon is valid or not, for a valid addon the result is Valid + val result = getAddonModelFromJson(validNpmPackageJson) + val addon = assertIs(result, "package.json contains required fields").addonModel // needs to test these fields - val addon = result.first!! assertEquals(addon.name, "valid-ankidroid-js-addon-test") assertEquals(addon.addonTitle, "Valid AnkiDroid JS Addon") assertEquals(addon.version, "1.0.0") @@ -81,40 +70,21 @@ class AddonModelTest : RobolectricTest() { @Test @Throws(IOException::class) fun notValidAnkiDroidAddonTest() { - // test addon is valid or not, for not valid addon the result string will not be empty - val result: Pair> = getAddonModelFromJson(notValidNpmPackageJson) - // assert that addon model is null i.e. the package.json not mapped to addon model - assertTrue("package.json not contains required fields", result.first == null) + // test addon is valid or not, for a not valid addon the result is Invalid + val result = getAddonModelFromJson(notValidNpmPackageJson) + // assert that the package.json was not mapped to an addon model + val errors = assertIs(result, "package.json not contains required fields").errors // assert that error list contains error when the package.json not mapped to AddonModel - assertFalse(result.second.isEmpty()) + assertFalse(errors.isEmpty()) } @Test - fun updatePrefsTest() { - shadowOf(getMainLooper()).idle() - - // test that prefs hashset for reviewer is empty - var reviewerEnabledAddonSet = prefs.getStringSet(REVIEWER_ADDON, HashSet()) - assertEquals(0, reviewerEnabledAddonSet?.size) - - val result: Pair> = getAddonModelFromJson(validNpmPackageJson) - val addonModel = result.first!! - - // update the prefs make it enabled - addonModel.updatePrefs(prefs, REVIEWER_ADDON, false) - - // test that new prefs added and size is 1 and the prefs hashset contains enabled addons name - reviewerEnabledAddonSet = prefs.getStringSet(REVIEWER_ADDON, HashSet()) - assertEquals(1, reviewerEnabledAddonSet?.size) - assertTrue(reviewerEnabledAddonSet!!.contains(addonModel.name)) - - // now remove the addons from prefs - addonModel.updatePrefs(prefs, REVIEWER_ADDON, true) - - // prefs hashset size for reviewer should be zero and prefs will not have addon name - reviewerEnabledAddonSet = prefs.getStringSet(REVIEWER_ADDON, HashSet()) - assertEquals(0, reviewerEnabledAddonSet?.size) - assertFalse(reviewerEnabledAddonSet!!.contains(addonModel.name)) + fun unreadableManifestReturnsErrorTest() { + // a missing/unreadable package.json must be reported as Invalid, not thrown: + // one corrupt addon directory must not abort listing the other addons + val result = getAddonModelFromJson(File("/does/not/exist/package.json")) + val errors = assertIs(result, "model is not built from an unreadable manifest").errors + assertFalse("unreadable manifest is reported as an error", errors.isEmpty()) } @Test @@ -125,11 +95,91 @@ class AddonModelTest : RobolectricTest() { // first addon name and tgz download url val addon1 = result.first[0] assertEquals(addon1.name, "ankidroid-js-addon-progress-bar") - assertThat(addon1.dist.tarball, endsWith(".tgz")) + assertThat(addon1.dist!!.tarball, endsWith(".tgz")) // second addon name and tgz download url val addon2 = result.first[1] assertEquals(addon2.name, "valid-ankidroid-js-addon-test") - assertThat(addon2.dist.tarball, endsWith(".tgz")) + assertThat(addon2.dist!!.tarball, endsWith(".tgz")) + } + + /** + * A valid manifest, with individual fields overridable so each test can knock one out + */ + private fun addonData( + name: String? = "valid-ankidroid-js-addon-test", + addonTitle: String? = "Valid AnkiDroid JS Addon", + icon: String? = "", + version: String? = "1.0.0", + description: String? = "A test addon", + main: String? = "index.js", + ankidroidJsApi: String? = CURRENT_JS_API_VERSION, + addonType: String? = REVIEWER_ADDON, + keywords: List? = listOf(ANKIDROID_JS_ADDON_KEYWORDS), + author: Map? = mapOf("name" to "AnkiDroid"), + license: String? = "MIT", + homepage: String? = "https://example.com", + dist: DistInfo? = DistInfo("https://example.com/addon.tgz"), + ): AddonData = + AddonData( + name, + addonTitle, + icon, + version, + description, + main, + ankidroidJsApi, + addonType, + keywords, + author, + license, + homepage, + dist, + ) + + @Test // the validator must report errors, never throw + fun missingNameReturnsErrorTest() { + val result = getAddonModelFromAddonData(addonData(name = null)) + val errors = assertIs(result, "model is not built from an invalid manifest").errors + assertFalse("missing 'name' is reported as an error", errors.isEmpty()) + } + + @Test // the validator must report errors, never throw + fun missingKeywordsReturnsErrorTest() { + val result = getAddonModelFromAddonData(addonData(keywords = null)) + val errors = assertIs(result, "model is not built from an invalid manifest").errors + assertFalse("missing 'keywords' is reported as an error", errors.isEmpty()) + } + + @Test // 'version' is required during model construction, so it must be validated + fun missingVersionReturnsErrorTest() { + val result = getAddonModelFromAddonData(addonData(version = null)) + val errors = assertIs(result, "model is not built from an invalid manifest").errors + assertFalse("missing 'version' is reported as an error", errors.isEmpty()) + } + + @Test // exercises the checks after the required-fields guard: their errors must be returned + fun invalidAddonTypeReturnsErrorTest() { + val result = getAddonModelFromAddonData(addonData(addonType = "invalid-type")) + val errors = assertIs(result, "model is not built from an invalid manifest").errors + assertFalse("invalid 'addonType' is reported as an error", errors.isEmpty()) + } + + @Test + fun missingDistIsValidTest() { + // 'dist' is metadata added by the npm registry API; the package.json inside a + // tarball does not contain it, so a locally installed addon must still validate + val result = getAddonModelFromAddonData(addonData(dist = null)) + assertIs(result, "model is built from a manifest without 'dist'") + } + + @Test + fun missingOptionalMetadataIsValidTest() { + // description/author/license are commonly absent from real package.json files + val result = getAddonModelFromAddonData(addonData(description = null, author = null, license = null)) + val addon = assertIs(result, "model is built from a manifest without optional metadata").addonModel + assertEquals("", addon.description) + assertEquals(emptyMap(), addon.author) + assertEquals("", addon.license) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt index 78261e7d8d62..d8bb9c2523ae 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt @@ -21,8 +21,11 @@ import com.ichi2.anki.CollectionHelper import com.ichi2.anki.RobolectricTest import com.ichi2.testutils.ShadowStatFs import com.ichi2.utils.FileOperation.Companion.getFileResource +import io.mockk.every +import io.mockk.spyk import junit.framework.TestCase.assertTrue import org.apache.commons.compress.archivers.ArchiveException +import org.hamcrest.CoreMatchers.not import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.io.FileMatchers.anExistingDirectory import org.hamcrest.io.FileMatchers.anExistingFile @@ -32,6 +35,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.io.File import java.io.IOException +import kotlin.test.assertFailsWith @RunWith(AndroidJUnit4::class) class TgzPackageExtractTest : RobolectricTest() { @@ -137,6 +141,53 @@ class TgzPackageExtractTest : RobolectricTest() { assertThat(packageJsonPath, anExistingFile()) } + /** + * Test that failure to create the addon directory is reported to the caller + * instead of being silently swallowed + */ + @Test + fun extractionFailureIsReportedTest() { + // a regular file where the addon directory should go: directory creation must fail + val blocker = File(addonDir, "blocker").also { it.writeText("") } + val addonsPackageDir = File(blocker, "some-addon") + + assertFailsWith { + addonPackage.extractTarGzipToAddonFolder(File(tarballPath), addonsPackageDir) + } + } + + /** + * Test that a failed extraction is reported to the caller + * instead of completing as if it had succeeded + */ + @Test + fun unTarFailureIsReportedTest() { + val failingExtract = spyk(addonPackage) + every { failingExtract.unTar(any(), any()) } throws IOException("simulated failure") + + assertFailsWith { + failingExtract.extractTarGzipToAddonFolder(File(tarballPath), addonDir) + } + } + + @Test + fun failedExtractionCleansUpPartialAddonTest() { + // an addon package dir inside the addons dir, as production lays it out + val addonsPackageDir = File(addonDir, "some-addon") + + val failingExtract = spyk(addonPackage) + every { failingExtract.unTar(any(), any()) } answers { + // simulate a partial extraction before the failure + File(addonsPackageDir, "partial.js").writeText("") + throw IOException("simulated failure") + } + + assertFailsWith { + failingExtract.extractTarGzipToAddonFolder(File(tarballPath), addonsPackageDir) + } + assertThat(addonsPackageDir, not(anExistingDirectory())) + } + /** * Test if .tgz file successfully unGzipped * i.e. .tgz changed to .tar file