236 lines
9.3 KiB
Kotlin
236 lines
9.3 KiB
Kotlin
package com.osglab.account.config
|
|
|
|
import io.ktor.server.config.MapApplicationConfig
|
|
import io.kotest.assertions.throwables.shouldThrow
|
|
import io.kotest.core.spec.style.FunSpec
|
|
import io.kotest.matchers.shouldBe
|
|
import io.kotest.matchers.string.shouldContain
|
|
import java.util.Base64
|
|
|
|
class AppConfigTest : FunSpec({
|
|
test("test configuration can be injected without Apple client credentials") {
|
|
val config = AppConfig.from(validConfig("test"))
|
|
|
|
config.environment shouldBe Environment.TEST
|
|
config.apple.clientCredentialsAvailable shouldBe false
|
|
config.encryption.key.size shouldBe 32
|
|
config.credits.signupTrial shouldBe 1_000
|
|
config.credits.referralInviter shouldBe 1_000
|
|
config.credits.referralInvitee shouldBe 1_000
|
|
}
|
|
|
|
test("production rejects placeholder secrets") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.session.secret", "replace-with-secret")
|
|
}
|
|
|
|
shouldThrow<ConfigValidationException> {
|
|
AppConfig.from(config)
|
|
}
|
|
}
|
|
|
|
test("production accepts complete independent configuration") {
|
|
val config = AppConfig.from(validProductionConfig())
|
|
|
|
config.environment shouldBe Environment.PRODUCTION
|
|
config.database.username shouldBe "test"
|
|
config.database.migrationUsername shouldBe "test_migrator"
|
|
}
|
|
|
|
test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.admin.enabled", "true")
|
|
put("app.admin.bootstrapEnabled", "true")
|
|
put("app.admin.bootstrapOperatorId", "2c031def-4517-4fde-b592-5db3a3eefdf6")
|
|
put("app.admin.bootstrapUsername", "owner")
|
|
put(
|
|
"app.admin.bootstrapPasswordHash",
|
|
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdHNhbHRzYWx0c2FsdA\$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA",
|
|
)
|
|
put("app.admin.bootstrapTotpSecretBase32", "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")
|
|
}
|
|
|
|
val admin = AppConfig.from(config).admin
|
|
admin.enabled shouldBe true
|
|
admin.bootstrapEnabled shouldBe true
|
|
}
|
|
|
|
test("production accepts established admin without bootstrap credentials") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.admin.enabled", "true")
|
|
put("app.admin.bootstrapEnabled", "false")
|
|
}
|
|
|
|
val admin = AppConfig.from(config).admin
|
|
|
|
admin.enabled shouldBe true
|
|
admin.bootstrapEnabled shouldBe false
|
|
admin.bootstrapTotpSecretBase32 shouldBe null
|
|
}
|
|
|
|
test("admin bootstrap cannot be enabled while admin routes are disabled") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.admin.enabled", "false")
|
|
put("app.admin.bootstrapEnabled", "true")
|
|
}
|
|
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(config)
|
|
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
|
|
}
|
|
|
|
test("StoreKit requires an app identifier and dedicated credit product when enabled") {
|
|
val missingAppId = validProductionConfig().apply {
|
|
put("app.storeKit.enabled", "true")
|
|
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
|
|
}
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(missingAppId)
|
|
}.message.orEmpty() shouldContain "appAppleId is required"
|
|
|
|
val enabled = validProductionConfig().apply {
|
|
put("app.storeKit.enabled", "true")
|
|
put("app.storeKit.appAppleId", "6781553267")
|
|
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
|
|
}
|
|
|
|
val storeKit = AppConfig.from(enabled).storeKit
|
|
storeKit.enabled shouldBe true
|
|
storeKit.appAppleId shouldBe 6_781_553_267
|
|
storeKit.products.map { it.productId to it.credits } shouldBe listOf(
|
|
"500tks" to 500,
|
|
"1500tks" to 1_500,
|
|
"3000tks" to 3_000,
|
|
)
|
|
}
|
|
|
|
test("StoreKit rejects duplicate product mappings") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.storeKit.enabled", "true")
|
|
put("app.storeKit.appAppleId", "6781553267")
|
|
put("app.storeKit.products", "500tks:500,500tks:3000")
|
|
}
|
|
|
|
shouldThrow<ConfigValidationException> {
|
|
AppConfig.from(config)
|
|
}.message.orEmpty() shouldContain "duplicate product IDs"
|
|
}
|
|
|
|
test("production fails fast when Apple signing credentials are missing") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.apple.keyId", "")
|
|
}
|
|
|
|
shouldThrow<ConfigValidationException> {
|
|
AppConfig.from(config)
|
|
}.message.orEmpty() shouldContain "app.apple.keyId"
|
|
}
|
|
|
|
test("production rejects monitor-only integrity configuration") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.providers.volcengine.apiKey", "volcengine-key")
|
|
put("app.providers.deepseek.apiKey", "deepseek-key")
|
|
put("app.integrity.enforceDeviceCheck", "false")
|
|
put("app.integrity.enforceAppAttest", "false")
|
|
}
|
|
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(config)
|
|
}.message.orEmpty() shouldContain "must enforce both DeviceCheck and App Attest"
|
|
}
|
|
|
|
test("production rejects provider endpoints outside the exact host allowlist") {
|
|
val config = validProductionConfig().apply {
|
|
put("app.providers.deepseek.endpoint", "https://127.0.0.1/v1")
|
|
}
|
|
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(config)
|
|
}.message.orEmpty() shouldContain "DeepSeek endpoint"
|
|
}
|
|
|
|
test("production requires separate migration credentials") {
|
|
val missingMigrator = validProductionConfig().apply {
|
|
put("app.database.migrationUsername", "")
|
|
}
|
|
shouldThrow<ConfigValidationException> {
|
|
AppConfig.from(missingMigrator)
|
|
}.message.orEmpty() shouldContain "app.database.migrationUsername"
|
|
|
|
val reusedPassword = validProductionConfig().apply {
|
|
put("app.database.migrationPassword", "database-password")
|
|
}
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(reusedPassword)
|
|
}.message.orEmpty() shouldContain "passwords must be distinct"
|
|
}
|
|
|
|
test("production requires independent cryptographic secrets") {
|
|
val config = validProductionConfig().apply {
|
|
put(
|
|
"app.antiAbuse.identityHmacKeyBase64",
|
|
Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
|
|
)
|
|
}
|
|
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(config)
|
|
}.message.orEmpty() shouldContain "must be distinct"
|
|
}
|
|
|
|
test("production requires exact public and App Store URLs") {
|
|
val publicUrl = validProductionConfig().apply {
|
|
put("app.publicBaseUrl", "https://account.osglab.com.evil.example")
|
|
}
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(publicUrl)
|
|
}.message.orEmpty() shouldContain "PUBLIC_BASE_URL"
|
|
|
|
val appStoreUrl = validProductionConfig().apply {
|
|
put("app.appStoreUrl", "https://apps.apple.com/app/id0000000000")
|
|
}
|
|
shouldThrow<IllegalArgumentException> {
|
|
AppConfig.from(appStoreUrl)
|
|
}.message.orEmpty() shouldContain "APP_STORE_URL"
|
|
}
|
|
})
|
|
|
|
private fun validConfig(environment: String) = MapApplicationConfig(
|
|
"app.environment" to environment,
|
|
"app.database.jdbcUrl" to "jdbc:mysql://localhost:3306/test",
|
|
"app.database.username" to "test",
|
|
"app.database.password" to "database-password",
|
|
"app.database.migrationUsername" to "test_migrator",
|
|
"app.database.migrationPassword" to "migration-password",
|
|
"app.database.maximumPoolSize" to "4",
|
|
"app.session.issuer" to "https://issuer.example",
|
|
"app.session.audience" to "ios-app",
|
|
"app.session.secret" to "01234567890123456789012345678901",
|
|
"app.session.accessMinutes" to "15",
|
|
"app.session.refreshDays" to "30",
|
|
"app.encryption.keyBase64" to Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
|
|
"app.antiAbuse.identityHmacKeyBase64" to
|
|
Base64.getEncoder().encodeToString(ByteArray(32) { 8 }),
|
|
"app.apple.clientId" to "com.example.app",
|
|
"app.apple.jwksUrl" to "https://appleid.apple.com/auth/keys",
|
|
"app.apple.tokenUrl" to "https://appleid.apple.com/auth/token",
|
|
"app.apple.revokeUrl" to "https://appleid.apple.com/auth/revoke",
|
|
"app.integrity.enforceDeviceCheck" to "false",
|
|
"app.integrity.enforceAppAttest" to "false",
|
|
)
|
|
|
|
private fun validProductionConfig() = validConfig("production").apply {
|
|
put("app.publicBaseUrl", "https://account.osglab.com")
|
|
put("app.inviteBaseUrl", "https://osglab.com/i")
|
|
put("app.appStoreUrl", "https://apps.apple.com/app/id1234567890")
|
|
put("app.apple.teamId", APP_ATTEST_TEAM_ID)
|
|
put("app.apple.keyId", "APPLE_KEY")
|
|
put("app.apple.clientId", APP_ATTEST_BUNDLE_ID)
|
|
put("app.apple.privateKeyPem", "private-key-material")
|
|
put("app.integrity.appleEnvironment", "production")
|
|
put("app.integrity.enforceDeviceCheck", "true")
|
|
put("app.integrity.enforceAppAttest", "true")
|
|
put("app.providers.volcengine.apiKey", "volcengine-key")
|
|
put("app.providers.deepseek.apiKey", "deepseek-key")
|
|
}
|