Updated on 2026-08-14
This commit is contained in:
parent
ad0b09deae
commit
6480caeeed
13 changed files with 492 additions and 35 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
name: analyze-logs
|
name: analyze-logs
|
||||||
description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation.
|
description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation.
|
||||||
allowed-tools: Read, Grep
|
allowed-tools: Read, Grep
|
||||||
argument-hint: /path/to/logfile.txt [/path/to/logs.rtf]
|
argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] [--no-secrets-audit]
|
||||||
---
|
---
|
||||||
|
|
||||||
Analyze the Tangem app user log file at path: `$ARGUMENTS`
|
Analyze the Tangem app user log file at path: `$ARGUMENTS`
|
||||||
|
|
@ -108,12 +108,37 @@ Launch ALL Grep calls below in parallel. Steps 2+3 search the **full file** (dev
|
||||||
- `MainActivity.*onNewIntent` — deep link or push notification
|
- `MainActivity.*onNewIntent` — deep link or push notification
|
||||||
- `CardSDK_Session.*start card session` — NFC session starts
|
- `CardSDK_Session.*start card session` — NFC session starts
|
||||||
|
|
||||||
|
**Secrets & PII Audit (full file, head_limit: 20 each, -n: true):**
|
||||||
|
|
||||||
|
Skip this entire group if `--no-secrets-audit` is in arguments.
|
||||||
|
|
||||||
|
- API key leak in URL: `[?&](api[_-]?key|apiKey|access_token|token|secret)=(?!\*+)[^&\s]{8,}`
|
||||||
|
- Bearer token: `Bearer\s+[A-Za-z0-9._\-]{20,}`
|
||||||
|
- JWT: `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`
|
||||||
|
- Authorization header: `(?i)authorization:\s*\S+`
|
||||||
|
- Critical PII in JSON: `"(privateKey|mnemonic|seedPhrase|private_key|seed_phrase)"\s*:\s*"[^"]+"`
|
||||||
|
- card_public_key in JSON: `"card_public_key"\s*:\s*"[^"]{40,}"`
|
||||||
|
- FCM push token: `:APA91[A-Za-z0-9_\-]{100,}`
|
||||||
|
- xprv/tprv extended private key: `\b[xytzuv]prv[A-Za-z0-9]{100,}`
|
||||||
|
- Suspicious long hex in URL path: `https?://[^?\s]+/[A-Fa-f0-9]{32,}\b`
|
||||||
|
- Masking health check: count of `\*{6,}` — if 0 in a build that should mask, flag pipeline broken
|
||||||
|
|
||||||
**Error filtering:** When processing error results, skip these noisy matches:
|
**Error filtering:** When processing error results, skip these noisy matches:
|
||||||
- `java.io.IOException: Canceled` — normal request cancellation
|
- `java.io.IOException: Canceled` — normal request cancellation
|
||||||
- `HttpException(code=304` — HTTP "Not Modified"
|
- `HttpException(code=304` — HTTP "Not Modified"
|
||||||
- Bare stacktrace lines starting with `\tat`
|
- Bare stacktrace lines starting with `\tat`
|
||||||
- `<-- HTTP FAILED: java.io.IOException: Canceled`
|
- `<-- HTTP FAILED: java.io.IOException: Canceled`
|
||||||
|
|
||||||
|
### Step 6.5: Masking Consistency Check
|
||||||
|
|
||||||
|
Skip if `--no-secrets-audit` in arguments. Run sequentially after the parallel batch (needs results from the masked-endpoint grep).
|
||||||
|
|
||||||
|
1. Grep `https?://[^/\s]+/[^\s*]*\*{6,}` (full file, head_limit: 50) — collect all URLs where a path segment is masked
|
||||||
|
2. For each unique `host + path-prefix-before-mask`, derive the prefix string
|
||||||
|
3. For each prefix, Grep the prefix followed by a non-`*` character (`<prefix>[^*\s]`, head_limit: 20)
|
||||||
|
- If hits found → masking inconsistency: same endpoint has both masked and unmasked variants
|
||||||
|
- Record the prefix, count of masked hits, count of unmasked hits, first unmasked line number
|
||||||
|
|
||||||
### Step 7: Deep Dive
|
### Step 7: Deep Dive
|
||||||
|
|
||||||
For each significant error found above:
|
For each significant error found above:
|
||||||
|
|
@ -207,6 +232,37 @@ Structure your report EXACTLY as follows:
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
(chronological: app starts, card sessions, navigation, errors, notable API calls)
|
(chronological: app starts, card sessions, navigation, errors, notable API calls)
|
||||||
|
|
||||||
|
## Secrets & PII Audit
|
||||||
|
|
||||||
|
Omit this section entirely if `--no-secrets-audit` was passed.
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
- Total masked tokens (`******`) in log: **N**
|
||||||
|
- If N = 0 in a build expected to mask, flag: "masking pipeline may be broken"
|
||||||
|
|
||||||
|
### Confirmed Leaks (CRITICAL / HIGH)
|
||||||
|
| Line | Severity | Type | Matched (first 16 chars + `…`) | Context |
|
||||||
|
|------|----------|------|--------------------------------|---------|
|
||||||
|
|
||||||
|
### Masking Inconsistencies
|
||||||
|
| Endpoint Prefix | Masked Hits | Unmasked Hits | First Unmasked Line |
|
||||||
|
|-----------------|-------------|---------------|---------------------|
|
||||||
|
|
||||||
|
### Suspected Leaks (MEDIUM / LOW)
|
||||||
|
| Line | Severity | Type | Pattern Matched | Why Suspect |
|
||||||
|
|------|----------|------|-----------------|-------------|
|
||||||
|
|
||||||
|
**Severity legend:**
|
||||||
|
- **CRITICAL** — private key / mnemonic / xprv in clear text
|
||||||
|
- **HIGH** — API key / bearer / JWT / card_public_key visible
|
||||||
|
- **MEDIUM** — push token, card_id, persistent identifiers
|
||||||
|
- **LOW** — heuristic patterns that may be false positives (tx hash, content hash)
|
||||||
|
|
||||||
|
**Output rules:**
|
||||||
|
- Never include the full matched value — always truncate to 16 chars + `…`
|
||||||
|
- For LOW severity, add a "Why Suspect" column explaining typical false positives
|
||||||
|
- Skip matches from these known-public Tangem endpoints: `/v1/coins/settings`, `/v1/geo`, `/v1/currencies`, `/v1/hot_crypto`
|
||||||
|
|
||||||
## Analysis Summary
|
## Analysis Summary
|
||||||
(2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations.
|
(2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations.
|
||||||
If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.)
|
If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.)
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,20 @@ import android.app.Application
|
||||||
import com.chuckerteam.chucker.api.ChuckerInterceptor
|
import com.chuckerteam.chucker.api.ChuckerInterceptor
|
||||||
import com.tangem.Log
|
import com.tangem.Log
|
||||||
import com.tangem.TangemSdkLogger
|
import com.tangem.TangemSdkLogger
|
||||||
|
import com.tangem.blockchain.common.BlockchainSdkConfig
|
||||||
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
|
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
|
||||||
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
|
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
|
||||||
|
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||||
import com.tangem.datasource.local.logs.AppLogsStore
|
import com.tangem.datasource.local.logs.AppLogsStore
|
||||||
|
import com.tangem.datasource.local.logs.SensitiveUrlMasker
|
||||||
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
|
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
|
||||||
import com.tangem.datasource.utils.WireMockRedirectInterceptor
|
import com.tangem.datasource.utils.WireMockRedirectInterceptor
|
||||||
import com.tangem.domain.common.LogConfig
|
import com.tangem.domain.common.LogConfig
|
||||||
import com.tangem.operations.attestation.api.TangemApiServiceSettings
|
import com.tangem.operations.attestation.api.TangemApiServiceSettings
|
||||||
|
import com.tangem.utils.JsonStringValuesExtractor
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import com.tangem.wallet.BuildConfig
|
import com.tangem.wallet.BuildConfig
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns all app-startup wiring of the logging subsystem in a single place:
|
* Owns all app-startup wiring of the logging subsystem in a single place:
|
||||||
|
|
@ -23,12 +28,15 @@ import com.tangem.wallet.BuildConfig
|
||||||
* @property appLogsStore app logs store used by file-based writer and the network logs save
|
* @property appLogsStore app logs store used by file-based writer and the network logs save
|
||||||
* interceptor
|
* interceptor
|
||||||
* @property tangemSdkLogger Card SDK logger registered with [Log.addLogger]
|
* @property tangemSdkLogger Card SDK logger registered with [Log.addLogger]
|
||||||
|
* @property environmentConfig source of [BlockchainSdkConfig] used to build the blockchain
|
||||||
|
* URL masker
|
||||||
*
|
*
|
||||||
[REDACTED_AUTHOR]
|
[REDACTED_AUTHOR]
|
||||||
*/
|
*/
|
||||||
class TangemLoggingInitializer(
|
class TangemLoggingInitializer(
|
||||||
private val appLogsStore: AppLogsStore,
|
private val appLogsStore: AppLogsStore,
|
||||||
private val tangemSdkLogger: TangemSdkLogger,
|
private val tangemSdkLogger: TangemSdkLogger,
|
||||||
|
private val environmentConfig: EnvironmentConfig,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun initAppLogging() {
|
fun initAppLogging() {
|
||||||
|
|
@ -64,6 +72,13 @@ class TangemLoggingInitializer(
|
||||||
}
|
}
|
||||||
add(createNetworkLoggingInterceptor())
|
add(createNetworkLoggingInterceptor())
|
||||||
add(ChuckerInterceptor(application))
|
add(ChuckerInterceptor(application))
|
||||||
|
add(
|
||||||
|
NetworkLogsSaveInterceptor(
|
||||||
|
appLogsStore = appLogsStore,
|
||||||
|
sensitiveUrlMasker = createBlockchainSensitiveUrlMasker(),
|
||||||
|
shouldCheckResponseBodySize = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TangemApiServiceSettings.addInterceptors(
|
TangemApiServiceSettings.addInterceptors(
|
||||||
|
|
@ -77,4 +92,16 @@ class TangemLoggingInitializer(
|
||||||
}.toTypedArray(),
|
}.toTypedArray(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createBlockchainSensitiveUrlMasker(): SensitiveUrlMasker {
|
||||||
|
val json = Json.encodeToJsonElement(
|
||||||
|
BlockchainSdkConfig.serializer(),
|
||||||
|
environmentConfig.blockchainSdkConfig,
|
||||||
|
)
|
||||||
|
// Drop URL-shaped values (e.g. public endpoint URLs from BlockchainSdkConfig like
|
||||||
|
// kaspaSecondaryApiUrl); they are not secrets and would obscure unrelated requests in logs.
|
||||||
|
val values = JsonStringValuesExtractor.extract(json)
|
||||||
|
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
|
||||||
|
return SensitiveUrlMasker(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.tap.di.data
|
package com.tangem.tap.di.data
|
||||||
|
|
||||||
import com.tangem.blockchain.common.logging.BlockchainSDKLogger
|
import com.tangem.blockchain.common.logging.BlockchainSDKLogger
|
||||||
|
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||||
import com.tangem.datasource.local.logs.AppLogsStore
|
import com.tangem.datasource.local.logs.AppLogsStore
|
||||||
import com.tangem.tap.common.log.TangemBlockchainSDKLogger
|
import com.tangem.tap.common.log.TangemBlockchainSDKLogger
|
||||||
import com.tangem.tap.common.log.TangemCardSDKLogger
|
import com.tangem.tap.common.log.TangemCardSDKLogger
|
||||||
|
|
@ -17,10 +18,14 @@ internal object TangemLoggingModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer {
|
fun provideLoggingInitializer(
|
||||||
|
appLogsStore: AppLogsStore,
|
||||||
|
environmentConfig: EnvironmentConfig,
|
||||||
|
): TangemLoggingInitializer {
|
||||||
return TangemLoggingInitializer(
|
return TangemLoggingInitializer(
|
||||||
appLogsStore = appLogsStore,
|
appLogsStore = appLogsStore,
|
||||||
tangemSdkLogger = TangemCardSDKLogger(appLogsStore),
|
tangemSdkLogger = TangemCardSDKLogger(appLogsStore),
|
||||||
|
environmentConfig = environmentConfig,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ dependencies {
|
||||||
implementation(deps.kotlin.coroutines)
|
implementation(deps.kotlin.coroutines)
|
||||||
implementation(deps.kotlin.coroutines.rx2)
|
implementation(deps.kotlin.coroutines.rx2)
|
||||||
implementation(deps.kotlin.datetime)
|
implementation(deps.kotlin.datetime)
|
||||||
|
implementation(deps.kotlin.serialization)
|
||||||
|
|
||||||
/** Logging */
|
/** Logging */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,15 @@ import com.tangem.datasource.api.utils.ConnectTimeout
|
||||||
import com.tangem.datasource.api.utils.ReadTimeout
|
import com.tangem.datasource.api.utils.ReadTimeout
|
||||||
import com.tangem.datasource.api.utils.WriteTimeout
|
import com.tangem.datasource.api.utils.WriteTimeout
|
||||||
import com.tangem.datasource.di.NetworkMoshi
|
import com.tangem.datasource.di.NetworkMoshi
|
||||||
|
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||||
import com.tangem.datasource.local.logs.AppLogsStore
|
import com.tangem.datasource.local.logs.AppLogsStore
|
||||||
|
import com.tangem.datasource.local.logs.SensitiveUrlMasker
|
||||||
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
|
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
|
||||||
import com.tangem.datasource.utils.WireMockRedirectInterceptor
|
import com.tangem.datasource.utils.WireMockRedirectInterceptor
|
||||||
import com.tangem.datasource.utils.addHeaders
|
import com.tangem.datasource.utils.addHeaders
|
||||||
|
import com.tangem.utils.JsonStringValuesExtractor
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Invocation
|
import retrofit2.Invocation
|
||||||
|
|
@ -41,6 +45,7 @@ import javax.inject.Singleton
|
||||||
*
|
*
|
||||||
[REDACTED_AUTHOR]
|
[REDACTED_AUTHOR]
|
||||||
*/
|
*/
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@Singleton
|
@Singleton
|
||||||
internal class RetrofitApiBuilder @Inject constructor(
|
internal class RetrofitApiBuilder @Inject constructor(
|
||||||
private val apiConfigs: ApiConfigs,
|
private val apiConfigs: ApiConfigs,
|
||||||
|
|
@ -49,10 +54,20 @@ internal class RetrofitApiBuilder @Inject constructor(
|
||||||
private val analyticsErrorHandler: AnalyticsErrorHandler,
|
private val analyticsErrorHandler: AnalyticsErrorHandler,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val appLogsStore: AppLogsStore,
|
private val appLogsStore: AppLogsStore,
|
||||||
|
private val environmentConfig: EnvironmentConfig,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
|
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
|
||||||
|
|
||||||
|
private val sensitiveUrlMasker: SensitiveUrlMasker by lazy {
|
||||||
|
val json = Json.encodeToJsonElement(EnvironmentConfig.serializer(), environmentConfig)
|
||||||
|
// Drop URL-shaped values (e.g. public endpoint URLs from config); they are not secrets
|
||||||
|
// and would obscure unrelated requests in logs.
|
||||||
|
val values = JsonStringValuesExtractor.extract(json)
|
||||||
|
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
|
||||||
|
SensitiveUrlMasker(values)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Retrofit API instance for the specified API configuration ID
|
* Builds a Retrofit API instance for the specified API configuration ID
|
||||||
*
|
*
|
||||||
|
|
@ -179,7 +194,7 @@ internal class RetrofitApiBuilder @Inject constructor(
|
||||||
|
|
||||||
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
|
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
|
||||||
return addInterceptor(
|
return addInterceptor(
|
||||||
interceptor = NetworkLogsSaveInterceptor(appLogsStore),
|
interceptor = NetworkLogsSaveInterceptor(appLogsStore, sensitiveUrlMasker),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
|
||||||
import com.tangem.datasource.local.config.environment.models.ExpressModel
|
import com.tangem.datasource.local.config.environment.models.ExpressModel
|
||||||
import com.tangem.datasource.local.config.environment.models.P2PKeys
|
import com.tangem.datasource.local.config.environment.models.P2PKeys
|
||||||
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
|
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class EnvironmentConfig(
|
data class EnvironmentConfig(
|
||||||
val moonPayApiKey: String = "",
|
val moonPayApiKey: String = "",
|
||||||
val moonPayApiSecretKey: String = "",
|
val moonPayApiSecretKey: String = "",
|
||||||
|
|
@ -32,6 +35,7 @@ data class EnvironmentConfig(
|
||||||
val gaslessTxApiKey: String? = null,
|
val gaslessTxApiKey: String? = null,
|
||||||
val customerIoCdpApiKey: String? = null,
|
val customerIoCdpApiKey: String? = null,
|
||||||
val surveySparrowToken: String? = null,
|
val surveySparrowToken: String? = null,
|
||||||
|
@Transient
|
||||||
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
|
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
|
||||||
val authServiceKey: String? = null,
|
val authServiceKey: String? = null,
|
||||||
)
|
)
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package com.tangem.datasource.local.config.environment.models
|
package com.tangem.datasource.local.config.environment.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String)
|
data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class P2PKeys(val mainnet: String, val hoodi: String)
|
data class P2PKeys(val mainnet: String, val hoodi: String)
|
||||||
|
|
||||||
data class SurveySparrowSwapRatingConfig(
|
data class SurveySparrowSwapRatingConfig(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.tangem.datasource.local.logs
|
||||||
|
|
||||||
|
class SensitiveUrlMasker(sensitiveValues: Collection<String>) {
|
||||||
|
|
||||||
|
// Sorted by descending length so a value that is a prefix of another (e.g. "my-node" vs
|
||||||
|
// "my-node-prod") cannot mask the shorter one first and leave the suffix in the log.
|
||||||
|
private val sensitiveValues: List<String> = sensitiveValues
|
||||||
|
.distinct()
|
||||||
|
.sortedByDescending(String::length)
|
||||||
|
|
||||||
|
fun mask(url: String): String {
|
||||||
|
var result = url
|
||||||
|
for (value in sensitiveValues) {
|
||||||
|
if (result.contains(value, ignoreCase = true)) {
|
||||||
|
result = result.replace(value, MASKED_VALUE, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MASKED_VALUE = "******"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package com.tangem.datasource.utils
|
package com.tangem.datasource.utils
|
||||||
|
|
||||||
import com.tangem.datasource.local.logs.AppLogsStore
|
import com.tangem.datasource.local.logs.AppLogsStore
|
||||||
|
import com.tangem.datasource.local.logs.SensitiveUrlMasker
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|
@ -22,11 +24,15 @@ private const val JSON_INDENT_SPACES = 4
|
||||||
* Interceptor for save network requests and responses logs
|
* Interceptor for save network requests and responses logs
|
||||||
*
|
*
|
||||||
* @property appLogsStore app logs store
|
* @property appLogsStore app logs store
|
||||||
|
* @property sensitiveUrlMasker masker for sensitive data in URLs
|
||||||
|
* @property shouldCheckResponseBodySize whether to skip logging large response bodies
|
||||||
*
|
*
|
||||||
[REDACTED_AUTHOR]
|
[REDACTED_AUTHOR]
|
||||||
*/
|
*/
|
||||||
class NetworkLogsSaveInterceptor(
|
class NetworkLogsSaveInterceptor(
|
||||||
private val appLogsStore: AppLogsStore,
|
private val appLogsStore: AppLogsStore,
|
||||||
|
private val sensitiveUrlMasker: SensitiveUrlMasker? = null,
|
||||||
|
private val shouldCheckResponseBodySize: Boolean = false,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
|
|
@ -65,7 +71,7 @@ class NetworkLogsSaveInterceptor(
|
||||||
val connection = chain.connection()
|
val connection = chain.connection()
|
||||||
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
|
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
|
||||||
|
|
||||||
saveLogMessage("--> ${request.method} ${request.url}$connectionProtocol\n")
|
saveLogMessage("--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
|
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
|
||||||
|
|
@ -73,7 +79,7 @@ class NetworkLogsSaveInterceptor(
|
||||||
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
|
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
|
||||||
|
|
||||||
saveLogMessage(
|
saveLogMessage(
|
||||||
"--> ${request.method} ${request.url}$connectionProtocol\n",
|
"--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n",
|
||||||
createRequestEndMessage(request),
|
createRequestEndMessage(request),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +116,7 @@ class NetworkLogsSaveInterceptor(
|
||||||
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
|
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
|
||||||
saveLogMessage(
|
saveLogMessage(
|
||||||
"<-- ${response.code}",
|
"<-- ${response.code}",
|
||||||
" ${response.request.url} (${tookMs}ms)\n",
|
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,39 +129,45 @@ class NetworkLogsSaveInterceptor(
|
||||||
"<-- END HTTP"
|
"<-- END HTTP"
|
||||||
} else if (bodyHasUnknownEncoding(response.headers)) {
|
} else if (bodyHasUnknownEncoding(response.headers)) {
|
||||||
"<-- END HTTP (encoded body omitted)"
|
"<-- END HTTP (encoded body omitted)"
|
||||||
|
} else if (shouldCheckResponseBodySize && contentLength > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
|
||||||
|
"Response size too large: $contentLength bytes \n<-- END HTTP"
|
||||||
} else {
|
} else {
|
||||||
val source = responseBody.source()
|
val source = responseBody.source()
|
||||||
source.request(Long.MAX_VALUE)
|
source.request(Long.MAX_VALUE)
|
||||||
var buffer = source.buffer
|
var buffer = source.buffer
|
||||||
|
|
||||||
var gzippedLength: Long? = null
|
if (shouldCheckResponseBodySize && buffer.size > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
|
||||||
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
|
"Response size too large: ${buffer.size} bytes \n<-- END HTTP"
|
||||||
gzippedLength = buffer.size
|
|
||||||
GzipSource(buffer.clone()).use { gzippedResponseBody ->
|
|
||||||
buffer = Buffer()
|
|
||||||
buffer.writeAll(gzippedResponseBody)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val contentType = responseBody.contentType()
|
|
||||||
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
|
||||||
|
|
||||||
if (!buffer.isProbablyUtf8()) {
|
|
||||||
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
|
|
||||||
} else {
|
} else {
|
||||||
val json = if (contentLength != 0L) {
|
var gzippedLength: Long? = null
|
||||||
buffer.clone().readString(charset).beautifyJson()
|
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
|
||||||
} else {
|
gzippedLength = buffer.size
|
||||||
""
|
GzipSource(buffer.clone()).use { gzippedResponseBody ->
|
||||||
|
buffer = Buffer()
|
||||||
|
buffer.writeAll(gzippedResponseBody)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val end = if (gzippedLength != null) {
|
val contentType = responseBody.contentType()
|
||||||
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
|
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||||
} else {
|
|
||||||
"<-- END HTTP (${buffer.size}-byte body)"
|
|
||||||
}
|
|
||||||
|
|
||||||
"$json\n$end"
|
if (!buffer.isProbablyUtf8()) {
|
||||||
|
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
|
||||||
|
} else {
|
||||||
|
val json = if (contentLength != 0L) {
|
||||||
|
buffer.clone().readString(charset).beautifyJson()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
val end = if (gzippedLength != null) {
|
||||||
|
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
|
||||||
|
} else {
|
||||||
|
"<-- END HTTP (${buffer.size}-byte body)"
|
||||||
|
}
|
||||||
|
|
||||||
|
"$json\n$end"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,12 +178,16 @@ class NetworkLogsSaveInterceptor(
|
||||||
saveLogMessage(
|
saveLogMessage(
|
||||||
"<-- ${response.code}",
|
"<-- ${response.code}",
|
||||||
spaceBeforeResponseMessage,
|
spaceBeforeResponseMessage,
|
||||||
response.message,
|
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
|
||||||
" ${response.request.url} (${tookMs}ms)\n",
|
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun HttpUrl.maskSensitiveInfo(): String {
|
||||||
|
val url = toString()
|
||||||
|
return sensitiveUrlMasker?.mask(url) ?: url
|
||||||
|
}
|
||||||
|
|
||||||
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
|
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
|
||||||
val contentEncoding = headers["Content-Encoding"] ?: return false
|
val contentEncoding = headers["Content-Encoding"] ?: return false
|
||||||
return !contentEncoding.equals("identity", ignoreCase = true) &&
|
return !contentEncoding.equals("identity", ignoreCase = true) &&
|
||||||
|
|
@ -231,6 +247,9 @@ class NetworkLogsSaveInterceptor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|
||||||
|
const val WRITE_LOG_THRESHOLD_BYTES_SIZE = 2_048_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of URLs (host + path) for which logging is restricted
|
* List of URLs (host + path) for which logging is restricted
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
package com.tangem.datasource.local.logs
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import com.tangem.datasource.local.logs.SensitiveUrlMasker.Companion.MASKED_VALUE
|
||||||
|
import com.tangem.test.core.ProvideTestModels
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
internal class SensitiveUrlMaskerTest {
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ProvideTestModels
|
||||||
|
fun mask(model: TestModel) {
|
||||||
|
// Arrange
|
||||||
|
val masker = SensitiveUrlMasker(model.sensitiveValues)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = masker.mask(model.input)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).isEqualTo(model.expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mask returns url unchanged when no sensitive values provided`() {
|
||||||
|
// Arrange
|
||||||
|
val masker = SensitiveUrlMasker(emptyList())
|
||||||
|
val url = "https://api.tangem.com/v1/cards/abc123"
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = masker.mask(url)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).isEqualTo(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `constructor deduplicates input values`() {
|
||||||
|
// Arrange — same secret repeated; if no dedup, replace would be invoked twice
|
||||||
|
// (idempotent on already-masked string, but we assert behavior is identical
|
||||||
|
// to a single-value masker as a smoke-check)
|
||||||
|
val withDuplicates = SensitiveUrlMasker(listOf("secret123", "secret123", "secret123"))
|
||||||
|
val withSingle = SensitiveUrlMasker(listOf("secret123"))
|
||||||
|
val url = "https://api.tangem.com/?key=secret123"
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val withDup = withDuplicates.mask(url)
|
||||||
|
val withSingleResult = withSingle.mask(url)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(withDup).isEqualTo(withSingleResult)
|
||||||
|
Truth.assertThat(withDup).isEqualTo("https://api.tangem.com/?key=$MASKED_VALUE")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideTestModels() = listOf(
|
||||||
|
TestModel(
|
||||||
|
input = "https://api.tangem.com/?key=secret123",
|
||||||
|
sensitiveValues = listOf("secret123"),
|
||||||
|
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
|
||||||
|
),
|
||||||
|
TestModel(
|
||||||
|
input = "https://api.tangem.com/?a=alpha&b=beta",
|
||||||
|
sensitiveValues = listOf("alpha", "beta"),
|
||||||
|
expected = "https://api.tangem.com/?a=$MASKED_VALUE&b=$MASKED_VALUE",
|
||||||
|
),
|
||||||
|
TestModel(
|
||||||
|
input = "https://api.tangem.com/?key=SECRET123",
|
||||||
|
sensitiveValues = listOf("secret123"),
|
||||||
|
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
|
||||||
|
),
|
||||||
|
TestModel(
|
||||||
|
input = "https://api.tangem.com/v1/balance",
|
||||||
|
sensitiveValues = listOf("notInUrl"),
|
||||||
|
expected = "https://api.tangem.com/v1/balance",
|
||||||
|
),
|
||||||
|
TestModel(
|
||||||
|
input = "https://api.tangem.com/?key=secret123&other=secret123",
|
||||||
|
sensitiveValues = listOf("secret123"),
|
||||||
|
expected = "https://api.tangem.com/?key=$MASKED_VALUE&other=$MASKED_VALUE",
|
||||||
|
),
|
||||||
|
TestModel(
|
||||||
|
input = "https://api.tangem.com/v1/cards",
|
||||||
|
sensitiveValues = emptyList(),
|
||||||
|
expected = "https://api.tangem.com/v1/cards",
|
||||||
|
),
|
||||||
|
// Regression: when one value is a prefix of another, the longer one must be masked first
|
||||||
|
// regardless of input order, otherwise the suffix leaks (e.g. "my-node-prod" -> "******-prod").
|
||||||
|
TestModel(
|
||||||
|
input = "https://my-node-prod.example.com/v1",
|
||||||
|
sensitiveValues = listOf("my-node", "my-node-prod"),
|
||||||
|
expected = "https://$MASKED_VALUE.example.com/v1",
|
||||||
|
),
|
||||||
|
TestModel(
|
||||||
|
input = "https://my-node-prod.example.com/v1",
|
||||||
|
sensitiveValues = listOf("my-node-prod", "my-node"),
|
||||||
|
expected = "https://$MASKED_VALUE.example.com/v1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TestModel(
|
||||||
|
val input: String,
|
||||||
|
val sensitiveValues: List<String>,
|
||||||
|
val expected: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(deps.plugins.kotlin.jvm)
|
alias(deps.plugins.kotlin.jvm)
|
||||||
alias(deps.plugins.kotlin.kapt)
|
alias(deps.plugins.kotlin.kapt)
|
||||||
|
alias(deps.plugins.kotlin.serialization)
|
||||||
id("configuration")
|
id("configuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15,12 +16,13 @@ dependencies {
|
||||||
kapt(deps.hilt.kapt)
|
kapt(deps.hilt.kapt)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Coroutines
|
// region Kotlin
|
||||||
implementation(deps.kotlin.coroutines)
|
api(deps.kotlin.coroutines)
|
||||||
|
api(deps.kotlin.serialization)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Time dependencies
|
// region Time dependencies
|
||||||
implementation(deps.jodatime)
|
api(deps.jodatime)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
testImplementation(deps.test.coroutine)
|
testImplementation(deps.test.coroutine)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.tangem.utils
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all string primitive values from a [JsonElement] tree (recursively into
|
||||||
|
* objects and arrays). Non-string primitives are ignored.
|
||||||
|
*/
|
||||||
|
object JsonStringValuesExtractor {
|
||||||
|
|
||||||
|
fun extract(json: JsonElement): List<String> = json.extractStringValues()
|
||||||
|
|
||||||
|
private fun JsonElement.extractStringValues(): List<String> = when (this) {
|
||||||
|
is JsonPrimitive -> if (isString) listOfNotNull(contentOrNull) else emptyList()
|
||||||
|
is JsonObject -> values.flatMap { it.extractStringValues() }
|
||||||
|
is JsonArray -> flatMap { it.extractStringValues() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
package com.tangem.utils
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
class JsonStringValuesExtractorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns single value for string primitive`() {
|
||||||
|
// Arrange
|
||||||
|
val json = JsonPrimitive("hello")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).containsExactly("hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns empty for numeric primitive`() {
|
||||||
|
// Arrange
|
||||||
|
val json = JsonPrimitive(42)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns empty for boolean primitive`() {
|
||||||
|
// Arrange
|
||||||
|
val json = JsonPrimitive(true)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns empty for json null`() {
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(JsonNull)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns all string values from flat object`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement(
|
||||||
|
"""{"apiKey":"abc","secret":"xyz","count":42,"enabled":true}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).containsExactly("abc", "xyz")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns all string values from flat array`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement("""["one","two",3,true,null]""")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).containsExactly("one", "two").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract recurses into nested objects`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement(
|
||||||
|
"""{"outer":{"inner":{"key":"deep"}},"top":"shallow"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).containsExactly("deep", "shallow")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract recurses into nested arrays`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement("""[["a","b"],["c",["d"]]]""")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).containsExactly("a", "b", "c", "d").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract handles mixed nested objects and arrays`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement(
|
||||||
|
"""{"keys":["k1","k2"],"nested":{"items":[{"name":"x"},{"name":"y"}]}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).containsExactly("k1", "k2", "x", "y")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns empty for empty object`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement("""{}""")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns empty for empty array`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement("""[]""")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract preserves duplicate values`() {
|
||||||
|
// Arrange — extractor does NOT dedupe; that's the caller's concern
|
||||||
|
val json = Json.parseToJsonElement("""{"a":"same","b":"same","c":"other"}""")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Truth.assertThat(actual).containsExactly("same", "same", "other")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extract returns empty string when string primitive is empty`() {
|
||||||
|
// Arrange
|
||||||
|
val json = Json.parseToJsonElement("""{"a":"","b":"x"}""")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val actual = JsonStringValuesExtractor.extract(json)
|
||||||
|
|
||||||
|
// Assert — extractor returns "" too; filtering is caller's job
|
||||||
|
Truth.assertThat(actual).containsExactly("", "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue