mirror of
https://github.com/openlibrecommunity/olcng.git
synced 2026-07-03 14:05:17 +02:00
Tests for shadowsocks fmt protocol formatter (#5137)
* Add unit tests for ShadowsocksFmt protocol formatter - Add comprehensive test coverage for Shadowsocks URL parsing - Test SIP002 format (modern) with base64 encoded userinfo - Test legacy format with full base64 encoding - Test URI generation and round-trip consistency - Cover edge cases: IPv6 addresses, empty remarks, various encryption methods - Mock Android Base64 and Log classes for unit test execution * Adding URL decoding before Base64 decoding. The flow is now: extract → URL decode → Base64 decode. * Fix test naming convention to match project standards Change test method names from backtick-delimited descriptive names to underscore-separated format with test_ prefix to align with existing project conventions (e.g., test_parseInt in UtilsTest). Before: `parseSip002 valid URL with base64 encoded userinfo`() After: test_parseSip002_validUrlWithBase64EncodedUserinfo() * Fix Base64 mock to properly handle encoding flags The mock now inspects the flags parameter and applies URL-safe encoding and padding removal conditionally based on actual flag values (URL_SAFE, NO_PADDING) instead of unconditionally using withoutPadding(). * Fix Base64 decode mock to propagate exceptions The mock now properly handles the URL_SAFE flag and propagates exceptions on invalid input instead of silently returning an empty ByteArray. This matches Android's actual Base64 behavior where IllegalArgumentException is thrown on invalid input. * Extract helper functions to reduce code duplication Add createSip002Url() and createLegacyUrl() helper functions to centralize URL construction logic. Add SS_SCHEME constant to avoid magic strings. This improves maintainability by reducing repeated Base64 encoding and URL construction patterns. * Add URI format verification before extracting base64 Verify that toUri() returns a URI string without the scheme prefix before extracting the base64 content. This ensures the test correctly validates the URI format and prevents incorrect data extraction if the implementation changes. * Add scheme verification in round-trip test Explicitly verify that toUri() returns a URI string without the scheme prefix before prepending it. This documents the expected behavior and ensures the test fails gracefully if the implementation changes. * mockito-inline version 6.1.0 does not exist - The latest available version is 5.2.0 (released March 2023) - The mockito-inline artifact was effectively deprecated after 5.2.0; mocking final classes is now built into mockito-core since Mockito 5.x
This commit is contained in:
committed by
GitHub
parent
29b67c29f5
commit
02a9d64188
@@ -0,0 +1,382 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.After
|
||||
import org.junit.Test
|
||||
import org.mockito.MockedStatic
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.mockStatic
|
||||
import java.net.URLDecoder
|
||||
import java.util.Base64 as JavaBase64
|
||||
|
||||
/**
|
||||
* Unit tests for ShadowsocksFmt class.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Parsing SIP002 format URLs (modern format)
|
||||
* - Parsing legacy format URLs
|
||||
* - Converting ProfileItem to URI
|
||||
* - Various encryption methods
|
||||
*/
|
||||
class ShadowsocksFmtTest {
|
||||
|
||||
companion object {
|
||||
private const val SS_SCHEME = "ss://"
|
||||
}
|
||||
|
||||
private lateinit var mockBase64: MockedStatic<Base64>
|
||||
private lateinit var mockLog: MockedStatic<Log>
|
||||
|
||||
/**
|
||||
* Helper function to create a SIP002 format Shadowsocks URL.
|
||||
*/
|
||||
private fun createSip002Url(
|
||||
method: String,
|
||||
password: String,
|
||||
host: String,
|
||||
port: Int,
|
||||
remarks: String
|
||||
): String {
|
||||
val methodPassword = "$method:$password"
|
||||
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(methodPassword.toByteArray())
|
||||
return "$SS_SCHEME${base64UserInfo}@$host:$port#${remarks.replace(" ", "%20")}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a legacy format Shadowsocks URL.
|
||||
*/
|
||||
private fun createLegacyUrl(
|
||||
method: String,
|
||||
password: String,
|
||||
host: String,
|
||||
port: Int,
|
||||
remarks: String
|
||||
): String {
|
||||
val legacyContent = "$method:$password@$host:$port"
|
||||
val base64Encoded = JavaBase64.getEncoder().encodeToString(legacyContent.toByteArray())
|
||||
return "$SS_SCHEME${base64Encoded}#${remarks.replace(" ", "%20")}"
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockLog = mockStatic(Log::class.java, Mockito.RETURNS_DEFAULTS)
|
||||
|
||||
mockBase64 = mockStatic(Base64::class.java)
|
||||
// Mock decode with proper flag handling and exception propagation
|
||||
mockBase64.`when`<ByteArray> {
|
||||
Base64.decode(Mockito.anyString(), Mockito.anyInt())
|
||||
}.thenAnswer { invocation ->
|
||||
val input = invocation.arguments[0] as String
|
||||
val flags = invocation.arguments[1] as Int
|
||||
val isUrlSafe = (flags and Base64.URL_SAFE) != 0
|
||||
|
||||
val decoder = if (isUrlSafe) {
|
||||
JavaBase64.getUrlDecoder()
|
||||
} else {
|
||||
JavaBase64.getDecoder()
|
||||
}
|
||||
|
||||
// Propagate exception on invalid input (matches Android behavior)
|
||||
decoder.decode(input)
|
||||
}
|
||||
// Mock encode with proper flag handling
|
||||
mockBase64.`when`<String> {
|
||||
Base64.encodeToString(Mockito.any(ByteArray::class.java), Mockito.anyInt())
|
||||
}.thenAnswer { invocation ->
|
||||
val input = invocation.arguments[0] as ByteArray
|
||||
val flags = invocation.arguments[1] as Int
|
||||
|
||||
val isUrlSafe = (flags and Base64.URL_SAFE) != 0
|
||||
val noPadding = (flags and Base64.NO_PADDING) != 0
|
||||
|
||||
var encoder = if (isUrlSafe) {
|
||||
JavaBase64.getUrlEncoder()
|
||||
} else {
|
||||
JavaBase64.getEncoder()
|
||||
}
|
||||
|
||||
if (noPadding) {
|
||||
encoder = encoder.withoutPadding()
|
||||
}
|
||||
|
||||
encoder.encodeToString(input)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockLog.close()
|
||||
mockBase64.close()
|
||||
}
|
||||
|
||||
// ==================== SIP002 Format Tests ====================
|
||||
|
||||
@Test
|
||||
fun test_parseSip002_validUrlWithBase64EncodedUserinfo() {
|
||||
val ssUrl = createSip002Url(
|
||||
method = "aes-256-gcm",
|
||||
password = "my-secret-password",
|
||||
host = "example.com",
|
||||
port = 8388,
|
||||
remarks = "Test Server"
|
||||
)
|
||||
|
||||
val result = ShadowsocksFmt.parseSip002(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("Test Server", result?.remarks)
|
||||
assertEquals("example.com", result?.server)
|
||||
assertEquals("8388", result?.serverPort)
|
||||
assertEquals("aes-256-gcm", result?.method)
|
||||
assertEquals("my-secret-password", result?.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseSip002_validUrlWithPlainTextUserinfo() {
|
||||
val ssUrl = "ss://aes-256-gcm:mypassword@example.com:8388#Plain%20Server"
|
||||
|
||||
val result = ShadowsocksFmt.parseSip002(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("Plain Server", result?.remarks)
|
||||
assertEquals("aes-256-gcm", result?.method)
|
||||
assertEquals("mypassword", result?.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseSip002_withChacha20Encryption() {
|
||||
val ssUrl = createSip002Url(
|
||||
method = "chacha20-ietf-poly1305",
|
||||
password = "secret123",
|
||||
host = "ss.example.com",
|
||||
port = 443,
|
||||
remarks = "ChaCha20"
|
||||
)
|
||||
|
||||
val result = ShadowsocksFmt.parseSip002(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("chacha20-ietf-poly1305", result?.method)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseSip002_returnsNullForEmptyHost() {
|
||||
// Manually construct URL with empty host (can't use helper)
|
||||
val methodPassword = "aes-256-gcm:password"
|
||||
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(methodPassword.toByteArray())
|
||||
val ssUrl = "${SS_SCHEME}${base64UserInfo}@:8388#No%20Host"
|
||||
|
||||
val result = ShadowsocksFmt.parseSip002(ssUrl)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseSip002_returnsNullForInvalidPort() {
|
||||
// Manually construct URL with invalid port (can't use helper)
|
||||
val methodPassword = "aes-256-gcm:password"
|
||||
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(methodPassword.toByteArray())
|
||||
val ssUrl = "${SS_SCHEME}${base64UserInfo}@example.com:-1#Invalid%20Port"
|
||||
|
||||
val result = ShadowsocksFmt.parseSip002(ssUrl)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
// ==================== Legacy Format Tests ====================
|
||||
|
||||
@Test
|
||||
fun test_parseLegacy_validUrl() {
|
||||
val ssUrl = createLegacyUrl(
|
||||
method = "aes-256-gcm",
|
||||
password = "password123",
|
||||
host = "legacy.example.com",
|
||||
port = 8388,
|
||||
remarks = "Legacy Server"
|
||||
)
|
||||
|
||||
val result = ShadowsocksFmt.parseLegacy(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("Legacy Server", result?.remarks)
|
||||
assertEquals("legacy.example.com", result?.server)
|
||||
assertEquals("8388", result?.serverPort)
|
||||
assertEquals("aes-256-gcm", result?.method)
|
||||
assertEquals("password123", result?.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseLegacy_withPartiallyEncodedUrl() {
|
||||
// Partially encoded legacy format (method:password encoded, host:port not)
|
||||
val methodPassword = "chacha20-ietf-poly1305:my-pass"
|
||||
val base64Part = JavaBase64.getEncoder().encodeToString(methodPassword.toByteArray())
|
||||
val ssUrl = "${SS_SCHEME}${base64Part}@partial.example.com:443#Partial%20Encoded"
|
||||
|
||||
val result = ShadowsocksFmt.parseLegacy(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("Partial Encoded", result?.remarks)
|
||||
assertEquals("partial.example.com", result?.server)
|
||||
assertEquals("chacha20-ietf-poly1305", result?.method)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseLegacy_returnsNullForInvalidFormat() {
|
||||
val invalidContent = "not-a-valid-format"
|
||||
val base64Encoded = JavaBase64.getEncoder().encodeToString(invalidContent.toByteArray())
|
||||
val ssUrl = "${SS_SCHEME}${base64Encoded}#Invalid"
|
||||
|
||||
val result = ShadowsocksFmt.parseLegacy(ssUrl)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseLegacy_handlesPasswordWithColon() {
|
||||
// Special case: password contains colons (can't use helper)
|
||||
val legacyContent = "aes-256-gcm:pass:word:with:colons@example.com:8388"
|
||||
val base64Encoded = JavaBase64.getEncoder().encodeToString(legacyContent.toByteArray())
|
||||
val ssUrl = "${SS_SCHEME}${base64Encoded}#Colon%20Password"
|
||||
|
||||
val result = ShadowsocksFmt.parseLegacy(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("pass:word:with:colons", result?.password)
|
||||
}
|
||||
|
||||
// ==================== toUri Tests ====================
|
||||
|
||||
@Test
|
||||
fun test_toUri_createsValidSip002Url() {
|
||||
val config = ProfileItem.create(EConfigType.SHADOWSOCKS).apply {
|
||||
remarks = "Test Server"
|
||||
server = "example.com"
|
||||
serverPort = "8388"
|
||||
method = "aes-256-gcm"
|
||||
password = "my-secret-password"
|
||||
}
|
||||
|
||||
val uri = ShadowsocksFmt.toUri(config)
|
||||
|
||||
// Verify URI does not include scheme (toUri returns without ss:// prefix)
|
||||
assertFalse("toUri should not include scheme prefix", uri.startsWith(SS_SCHEME))
|
||||
|
||||
assertTrue(uri.contains("@example.com:8388"))
|
||||
assertTrue(uri.contains("#Test%20Server"))
|
||||
|
||||
// Verify the base64 part (URL decode first, then Base64 decode)
|
||||
val base64Part = uri.substringBefore("@")
|
||||
val urlDecoded = URLDecoder.decode(base64Part, Charsets.UTF_8.name())
|
||||
val decoded = String(JavaBase64.getDecoder().decode(urlDecoded))
|
||||
assertEquals("aes-256-gcm:my-secret-password", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_toUri_handlesIpv6Address() {
|
||||
val config = ProfileItem.create(EConfigType.SHADOWSOCKS).apply {
|
||||
remarks = "IPv6 Server"
|
||||
server = "2001:db8::1"
|
||||
serverPort = "8388"
|
||||
method = "aes-256-gcm"
|
||||
password = "password"
|
||||
}
|
||||
|
||||
val uri = ShadowsocksFmt.toUri(config)
|
||||
|
||||
assertTrue(uri.contains("[2001:db8::1]:8388"))
|
||||
}
|
||||
|
||||
// ==================== Round-trip Tests ====================
|
||||
|
||||
@Test
|
||||
fun test_parseAndToUri_roundTripPreservesData() {
|
||||
val originalUrl = createSip002Url(
|
||||
method = "chacha20-ietf-poly1305",
|
||||
password = "round-trip-password",
|
||||
host = "roundtrip.example.com",
|
||||
port = 443,
|
||||
remarks = "Round Trip Test"
|
||||
)
|
||||
|
||||
val parsed = ShadowsocksFmt.parse(originalUrl)
|
||||
assertNotNull(parsed)
|
||||
|
||||
val regeneratedUri = ShadowsocksFmt.toUri(parsed!!)
|
||||
|
||||
// Verify toUri returns without scheme, so we need to prepend it
|
||||
assertFalse(
|
||||
"toUri should return URI without scheme",
|
||||
regeneratedUri.startsWith(SS_SCHEME)
|
||||
)
|
||||
|
||||
val reparsed = ShadowsocksFmt.parse("$SS_SCHEME$regeneratedUri")
|
||||
assertNotNull(reparsed)
|
||||
|
||||
assertEquals(parsed.remarks, reparsed?.remarks)
|
||||
assertEquals(parsed.server, reparsed?.server)
|
||||
assertEquals(parsed.serverPort, reparsed?.serverPort)
|
||||
assertEquals(parsed.method, reparsed?.method)
|
||||
assertEquals(parsed.password, reparsed?.password)
|
||||
}
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
|
||||
@Test
|
||||
fun test_parse_handlesEmptyRemarksGracefully() {
|
||||
// Empty remarks edge case (can't use helper)
|
||||
val methodPassword = "aes-256-gcm:password"
|
||||
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(methodPassword.toByteArray())
|
||||
val ssUrl = "${SS_SCHEME}${base64UserInfo}@example.com:8388#"
|
||||
|
||||
val result = ShadowsocksFmt.parse(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("none", result?.remarks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseSip002_handlesDifferentEncryptionMethods() {
|
||||
val methods = listOf("aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305")
|
||||
|
||||
for (method in methods) {
|
||||
val ssUrl = createSip002Url(
|
||||
method = method,
|
||||
password = "testpass",
|
||||
host = "example.com",
|
||||
port = 8388,
|
||||
remarks = method
|
||||
)
|
||||
|
||||
val result = ShadowsocksFmt.parseSip002(ssUrl)
|
||||
|
||||
assertNotNull("Failed for method: $method", result)
|
||||
assertEquals(method, result?.method)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_parseLegacy_convertsMethodToLowercase() {
|
||||
// Uppercase method to test lowercase conversion (can't use helper)
|
||||
val legacyContent = "AES-256-GCM:password@example.com:8388"
|
||||
val base64Encoded = JavaBase64.getEncoder().encodeToString(legacyContent.toByteArray())
|
||||
val ssUrl = "${SS_SCHEME}${base64Encoded}#Uppercase%20Method"
|
||||
|
||||
val result = ShadowsocksFmt.parseLegacy(ssUrl)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("aes-256-gcm", result?.method)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ core = "3.5.4"
|
||||
workRuntimeKtx = "2.11.0"
|
||||
lifecycleViewmodelKtx = "2.10.0"
|
||||
multidex = "2.0.1"
|
||||
mockitoMockitoInline = "6.1.0"
|
||||
mockitoMockitoInline = "5.2.0"
|
||||
flexbox = "3.0.0"
|
||||
preferenceKtx = "1.2.1"
|
||||
recyclerview = "1.4.0"
|
||||
|
||||
Reference in New Issue
Block a user