chore: Introduced mock-proxy module with SDK and pull mock files plugin.

This commit is contained in:
Denys Zelenchuk
2025-02-03 13:40:41 +00:00
parent 268ac02caa
commit 95e559e7db
34 changed files with 1388 additions and 9 deletions
+1
View File
@@ -234,6 +234,7 @@ Core libraries coordinates can be found under [coordinates section](#coordinates
| me.proton.core:test-android |
| me.proton.core:test-android-instrumented |
| me.proton.core:test-kotlin |
| me.proton.core:test-mock-proxy |
| me.proton.core:test-quark |
| me.proton.core:test-performance |
| me.proton.core:test-rule |
@@ -0,0 +1,27 @@
{
"request": {
"exactUrl": [
"/api/v4/test1"
],
"method": "post"
},
"response": {
"statusCode": 200,
"headers": {
"header1": "header1-1",
"header2": "header2-2"
},
"body": {
"Code": 1000,
"Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nY+6iVlkGjwe9tBlF3aDN8dXR4W7U3mX0r4RjfwxckmX3YSwfKCm/zM33b0As8mS8A5kpMSnvGs+E/Mc8CNOPQW6Oulxw4e0prdW9gEZEUCSJA3z02HWGk13/7zjmdzyWiU8yiWtmFga6i8GwfXUyjufS8T+1UYMvCa/HE8R6i2HNgfhhIQ0lKWqcO9DpAK/icPZUUtjVE4Xh2IDLDvjoQbiRJEo4bU6zMYEGcqy0g7a1Vz0IvSvQRFeMHxRuqtV8NtuDzISSm/gj/4DylPmOUQBdt1VPD4UKhPHLfS/MKt7MJz4cdnc6vrIsY2QDVCZvNa0zckaARjanz3MLPOwd/A==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j8JEDUFhcTpUY8mAABgEAEA/sDprqlXbiuE+RFWe9HW\n3u9jxwTXpPVXHtkkFCILzokBAOvXQ7yKDrAEXH4f045IyVoFwnhBc6OcPD/x\ncmCxAokC\n=JC9y\n-----END PGP SIGNATURE-----\n",
"ServerEphemeral": "S0R4Lfi2t4Bu+StgU39uXpZEYPfG/4vo2WuCGB7f0CfLFfg2he1+HvKCatly/n89KBQx+ei7uoVvD+iDxlaKzPyMvJ+ZiLB73QNaDwsWesnEYmA8ozJgoCRRJf+IBwx7fZAMD/mp9T5J6xscPULmLdFVNrAGjX+v99iTpVanXbFDZ7I/YA3gxjtcrgwyUuQkpkoHyVkuKnodH206e5cYX3szyKHzI48MsZy+UJ2gfuzCT1sYY43+tC1AKet7DNbthfDN9ERMfjWTUghba54DHoscy9GiVHibjD33xoQXpIeubMvI87mrv5Uu0voBYKsJ8TfZIhs/I0j+Xtrmskc5xQ==",
"Version": 4,
"Salt": "DnJh2bj2RBChNQ==",
"SRPSession": "2f34224721344fdb5a62e6f441a496a7"
}
},
"meta": {
"test": "test case name2",
"description": "test case description 2"
}
}
@@ -0,0 +1,24 @@
{
"request": {
"exactUrl": [
"/api/v4/test2"
],
"method": "post"
},
"response": {
"statusCode": 200,
"headers": {"header11": "header11-11", "header22": "header22-22"},
"body": {
"Code": 1000,
"Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nY+6iVlkGjwe9tBlF3aDN8dXR4W7U3mX0r4RjfwxckmX3YSwfKCm/zM33b0As8mS8A5kpMSnvGs+E/Mc8CNOPQW6Oulxw4e0prdW9gEZEUCSJA3z02HWGk13/7zjmdzyWiU8yiWtmFga6i8GwfXUyjufS8T+1UYMvCa/HE8R6i2HNgfhhIQ0lKWqcO9DpAK/icPZUUtjVE4Xh2IDLDvjoQbiRJEo4bU6zMYEGcqy0g7a1Vz0IvSvQRFeMHxRuqtV8NtuDzISSm/gj/4DylPmOUQBdt1VPD4UKhPHLfS/MKt7MJz4cdnc6vrIsY2QDVCZvNa0zckaARjanz3MLPOwd/A==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j8JEDUFhcTpUY8mAABgEAEA/sDprqlXbiuE+RFWe9HW\n3u9jxwTXpPVXHtkkFCILzokBAOvXQ7yKDrAEXH4f045IyVoFwnhBc6OcPD/x\ncmCxAokC\n=JC9y\n-----END PGP SIGNATURE-----\n",
"ServerEphemeral": "S0R4Lfi2t4Bu+StgU39uXpZEYPfG/4vo2WuCGB7f0CfLFfg2he1+HvKCatly/n89KBQx+ei7uoVvD+iDxlaKzPyMvJ+ZiLB73QNaDwsWesnEYmA8ozJgoCRRJf+IBwx7fZAMD/mp9T5J6xscPULmLdFVNrAGjX+v99iTpVanXbFDZ7I/YA3gxjtcrgwyUuQkpkoHyVkuKnodH206e5cYX3szyKHzI48MsZy+UJ2gfuzCT1sYY43+tC1AKet7DNbthfDN9ERMfjWTUghba54DHoscy9GiVHibjD33xoQXpIeubMvI87mrv5Uu0voBYKsJ8TfZIhs/I0j+Xtrmskc5xQ==",
"Version": 4,
"Salt": "DnJh2bj2RBChNQ==",
"SRPSession": "2f34224721344fdb5a62e6f441a496a7"
}
},
"meta": {
"test": "test case name2",
"description": "test case description 2"
}
}
@@ -0,0 +1,5 @@
{
"description": "scenario description",
"updateFile": false,
"mockFiles": ["scenarios/auth_scenario1/auth_mock1.json", "scenarios/auth_scenario1/auth_mock2.json"]
}
@@ -29,6 +29,7 @@ class ProtonIssueRegistry : IssueRegistry() {
override val issues: List<Issue> get() = listOf(
HardcodedCoroutineDispatcherDetector,
NoSerialNameAnnotationDetector,
NotConstantStringDetector
NotConstantStringDetector,
UsesCleartextTrafficManifestDetector
).flatMap { it.ISSUES }
}
@@ -0,0 +1,41 @@
package ch.protonmail.libs.lint.detectors
import com.android.tools.lint.detector.api.*
import org.w3c.dom.Element
class UsesCleartextTrafficManifestDetector : Detector(), XmlScanner {
override fun getApplicableElements(): Collection<String> {
// Look for <uses-permission> and <application> tags
return listOf("uses-permission", "application")
}
override fun visitElement(context: XmlContext, element: Element) {
if (element.tagName == "application") {
val cleartextTraffic = element.getAttribute("android:usesCleartextTraffic")
if (cleartextTraffic == "true") {
context.report(
ISSUE_FORBIDDEN_USES_CLEARTEXT,
element,
context.getLocation(element),
"`usesCleartextTraffic=\"true\"` must not be included in the release manifest."
)
}
}
}
companion object {
val ISSUE_FORBIDDEN_USES_CLEARTEXT = Issue.create(
id = "ForbiddenUsesCleartextTraffic",
briefDescription = "Forbidden usesCleartextTraffic in release build",
explanation = "Cleartext traffic should not be enabled in the release manifest.",
category = Category.SECURITY,
priority = 9,
severity = Severity.ERROR,
implementation = Implementation(
ForbiddenManifestCheck::class.java,
Scope.MANIFEST_SCOPE
)
)
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changes
- Added product flavor dimension support to environment config plugin.
- Added product flavor dimension support to environment config plugin.
## [1.3.0]
+10 -2
View File
@@ -152,8 +152,6 @@ Use internally in core project to orchestrate [core libraries publication](../RE
This plugin should be applied to either build flavor or application build type android extension
in `build.gradle.kts` file.
*
Generates `build/generated/source/envConfig/{flavor}/{buildType}/EnvironmentConfigurationDefaults.java`
similarly to `BuildConfig.java`.
Generated class is then added to source directories and can be accessed at runtime
@@ -162,6 +160,16 @@ Generated class is then added to source directories and can be accessed at runti
* By default (if no configuration provided in build.gradle.kts) generates a default production
config (`api.proton.me`)
## Mock-proxy file puller plugin
- Plugin id: `me.proton.core.gradle-plugins.mock-proxy`
- Published on MavenCentral.
Allows to automatically pull recorded mock files from local mock-proxy server.
In order to use it define below environment variables in your `local.properties` file:
1. `MOCK_PROXY_RECORD_DIR=/path-to-local-mock-roxy-repository`
2. `PROJECT_MOCK_FILES_DIR=/path-to-mock-files-dir-in-android-project`
```kotlin
build.gradle.kts
+1
View File
@@ -39,6 +39,7 @@ public object Module {
public const val androidTest: String = "$test:test-android"
public const val androidInstrumentedTest: String = "$androidTest:test-android-instrumented"
public const val quark: String = "$test:test-quark"
public const val mockProxy: String = "$test:test-mock-proxy"
public const val testPerformance: String = "$test:test-performance"
public const val testRule: String = "$test:test-rule"
// endregion
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2021 Proton Technologies AG
* This file is part of Proton Technologies AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
import org.gradle.kotlin.dsl.`java-gradle-plugin`
import org.gradle.kotlin.dsl.`kotlin-dsl`
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.gradlePlugin
import org.gradle.kotlin.dsl.implementation
import org.gradle.kotlin.dsl.kotlin
import org.gradle.kotlin.dsl.repositories
plugins {
`kotlin-dsl`
kotlin("jvm")
`java-gradle-plugin`
}
publishOption.shouldBePublishedAsPlugin = true
gradlePlugin {
plugins {
create("mockFilesPuller") {
id = "me.proton.core.gradle-plugins.mock-proxy"
displayName = "Proton pull recordings mock-proxy plugin."
description = "Pulls recorded files from and to defined locations."
implementationClass = "mockproxy.MockFilesPullerPlugin"
}
}
}
repositories {
google()
mavenCentral()
maven("https://plugins.gradle.org/m2/")
}
dependencies {
implementation(gradleApi())
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin")
compileOnly(libs.android.gradle)
api(libs.easyGradle.androidDsl)
}
@@ -0,0 +1,87 @@
package mockproxy
import com.android.build.gradle.internal.coverage.JacocoReportTask.JacocoReportWorkerAction.Companion.logger
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Properties
class MockFilesPullerPlugin : Plugin<Project> {
override fun apply(project: Project) {
val properties = Properties().apply {
runCatching {
load(project.rootDir.resolve("local.properties").inputStream())
}.recoverCatching {
load(project.rootDir.resolve("private.properties").inputStream())
}.getOrElse { throwable ->
// Provide empty properties to allow the app to be built without secrets
logger.warn(
"MockFilePullerPlugin: local.properties or private.properties " +
"file not found. Proceeding with empty properties." +
"\n Message ${throwable.message}"
)
Properties()
}
}
project.tasks.register("pullMockFiles", PullMockFilesTask::class.java) {
// Default setup: destination directory within the project
destinationDir =
project.layout.projectDirectory.dir(
properties.getProperty("PROJECT_MOCK_FILES_DIR")
).asFile
sourceDir =
project.layout.projectDirectory.dir(
properties.getProperty("MOCK_PROXY_RECORD_DIR")
).asFile
}
}
}
abstract class PullMockFilesTask : DefaultTask() {
@InputDirectory
lateinit var sourceDir: File
@OutputDirectory
lateinit var destinationDir: File
init {
group = "Mock"
description = "Pulls mock files from a source directory to the destination directory."
}
@TaskAction
fun pullMockFiles() {
logger.lifecycle(
"MockFilePullerPlugin: Pulling mock files from $sourceDir to $destinationDir"
)
if (!sourceDir.exists()) {
throw IllegalArgumentException("Source directory does not exist: $sourceDir")
}
destinationDir.mkdirs()
sourceDir.walkTopDown().forEach { file ->
val destinationFile = File(destinationDir, file.relativeTo(sourceDir).path)
if (file.isDirectory) {
destinationFile.mkdirs()
} else {
Files.copy(
file.toPath(),
destinationFile.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
logger.lifecycle(
"MockFilePullerPlugin: Copied: ${file.absolutePath} ->" +
" ${destinationFile.absolutePath}"
)
}
}
}
}
+2 -1
View File
@@ -28,7 +28,8 @@ include(
"tests",
"include-core-build",
"publish-core-libraries",
"environment-configuration"
"environment-configuration",
"mock-proxy"
)
pluginManagement {
+1
View File
@@ -0,0 +1 @@
/build
+76
View File
@@ -0,0 +1,76 @@
# Mock Proxy SDK
Mock proxy SDK allows to control mock proxy server parameters from inside your test.
Supported features:
1. Single route mock.
2. Scenario mocking (collection of routes).
3. Control bandwidth.
4. Control latency.
## Start using
To start using SDK create an instance of `MockClient(baseUrl: String)`:
```kotlin
val mockClient = MockClient(baseUrl)
```
Here `baseUrl` can be an atlas environment or the local host URL.
If you run tests locally using emulator use Android localhost baseUrl value from `Constants`:
```kotlin
public const val EMULATOR_LOCALHOST: String = "http://10.0.2.2:3001/"
```
`MockClient` instance creation with pre-defined local host value for Android emulator:
```kotlin
val mockClient = MockClient(EMULATOR_LOCALHOST)
```
## Mock single route
SDK assumes mock files are stored in app or test assets.
Mock single route from app assets:
```kotlin
mockClient.setStaticMockFromAppAssets(routeFilePath)
```
Mock single route from test assets:
```kotlin
mockClient.setStaticMockFromTestAssets(routeFilePath)
```
## Mock scenario
Mock scenario route from app assets (both app and test assets):
```kotlin
mockClient.setScenarioFromAssets(scenarioFilePath)
```
Reset scenario from app assets:
```kotlin
mockClient.resetScenarioFromAppAssets(scenarioFilePath)
```
Reset scenario from test assets:
```kotlin
mockClient.resetScenarioFromTestAssets(scenarioFilePath)
```
## Get, set and reset latency
```kotlin
val latencyInfo = mockClient.getLatency()
mockClient.setLatency(LatencyLevel.HIGH)
mockClient.resetLatency()
```
## Get, set and reset bandwidth
```kotlin
val bandwidth = mockClient.getBandwidth()
mockClient.setBandwidth(BandwidthLimit._4G)
mockClient.resetBandwidth()
```
## Resetting the state (reset all)
```kotlin
mockClient.resetAllMocks()
```
+45
View File
@@ -0,0 +1,45 @@
import studio.forface.easygradle.dsl.android.`android-test-runner`
import studio.forface.easygradle.dsl.android.androidTestImplementation
import studio.forface.easygradle.dsl.android.`hilt-android-testing`
import studio.forface.easygradle.dsl.android.retrofit
import studio.forface.easygradle.dsl.android.`retrofit-kotlin-serialization`
import studio.forface.easygradle.dsl.implementation
import studio.forface.easygradle.dsl.`kotlin-test`
import studio.forface.easygradle.dsl.`kotlin-test-junit`
import studio.forface.easygradle.dsl.`serialization-json`
import studio.forface.easygradle.dsl.squareup
import studio.forface.easygradle.dsl.version
plugins {
protonAndroidLibrary
kotlin("plugin.serialization")
jacoco
}
protonCoverage.disabled.set(true)
publishOption.shouldBePublishedAsLib = true
android {
namespace = "me.proton.core.test.mockproxy"
}
dependencies {
implementation(
junit,
retrofit,
okhttp,
`android-test-runner`,
`hilt-android-testing`,
`kotlin-test`,
`kotlin-test-junit`,
`retrofit-kotlin-serialization`,
`serialization-json`,
squareup("okhttp3", "okhttp-tls") version `okHttp version`,
)
androidTestImplementation(
`kotlin-test`
)
androidTestUtil(`androidx-test-orchestrator`)
}
@@ -0,0 +1,29 @@
{
"request": {
"exactUrl": [
"/api/v4/test1"
],
"method": "post"
},
"response": {
"statusCode": 200,
"headers": {
"header1": "header1-1",
"header2": "header2-2"
},
"body": {
"Code": 1000,
"Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nY+6iVlkGjwe9tBlF3aDN8dXR4W7U3mX0r4RjfwxckmX3YSwfKCm/zM33b0As8mS8A5kpMSnvGs+E/Mc8CNOPQW6Oulxw4e0prdW9gEZEUCSJA3z02HWGk13/7zjmdzyWiU8yiWtmFga6i8GwfXUyjufS8T+1UYMvCa/HE8R6i2HNgfhhIQ0lKWqcO9DpAK/icPZUUtjVE4Xh2IDLDvjoQbiRJEo4bU6zMYEGcqy0g7a1Vz0IvSvQRFeMHxRuqtV8NtuDzISSm/gj/4DylPmOUQBdt1VPD4UKhPHLfS/MKt7MJz4cdnc6vrIsY2QDVCZvNa0zckaARjanz3MLPOwd/A==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j8JEDUFhcTpUY8mAABgEAEA/sDprqlXbiuE+RFWe9HW\n3u9jxwTXpPVXHtkkFCILzokBAOvXQ7yKDrAEXH4f045IyVoFwnhBc6OcPD/x\ncmCxAokC\n=JC9y\n-----END PGP SIGNATURE-----\n",
"ServerEphemeral": "S0R4Lfi2t4Bu+StgU39uXpZEYPfG/4vo2WuCGB7f0CfLFfg2he1+HvKCatly/n89KBQx+ei7uoVvD+iDxlaKzPyMvJ+ZiLB73QNaDwsWesnEYmA8ozJgoCRRJf+IBwx7fZAMD/mp9T5J6xscPULmLdFVNrAGjX+v99iTpVanXbFDZ7I/YA3gxjtcrgwyUuQkpkoHyVkuKnodH206e5cYX3szyKHzI48MsZy+UJ2gfuzCT1sYY43+tC1AKet7DNbthfDN9ERMfjWTUghba54DHoscy9GiVHibjD33xoQXpIeubMvI87mrv5Uu0voBYKsJ8TfZIhs/I0j+Xtrmskc5xQ==",
"Version": 4,
"Salt": "DnJh2bj2RBChNQ==",
"SRPSession": "2f34224721344fdb5a62e6f441a496a7"
}
},
"meta": {
"test": "test case name2",
"description": "test case description 2"
},
"name": "test mock 1",
"enabled": false
}
@@ -0,0 +1,26 @@
{
"request": {
"exactUrl": [
"/api/v4/test2"
],
"method": "post"
},
"response": {
"statusCode": 200,
"headers": {"header11": "header11-11", "header22": "header22-22"},
"body": {
"Code": 1000,
"Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nY+6iVlkGjwe9tBlF3aDN8dXR4W7U3mX0r4RjfwxckmX3YSwfKCm/zM33b0As8mS8A5kpMSnvGs+E/Mc8CNOPQW6Oulxw4e0prdW9gEZEUCSJA3z02HWGk13/7zjmdzyWiU8yiWtmFga6i8GwfXUyjufS8T+1UYMvCa/HE8R6i2HNgfhhIQ0lKWqcO9DpAK/icPZUUtjVE4Xh2IDLDvjoQbiRJEo4bU6zMYEGcqy0g7a1Vz0IvSvQRFeMHxRuqtV8NtuDzISSm/gj/4DylPmOUQBdt1VPD4UKhPHLfS/MKt7MJz4cdnc6vrIsY2QDVCZvNa0zckaARjanz3MLPOwd/A==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j8JEDUFhcTpUY8mAABgEAEA/sDprqlXbiuE+RFWe9HW\n3u9jxwTXpPVXHtkkFCILzokBAOvXQ7yKDrAEXH4f045IyVoFwnhBc6OcPD/x\ncmCxAokC\n=JC9y\n-----END PGP SIGNATURE-----\n",
"ServerEphemeral": "S0R4Lfi2t4Bu+StgU39uXpZEYPfG/4vo2WuCGB7f0CfLFfg2he1+HvKCatly/n89KBQx+ei7uoVvD+iDxlaKzPyMvJ+ZiLB73QNaDwsWesnEYmA8ozJgoCRRJf+IBwx7fZAMD/mp9T5J6xscPULmLdFVNrAGjX+v99iTpVanXbFDZ7I/YA3gxjtcrgwyUuQkpkoHyVkuKnodH206e5cYX3szyKHzI48MsZy+UJ2gfuzCT1sYY43+tC1AKet7DNbthfDN9ERMfjWTUghba54DHoscy9GiVHibjD33xoQXpIeubMvI87mrv5Uu0voBYKsJ8TfZIhs/I0j+Xtrmskc5xQ==",
"Version": 4,
"Salt": "DnJh2bj2RBChNQ==",
"SRPSession": "2f34224721344fdb5a62e6f441a496a7"
}
},
"meta": {
"test": "test case name2",
"description": "test case description 2"
},
"name": "test mock 2",
"enabled": false
}
@@ -0,0 +1,5 @@
{
"description": "scenario description",
"updateFile": false,
"mockFiles": ["scenarios/auth_scenario1/auth_mock1.json", "scenarios/auth_scenario1/auth_mock2.json"]
}
@@ -0,0 +1,136 @@
package me.proton.core.test.mockproxy
import me.proton.core.test.mockproxy.Constants.EMULATOR_LOCALHOST
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Test
import java.io.FileNotFoundException
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MockProxyTest {
private val mockClient = MockClient(EMULATOR_LOCALHOST)
@After
fun afterTest() {
mockClient.resetAllMocks()
}
@Test
fun testScenarioMockingFromTestAssets() {
val scenarioFilePath = "scenarios/auth_scenario1/auth_scenario.json"
val staticMocksResponseList: List<MockObject> =
mockClient.setScenarioFromAssets(scenarioFilePath)
assertTrue(staticMocksResponseList.count() == 2)
}
@Test
fun testScenarioMockingFromAppAssets() {
val scenarioFilePath = "scenarios/auth_scenario1/auth_scenario.json"
mockClient.setLatency(LatencyLevel.EXTREME)
val staticMocksResponseList: List<MockObject> =
mockClient.setScenarioFromAssets(scenarioFilePath)
assertTrue(staticMocksResponseList.count() == 2)
}
@Test
fun testResetScenarioMockingFromAppAssets() {
val scenarioFilePath = "scenarios/auth_scenario1/auth_scenario.json"
mockClient.setLatency(LatencyLevel.EXTREME)
var staticMocksResponseList: List<MockObject> =
mockClient.setScenarioFromAssets(scenarioFilePath)
assert(staticMocksResponseList.count() == 2)
staticMocksResponseList.forEach { staticMock ->
assertTrue(staticMock.enabled, "Static mock should be enabled but it is not.")
}
staticMocksResponseList = mockClient.resetScenarioFromAssets(scenarioFilePath)
staticMocksResponseList.forEach { staticMock ->
assertFalse(staticMock.enabled, "Static mock should be disabled but it is not.")
}
}
@Test
fun testResetScenarioMockingFromTestAssets() {
val scenarioFilePath = "scenarios/auth_scenario1/auth_scenario.json"
mockClient.setLatency(LatencyLevel.EXTREME)
var staticMocksResponseList: List<MockObject> =
mockClient.setScenarioFromAssets(scenarioFilePath)
assert(staticMocksResponseList.count() == 2)
staticMocksResponseList.forEach { staticMock ->
assertTrue(staticMock.enabled, "Static mock should be enabled but it is not.")
}
staticMocksResponseList = mockClient.resetScenarioFromAssets(scenarioFilePath)
staticMocksResponseList.forEach { staticMock ->
assertFalse(staticMock.enabled, "Static mock should be disabled but it is not.")
}
}
@Test
fun testSingleAuthRouteMockingFromAppAssets() {
val routeFilePath = "scenarios/auth_scenario1/auth_mock1.json"
val staticMockResponse: MockObject = mockClient.setStaticMockFromAssets(routeFilePath)
assertTrue(staticMockResponse.response.statusCode == 200)
}
@Test
fun testSingleAuthRouteMockingFromTestAssets() {
val routeFilePath = "scenarios/auth_scenario1/auth_mock1.json"
val staticMockResponse: MockObject = mockClient.setStaticMockFromAssets(routeFilePath)
assertTrue(staticMockResponse.response.statusCode == 200)
}
@Test
fun testFailNotExistingFileLoadFromTestAssets() {
val notExistingFilePath = "scenarios/auth_scenario1/not_exists.json"
val exception = assertThrows(FileNotFoundException::class.java) {
mockClient.setStaticMockFromAssets(notExistingFilePath)
}
assertTrue(exception.message?.contains(notExistingFilePath) == true)
}
@Test
fun testFailNotExistingFileLoadFromAppAssets() {
val notExistingFilePath = "scenarios/auth_scenario1/not_exists.json"
val exception = assertThrows(FileNotFoundException::class.java) {
mockClient.setStaticMockFromAssets(notExistingFilePath)
}
assertTrue(exception.message?.contains(notExistingFilePath) == true)
}
@Test
fun testSetLatency() {
mockClient.setLatency(LatencyLevel.HIGH)
val latencyInfo = mockClient.getLatency()
assertTrue(latencyInfo.enabled && latencyInfo.latency == LatencyLevel.HIGH.latencyMs)
}
@Test
fun testSetAndResetLatency() {
mockClient.setLatency(LatencyLevel.HIGH)
var latencyInfo = mockClient.getLatency()
assertTrue(latencyInfo.enabled && latencyInfo.latency == LatencyLevel.HIGH.latencyMs)
mockClient.resetLatency()
latencyInfo = mockClient.getLatency()
assertTrue(!latencyInfo.enabled && latencyInfo.latency == LatencyLevel.NONE.latencyMs)
}
@Test
fun testSetBandwidth() {
mockClient.setBandwidth(BandwidthLimit.NONE)
val bandwidthInfo = mockClient.getBandwidth()
assertTrue(bandwidthInfo.enabled && bandwidthInfo.limit == BandwidthLimit.NONE.speedKbps)
}
@Test
fun testSetAndResetBandwidth() {
mockClient.setBandwidth(BandwidthLimit._4G)
var bandwidthInfo = mockClient.getBandwidth()
assert(bandwidthInfo.enabled && bandwidthInfo.limit == BandwidthLimit._4G.speedKbps)
mockClient.resetBandwidth()
bandwidthInfo = mockClient.getBandwidth()
assertTrue(!bandwidthInfo.enabled && bandwidthInfo.limit == BandwidthLimit.NONE.speedKbps)
}
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2023 Proton AG
~ This file is part of Proton AG and ProtonCore.
~
~ ProtonCore 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.
~
~ ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<!-- android:usesCleartextTraffic="true">-->
</application>
</manifest>
@@ -0,0 +1,27 @@
{
"request": {
"exactUrl": [
"/api/v4/test1"
],
"method": "post"
},
"response": {
"statusCode": 200,
"headers": {
"header1": "header1-1",
"header2": "header2-2"
},
"body": {
"Code": 1000,
"Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nY+6iVlkGjwe9tBlF3aDN8dXR4W7U3mX0r4RjfwxckmX3YSwfKCm/zM33b0As8mS8A5kpMSnvGs+E/Mc8CNOPQW6Oulxw4e0prdW9gEZEUCSJA3z02HWGk13/7zjmdzyWiU8yiWtmFga6i8GwfXUyjufS8T+1UYMvCa/HE8R6i2HNgfhhIQ0lKWqcO9DpAK/icPZUUtjVE4Xh2IDLDvjoQbiRJEo4bU6zMYEGcqy0g7a1Vz0IvSvQRFeMHxRuqtV8NtuDzISSm/gj/4DylPmOUQBdt1VPD4UKhPHLfS/MKt7MJz4cdnc6vrIsY2QDVCZvNa0zckaARjanz3MLPOwd/A==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j8JEDUFhcTpUY8mAABgEAEA/sDprqlXbiuE+RFWe9HW\n3u9jxwTXpPVXHtkkFCILzokBAOvXQ7yKDrAEXH4f045IyVoFwnhBc6OcPD/x\ncmCxAokC\n=JC9y\n-----END PGP SIGNATURE-----\n",
"ServerEphemeral": "S0R4Lfi2t4Bu+StgU39uXpZEYPfG/4vo2WuCGB7f0CfLFfg2he1+HvKCatly/n89KBQx+ei7uoVvD+iDxlaKzPyMvJ+ZiLB73QNaDwsWesnEYmA8ozJgoCRRJf+IBwx7fZAMD/mp9T5J6xscPULmLdFVNrAGjX+v99iTpVanXbFDZ7I/YA3gxjtcrgwyUuQkpkoHyVkuKnodH206e5cYX3szyKHzI48MsZy+UJ2gfuzCT1sYY43+tC1AKet7DNbthfDN9ERMfjWTUghba54DHoscy9GiVHibjD33xoQXpIeubMvI87mrv5Uu0voBYKsJ8TfZIhs/I0j+Xtrmskc5xQ==",
"Version": 4,
"Salt": "DnJh2bj2RBChNQ==",
"SRPSession": "2f34224721344fdb5a62e6f441a496a7"
}
},
"meta": {
"test": "test case name2",
"description": "test case description 2"
}
}
@@ -0,0 +1,24 @@
{
"request": {
"exactUrl": [
"/api/v4/test2"
],
"method": "post"
},
"response": {
"statusCode": 200,
"headers": {"header11": "header11-11", "header22": "header22-22"},
"body": {
"Code": 1000,
"Modulus": "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nY+6iVlkGjwe9tBlF3aDN8dXR4W7U3mX0r4RjfwxckmX3YSwfKCm/zM33b0As8mS8A5kpMSnvGs+E/Mc8CNOPQW6Oulxw4e0prdW9gEZEUCSJA3z02HWGk13/7zjmdzyWiU8yiWtmFga6i8GwfXUyjufS8T+1UYMvCa/HE8R6i2HNgfhhIQ0lKWqcO9DpAK/icPZUUtjVE4Xh2IDLDvjoQbiRJEo4bU6zMYEGcqy0g7a1Vz0IvSvQRFeMHxRuqtV8NtuDzISSm/gj/4DylPmOUQBdt1VPD4UKhPHLfS/MKt7MJz4cdnc6vrIsY2QDVCZvNa0zckaARjanz3MLPOwd/A==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1j8JEDUFhcTpUY8mAABgEAEA/sDprqlXbiuE+RFWe9HW\n3u9jxwTXpPVXHtkkFCILzokBAOvXQ7yKDrAEXH4f045IyVoFwnhBc6OcPD/x\ncmCxAokC\n=JC9y\n-----END PGP SIGNATURE-----\n",
"ServerEphemeral": "S0R4Lfi2t4Bu+StgU39uXpZEYPfG/4vo2WuCGB7f0CfLFfg2he1+HvKCatly/n89KBQx+ei7uoVvD+iDxlaKzPyMvJ+ZiLB73QNaDwsWesnEYmA8ozJgoCRRJf+IBwx7fZAMD/mp9T5J6xscPULmLdFVNrAGjX+v99iTpVanXbFDZ7I/YA3gxjtcrgwyUuQkpkoHyVkuKnodH206e5cYX3szyKHzI48MsZy+UJ2gfuzCT1sYY43+tC1AKet7DNbthfDN9ERMfjWTUghba54DHoscy9GiVHibjD33xoQXpIeubMvI87mrv5Uu0voBYKsJ8TfZIhs/I0j+Xtrmskc5xQ==",
"Version": 4,
"Salt": "DnJh2bj2RBChNQ==",
"SRPSession": "2f34224721344fdb5a62e6f441a496a7"
}
},
"meta": {
"test": "test case name2",
"description": "test case description 2"
}
}
@@ -0,0 +1,19 @@
{
"name": "loginMock",
"enabled": true,
"request": {
"exactUrl": [
"/api/core/v4/auth"
]
},
"response": {
"statusCode": 422,
"body": {
"Code": 8002,
"Error": "Incorrect login credentials. Please do not try again.",
"Details": {},
"exception": "Proton\\Http\\Exceptions\\UnprocessableEntityException",
"message": "Incorrect login credentials. Please do not try again."
}
}
}
@@ -0,0 +1,5 @@
{
"description": "scenario description",
"updateFile": false,
"mockFiles": ["scenarios/auth_scenario1/auth_mock1.json", "scenarios/auth_scenario1/auth_mock2.json"]
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.mockproxy
public object Constants {
public const val EMULATOR_LOCALHOST: String = "http://10.0.2.2:3001/"
public const val MOCK_PROXY_TAG: String = "Mock-proxy"
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.mockproxy
import retrofit2.http.*
internal interface MockApi {
/** Single route mock **/
@POST("/mock/route/static")
suspend fun setStaticMock(@Body mocks: MockObject): MockObject
/** List of routes to mock **/
@GET("/mock/routes/static")
suspend fun getStaticMocks(): List<MockObject>
@POST("/mock/routes/static")
suspend fun setStaticMocks(@Body mocks: List<MockObject>): List<MockObject>
/** List of routes to mock dynamically **/
@GET("/mock/routes/dynamic")
suspend fun getDynamicMocks(): List<DynamicMockObject>
@POST("/mock/routes/dynamic")
suspend fun setDynamicMocks(@Body mocks: DynamicMockObject): DynamicMockObject
/** Latency endpoints **/
@GET("/mock/latency")
suspend fun getLatency(): LatencyObject
@POST("/mock/latency")
suspend fun setLatency(@Body latencyInfo: LatencyObject): LatencyObject
/** Bandwidth endpoints **/
@GET("/mock/bandwidth")
suspend fun getBandwidth(): BandwidthObject
@POST("/mock/bandwidth")
suspend fun setBandwidth(@Body bandwidthInfo: BandwidthObject): BandwidthObject
/** Reset endpoints **/
@POST("/mock/reset/all")
suspend fun resetAllMocks(): ResponseMessage
}
@@ -0,0 +1,193 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.mockproxy
import android.util.Log
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
public class MockClient(private val baseUrl: String, private val proxyToken: String = "") {
private val retrofitBuilder: Retrofit.Builder = Retrofit.Builder()
.baseUrl(baseUrl)
.client(
OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request()
.newBuilder()
.addHeader("x-atlas-secret", proxyToken)
.build()
chain.proceed(request)
}
.build()
)
private var mockApi: MockApi = retrofitBuilder
.addConverterFactory(Json.asConverterFactory(MimeType.JSON.value.toMediaType()))
.build()
.create(MockApi::class.java)
public fun setSRPMock(
shouldEnable: Boolean = false,
password: String
) {
Log.d(
"MockProxy",
"Executing SRP request with baseUrl: ${this.baseUrl} and proxyToken: ${this.proxyToken}"
)
val srpMockList = DynamicMockObject(
name = "loginWithSrp",
enabled = shouldEnable,
parameters = mapOf("password" to password)
)
runBlocking { mockApi.setDynamicMocks(srpMockList) }
}
public fun setRecording(
shouldEnable: Boolean = false,
recordingDirPath: String
) {
Log.d(
"MockProxy",
"Executing set recording request with baseUrl: ${this.baseUrl} and proxyToken: ${this.proxyToken}"
)
val mock = DynamicMockObject(
name = "recordAll",
enabled = shouldEnable,
updateFile = true,
parameters = mapOf("mockDirectory" to recordingDirPath)
)
runBlocking {
mockApi.setDynamicMocks(mock)
}
}
public fun setScenarioFromAssets(
scenarioFilePath: String,
shouldUpdateFile: Boolean = false
): List<MockObject> {
val staticMocksList =
MockParser.parseScenarioFileFromAssets(scenarioFilePath, true, shouldUpdateFile)
return this.setStaticMocks(staticMocksList)
}
public fun resetScenarioFromAssets(scenarioFilePath: String): List<MockObject> {
val staticMocksList = MockParser.parseScenarioFileFromAssets(scenarioFilePath, false)
lateinit var mockObjectResponse: List<MockObject>
runBlocking { mockObjectResponse = mockApi.setStaticMocks(staticMocksList) }
return mockObjectResponse
}
public fun getScenarioDirPath(scenarioFilePath: String): String =
MockParser.getScenarioDirectoryOrThrow(scenarioFilePath)
public fun getStaticMocks(): List<MockObject> {
lateinit var mockObjectResponse: List<MockObject>
runBlocking { mockObjectResponse = mockApi.getStaticMocks() }
return mockObjectResponse
}
public fun setStaticMockFromAssets(filePath: String): MockObject {
val staticMockFile = MockParser.parseMockFileFromAssets(filePath)
lateinit var mockObjectResponse: MockObject
runBlocking { mockObjectResponse = mockApi.setStaticMock(staticMockFile) }
return mockObjectResponse
}
private fun setStaticMocks(mockRoutesList: List<MockObject>): List<MockObject> {
lateinit var mockObjectResponse: List<MockObject>
runBlocking { mockObjectResponse = mockApi.setStaticMocks(mockRoutesList) }
return mockObjectResponse
}
public fun setStaticMock(staticMock: MockObject): MockObject {
lateinit var mockObjectResponse: MockObject
runBlocking { mockObjectResponse = mockApi.setStaticMock(staticMock) }
return mockObjectResponse
}
public fun resetStaticMock(staticMock: MockObject): MockObject {
staticMock.enabled = false
lateinit var mockObjectResponse: MockObject
runBlocking { mockObjectResponse = mockApi.setStaticMock(staticMock) }
return mockObjectResponse
}
public fun getLatency(): LatencyObject {
lateinit var mockObjectResponse: LatencyObject
runBlocking { mockObjectResponse = mockApi.getLatency() }
return mockObjectResponse
}
public fun setLatency(latencyLevel: LatencyLevel): LatencyObject {
val latencyInfo = LatencyObject(
enabled = true,
latency = latencyLevel.latencyMs
)
lateinit var mockObjectResponse: LatencyObject
runBlocking { mockObjectResponse = mockApi.setLatency(latencyInfo) }
return mockObjectResponse
}
public fun resetLatency(): LatencyObject {
val latencyInfo = LatencyObject(
enabled = false,
latency = LatencyLevel.NONE.latencyMs
)
lateinit var mockObjectResponse: LatencyObject
runBlocking { mockObjectResponse = mockApi.setLatency(latencyInfo) }
return mockObjectResponse
}
public fun getBandwidth(): BandwidthObject {
lateinit var mockObjectResponse: BandwidthObject
runBlocking { mockObjectResponse = mockApi.getBandwidth() }
return mockObjectResponse
}
public fun setBandwidth(bandwidthLimit: BandwidthLimit = BandwidthLimit.NONE): BandwidthObject {
val bandwidthInfo = BandwidthObject(
enabled = true,
limit = bandwidthLimit.speedKbps
)
lateinit var mockObjectResponse: BandwidthObject
runBlocking { mockObjectResponse = mockApi.setBandwidth(bandwidthInfo) }
return mockObjectResponse
}
public fun resetBandwidth(): BandwidthObject {
val bandwidthInfo = BandwidthObject(
enabled = false,
limit = BandwidthLimit.NONE.speedKbps
)
lateinit var mockObjectResponse: BandwidthObject
runBlocking { mockObjectResponse = mockApi.setBandwidth(bandwidthInfo) }
return mockObjectResponse
}
public fun resetAllMocks(): String {
lateinit var mockObjectResponse: String
runBlocking { mockObjectResponse = mockApi.resetAllMocks().message }
return mockObjectResponse
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.mockproxy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
public data class MockObject(
var name: String,
var enabled: Boolean,
var updateFile: Boolean? = null,
val isRawFileContent: Boolean = false,
var request: MockFile.RequestData,
var response: MockFile.ResponseData
)
@Serializable
public data class DynamicMockObject(
var name: String,
var enabled: Boolean,
var description: String? = null,
var updateFile: Boolean? = null,
var parameters: Map<String, String>? = null,
var mocks: List<DynamicMock>? = null
)
@Serializable
public data class DynamicMock(
var request: MockFile.RequestData? = null,
var response: MockFile.ResponseData? = null
)
@Serializable
public data class ScenarioMockObject(
val scenarioName: String,
var enabled: Boolean,
val updateFile: Boolean? = null
)
@Serializable
public data class LatencyObject(
var enabled: Boolean,
val latency: Int
)
@Serializable
public data class BandwidthObject(
var enabled: Boolean,
val limit: Int
)
@Serializable
public data class ScenarioFileObject(
val description: String,
val updateFile: Boolean,
val mockFiles: List<String>
)
@Serializable
public data class MockFile(
val request: RequestData,
val response: ResponseData,
val name: String,
var enabled: Boolean,
val meta: MetaData? = null
) {
@Serializable
public data class RequestData(
val exactUrl: List<String>? = listOf(),
val matchUrl: List<String>? = listOf(),
val method: String? = null
)
@Serializable
public data class ResponseData(
val headers: Map<String, JsonElement>? = null,
val body: JsonElement? = null,
val statusCode: Int,
)
@Serializable
public data class MetaData(
val test: String,
val description: String,
)
}
@Serializable
public data class ParsedScenarioFile(
val name: String,
var enabled: Boolean,
val request: MockFile.RequestData,
val response: MockFile.ResponseData
)
@Serializable
public data class ResponseMessage(val message: String)
@@ -0,0 +1,162 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.mockproxy
import android.content.res.AssetManager
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.serialization.json.Json
import java.io.File
import java.io.FileNotFoundException
internal object MockParser {
private val testContext
get() = InstrumentationRegistry.getInstrumentation().context
private val testAssetManager
get() = testContext.assets ?: error("Could not load app assets.")
private fun readFileFromAssets(filePath: String, assetManager: AssetManager): String =
assetManager.open(filePath).bufferedReader().use { it.readText() }
internal fun parseMockFileFromAssets(mockFilePath: String): MockObject {
val assetManager = getAssetManager(mockFilePath)
return parseAssetFileToStaticMock(mockFilePath, assetManager)
}
private fun parseAssetFileToStaticMock(
mockFilePath: String,
assetManager: AssetManager,
isEnabled: Boolean = false
): MockObject {
val mockFile = File(mockFilePath)
val mockFileContent = readFileFromAssets(mockFilePath, assetManager)
val decodedMockFile: MockFile = Json.decodeFromString(mockFileContent)
val fileName = mockFile.nameWithoutExtension
return MockObject(
name = fileName,
enabled = isEnabled,
updateFile = false,
request = decodedMockFile.request,
response = decodedMockFile.response
)
}
internal fun getScenarioDirectoryOrThrow(
scenarioFilePath: String
): String {
val assetManager = getAssetManager(scenarioFilePath)
val fileContent = readFileFromAssets(scenarioFilePath, assetManager)
val scenarioFileObject: ScenarioFileObject = Json.decodeFromString(fileContent)
// Get the first mock file and check if it is a folder
val firstMockFile = scenarioFileObject.mockFiles[0]
val assetList = assetManager.list(firstMockFile)
if (assetList.isNullOrEmpty()) {
throw IllegalStateException("The first mock file is not a folder: $firstMockFile")
}
return firstMockFile
}
internal fun parseScenarioFileFromAssets(
scenarioFilePath: String,
isEnabled: Boolean,
shouldUpdateFile: Boolean = false
): List<MockObject> {
val assetManager = getAssetManager(scenarioFilePath)
return parseAssetFileToListOfStaticMocks(
scenarioFilePath,
assetManager,
isEnabled,
shouldUpdateFile
)
}
private fun getAssetManager(scenarioFilePath: String): AssetManager {
val assetManager = runCatching {
readFileFromAssets(
scenarioFilePath,
testAssetManager
).also { println("Read from testAssetManager") }
testAssetManager
}.getOrElse {
throw FileNotFoundException(
"Failed to read file from both test and app assets. File path: $scenarioFilePath"
)
}
return assetManager
}
private fun parseAssetFileToListOfStaticMocks(
scenarioFilePath: String,
assetManager: AssetManager,
isEnabled: Boolean = false,
shouldUpdateFile: Boolean = false
): List<MockObject> {
val mockRoutesList = mutableListOf<MockObject>()
val fileContent = readFileFromAssets(scenarioFilePath, assetManager)
val scenarioFileObject: ScenarioFileObject = Json.decodeFromString(fileContent)
processMockFiles(
scenarioFileObject.mockFiles,
assetManager,
isEnabled,
shouldUpdateFile,
mockRoutesList
)
return mockRoutesList
}
private fun processMockFiles(
mockFiles: List<String>,
assetManager: AssetManager,
isEnabled: Boolean,
shouldUpdateFile: Boolean,
mockRoutesList: MutableList<MockObject>
) {
mockFiles.forEach { mockFilePath ->
val assetList = assetManager.list(mockFilePath)
if (assetList.isNullOrEmpty()) {
val mockFile = File(mockFilePath)
val mockName =
"${System.currentTimeMillis()}-${mockFile.parentFile?.name}-${mockFile.nameWithoutExtension}"
val mockFileContent = readFileFromAssets(mockFilePath, assetManager)
val decodedMockFile: MockFile = Json.decodeFromString(mockFileContent)
mockRoutesList.add(
MockObject(
name = mockName,
enabled = isEnabled,
updateFile = shouldUpdateFile,
request = decodedMockFile.request,
response = decodedMockFile.response
)
)
} else {
assetList.forEach { nestedFileName ->
val nestedFilePath = "$mockFilePath/$nestedFileName"
processMockFiles(
listOf(nestedFilePath),
assetManager,
isEnabled,
shouldUpdateFile,
mockRoutesList
)
}
}
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.mockproxy
public enum class MimeType(public val value: String) {
JSON("application/json"),
IMAGE_SVG("image/svg+xml"),
IMAGE_PNG("image/png"),
OCTET_STREAM("application/octet-stream"),
MULTIPART_FORM_DATA("multipart/form-data");
}
public enum class LatencyLevel(public val latencyMs: Int) {
NONE(0),
LOW(50),
MODERATE(150),
HIGH(300),
VERY_HIGH(600),
CRITICAL(1000),
EXTREME(5000),
}
public enum class BandwidthLimit(public val speedKbps: Int) {
GPRS(56), // Real-world maximum
EDGE(236), // Real-world maximum
_2G(256), // Real-world maximum
_3G(42000), // Theoretical maximum for HSPA+
_4G(1000000), // 1 Gbps = Theoretical maximum
WIFI(9600000), // WiFi 6 theoretical maximum (9.6 Gbps)
BROADBAND(1000000), // Common high-speed broadband (1 Gbps)
NONE(Int.MAX_VALUE) // Unlimited
}
+1
View File
@@ -37,6 +37,7 @@ dependencies {
project(Module.configurationData),
project(Module.configurationDaggerContentResolver),
project(Module.quark),
project(Module.mockProxy),
project(Module.authDomain),
project(Module.authPresentation),
`coroutines-android`,
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.rule
import me.proton.core.test.mockproxy.MockClient
import me.proton.core.test.rule.annotation.MockTest
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.core.util.kotlin.takeIfNotEmpty
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Responsible for mock-proxy behavior configuration.
* Can act in test replay and record mode based on [shouldRecord] parameter.
* Enables SRP mocking in replay mode.
*/
public class MockTestRule(
private val mockClient: MockClient,
private val shouldRecord: Boolean = false
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
val mockTest = description.getAnnotation(MockTest::class.java)
?: description.testClass.getAnnotation(MockTest::class.java)
mockTest?.let {
mockTest.scenario.let { scenario ->
if (shouldRecord && mockTest.isReference) {
mockClient.setRecording(
true,
mockClient.getScenarioDirPath(scenario)
)
} else {
description.getAnnotation(PrepareUser::class.java)?.let { user ->
mockClient.setSRPMock(
true,
// Fall back to the default password value as we have to
// pass password to SRP anyway here.
user.userData.password.takeIfNotEmpty() ?: "password"
)
mockClient.setScenarioFromAssets(scenario)
}
}
}
}
return base
}
}
@@ -105,6 +105,8 @@ public class QuarkTestDataRule(
prepareUserAnnotations = method?.findAnnotations<PrepareUser>().orEmpty()
paymentMethodAnnotation = method?.findAnnotation<TestPaymentMethods>()
quarkCommand = getQuarkCommand(environmentConfiguration())
if (prepareUserAnnotations.isNotEmpty()) {
prepareUserAnnotations.forEach { prepareUser ->
@@ -140,7 +142,6 @@ public class QuarkTestDataRule(
*/
var handledUserData = prepareUser.userData.handleUserData()
var createdUserResponse: CreateUserQuarkResponse?
quarkCommand = getQuarkCommand(environmentConfiguration())
val userSeedingTime = measureTime {
createdUserResponse = prepareUser.annotationTestData.implementation(
@@ -300,10 +301,17 @@ public class QuarkTestDataRule(
}
}
private fun getQuarkCommand(envConfig: EnvironmentConfiguration): QuarkCommand =
QuarkCommand(quarkClient)
.baseUrl("https://${envConfig.host}/api/internal")
private fun getQuarkCommand(envConfig: EnvironmentConfiguration): QuarkCommand {
lateinit var baseUrl: String
if (envConfig.host.contains("10.0.2.2")) {
baseUrl = "http://${envConfig.host}/api/internal"
} else {
baseUrl = "https://${envConfig.host}/api/internal"
}
return QuarkCommand(quarkClient)
.baseUrl(baseUrl)
.proxyToken(envConfig.proxyToken)
}
public fun getAnnotationProperty(annotation: Annotation, propertyName: String): Any? {
return try {
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore 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.
*
* ProtonCore 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 ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.rule.annotation
import me.proton.core.test.rule.MockTestRule
/**
* Test function or test class annotated with this annotation will be treated as mock test by
* [MockTestRule].
*
* @property scenario - path to the scenario file in tests assets.
* @property isReference - if "true" indicates that this test should be used in recording mode and
* is the reference for recording for a given scenario.
*/
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
public annotation class MockTest(
val scenario: String,
val isReference: Boolean = false
)