Android: send X-Kvitt-App app key on every server request (D-126)
The reverse proxy closes requests from outside the home LAN that lack X-Kvitt-App: <key>. The key is the build property kvitt.appKey (-Pkvitt.appKey, else android/local.properties, else empty = no header), validated as 16-128 printable ASCII without whitespace, exposed as BuildConfig.APP_KEY and passed via AppBuildInfo.appKey to KvittApi (every call, /health and other unauthenticated calls included) and the Coil picture client (PictureAuth). AppBuildInfo.toString hides the key. Tests use a made-up key; README documents the property. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -13,6 +13,16 @@ prefills (AC-2.7). Gradle takes `-Pkvitt.publicHost=…` first (`infra/publish-a
|
|||||||
placeholder `kvitt.example.invalid`, so a release built without it cannot talk to a real host. Debug builds pair with
|
placeholder `kvitt.example.invalid`, so a release built without it cannot talk to a real host. Debug builds pair with
|
||||||
the address typed on S-1. Tests and goldens never depend on it: they use fixed hosts.
|
the address typed on S-1. Tests and goldens never depend on it: they use fixed hosts.
|
||||||
|
|
||||||
|
**App key (D-126).** Outside the home LAN the reverse proxy closes every request that does not carry the header
|
||||||
|
`X-Kvitt-App: <key>`; the server never reads it, and the per-device bearer token stays the authentication. The key is
|
||||||
|
the build property `kvitt.appKey`: `:app` exposes it as `BuildConfig.APP_KEY`, passed through `AppBuildInfo.appKey` to
|
||||||
|
`KvittApi` and the picture client (`PictureAuth`), which send it on every request to the server, `/health` and the other
|
||||||
|
unauthenticated calls included. Gradle takes `-Pkvitt.appKey=…` first, else the key `kvitt.appKey` in
|
||||||
|
`android/local.properties`, where the owner sets it (git-ignored, never committed), else empty: no header, which is
|
||||||
|
what debug builds against the dev server need. A key that is set must be 16 to 128 printable ASCII characters without
|
||||||
|
whitespace, or the build fails (the message names its length, never the value). It is a secret: never logged, never in
|
||||||
|
a test, golden or report; tests use a made-up key.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| Module | Plugin (build-logic) | Notes |
|
| Module | Plugin (build-logic) | Notes |
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ require(Regex("[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+"
|
|||||||
"kvitt.publicHost must be a host name such as kvitt.duckdns.org (no scheme, port or path), was '$publicHost'"
|
"kvitt.publicHost must be a host name such as kvitt.duckdns.org (no scheme, port or path), was '$publicHost'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-126: the app key for the reverse proxy (SPEC §8.5, §14.3.1). Every request to the server carries X-Kvitt-App: <key>;
|
||||||
|
// outside the home LAN the proxy closes any request without it. From -Pkvitt.appKey, else the key kvitt.appKey in
|
||||||
|
// android/local.properties (the owner sets it there; never committed), else empty: no header (debug builds against the
|
||||||
|
// dev server need none). A secret: never logged, and the failure below names its length only, never its value.
|
||||||
|
val appKey: String =
|
||||||
|
providers.gradleProperty("kvitt.appKey").orNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: localProperties.getProperty("kvitt.appKey")?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: ""
|
||||||
|
require(appKey.isEmpty() || Regex("[!-~]{16,128}").matches(appKey)) {
|
||||||
|
"kvitt.appKey must be 16 to 128 printable ASCII characters without whitespace (D-126); the value given has " +
|
||||||
|
"${appKey.length} characters"
|
||||||
|
}
|
||||||
|
|
||||||
// SPEC §10.5.3 (D-082): the pinned NPM root (D-120) replaces the system anchors for kvitt.publicHost only when this is true.
|
// SPEC §10.5.3 (D-082): the pinned NPM root (D-120) replaces the system anchors for kvitt.publicHost only when this is true.
|
||||||
val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean()
|
val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean()
|
||||||
|
|
||||||
@@ -65,6 +78,7 @@ android {
|
|||||||
versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt()
|
versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt()
|
||||||
versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get()
|
versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get()
|
||||||
buildConfigField("String", "PUBLIC_HOST", "\"$publicHost\"")
|
buildConfigField("String", "PUBLIC_HOST", "\"$publicHost\"")
|
||||||
|
buildConfigField("String", "APP_KEY", "\"${appKey.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||||
manifestPlaceholders["publicHost"] = publicHost
|
manifestPlaceholders["publicHost"] = publicHost
|
||||||
}
|
}
|
||||||
// SPEC G-8 and AC-19.1: the app is English (fallback) and Swedish only; library translations of other languages
|
// SPEC G-8 and AC-19.1: the app is English (fallback) and Swedish only; library translations of other languages
|
||||||
@@ -72,7 +86,8 @@ android {
|
|||||||
androidResources {
|
androidResources {
|
||||||
localeFilters += listOf("en", "sv")
|
localeFilters += listOf("en", "sv")
|
||||||
}
|
}
|
||||||
// BuildConfig carries versionCode, versionName and DEBUG into AppBuildInfo (:data, D-041), and PUBLIC_HOST (D-121).
|
// BuildConfig carries versionCode, versionName, DEBUG and APP_KEY (D-126) into AppBuildInfo (:data, D-041), and
|
||||||
|
// PUBLIC_HOST (D-121).
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import se.hagstedt.kvitt.link.RELEASE_SERVER_URL
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Both variants: the app's version (§10.6), build type and release server ([RELEASE_SERVER_URL], D-121, D-122) for
|
* Both variants: the app's version (§10.6), build type, release server ([RELEASE_SERVER_URL], D-121, D-122) and app key
|
||||||
|
* for the reverse proxy (`BuildConfig.APP_KEY` from `kvitt.appKey`, empty when the build has none, D-126) for
|
||||||
* `:data`'s `DataModule`, which Hilt installs in debug and release alike (it binds no `:core:model` interface, D-041).
|
* `:data`'s `DataModule`, which Hilt installs in debug and release alike (it binds no `:core:model` interface, D-041).
|
||||||
*/
|
*/
|
||||||
@Module
|
@Module
|
||||||
@@ -22,5 +23,6 @@ object AppBuildInfoModule {
|
|||||||
versionName = BuildConfig.VERSION_NAME,
|
versionName = BuildConfig.VERSION_NAME,
|
||||||
debug = BuildConfig.DEBUG,
|
debug = BuildConfig.DEBUG,
|
||||||
releaseServerUrl = RELEASE_SERVER_URL,
|
releaseServerUrl = RELEASE_SERVER_URL,
|
||||||
|
appKey = BuildConfig.APP_KEY,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,19 @@ interface ApiClientInfo {
|
|||||||
/** The `versionName`, for `X-Kvitt-App-Version-Name` (schema 3, S-20). */
|
/** The `versionName`, for `X-Kvitt-App-Version-Name` (schema 3, S-20). */
|
||||||
val appVersionName: String
|
val appVersionName: String
|
||||||
|
|
||||||
|
/** The app key for the reverse proxy, for `X-Kvitt-App` (D-126); empty sends no header. A secret: never logged. */
|
||||||
|
val appKey: String
|
||||||
|
|
||||||
/** The device locale as a BCP 47 tag, for `Accept-Language`. */
|
/** The device locale as a BCP 47 tag, for `Accept-Language`. */
|
||||||
fun acceptLanguage(): String
|
fun acceptLanguage(): String
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Ktor client of every endpoint the app calls (§14.3.4). Every request carries `X-Kvitt-Protocol: 1`,
|
* The Ktor client of every endpoint the app calls (§14.3.4). Every request carries `X-Kvitt-Protocol: 1`,
|
||||||
* `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` and `Accept-Language`, and authenticated calls `Authorization: Bearer` (§14.3.1). The token is
|
* `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` and `Accept-Language`, `X-Kvitt-App` when the build has an app key
|
||||||
* only ever in that header, never in a URL or a log (§8.2). Errors map to the §14.3.2 codes; 401 and 426 are reported
|
* (D-126: the reverse proxy's gate, which the server never reads), and authenticated calls `Authorization: Bearer`
|
||||||
* to [events] so the session and the sync engine react in one place.
|
* (§14.3.1). The token and the app key are only ever in their headers, never in a URL or a log (§8.2). Errors map to
|
||||||
|
* the §14.3.2 codes; 401 and 426 are reported to [events] so the session and the sync engine react in one place.
|
||||||
*/
|
*/
|
||||||
class KvittApi(
|
class KvittApi(
|
||||||
private val http: HttpClient,
|
private val http: HttpClient,
|
||||||
@@ -188,6 +192,7 @@ class KvittApi(
|
|||||||
header(APP_VERSION_HEADER, info.appVersionCode.toString())
|
header(APP_VERSION_HEADER, info.appVersionCode.toString())
|
||||||
versionNameHeader(info.appVersionName)?.let { header(APP_VERSION_NAME_HEADER, it) }
|
versionNameHeader(info.appVersionName)?.let { header(APP_VERSION_NAME_HEADER, it) }
|
||||||
header(HttpHeaders.AcceptLanguage, info.acceptLanguage())
|
header(HttpHeaders.AcceptLanguage, info.acceptLanguage())
|
||||||
|
appKeyHeader(info.appKey)?.let { header(APP_KEY_HEADER, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun failure(response: HttpResponse, authenticated: Boolean): ApiResult.Failure {
|
private suspend fun failure(response: HttpResponse, authenticated: Boolean): ApiResult.Failure {
|
||||||
@@ -243,6 +248,12 @@ class KvittApi(
|
|||||||
fun versionNameHeader(name: String): String? =
|
fun versionNameHeader(name: String): String? =
|
||||||
name.trim().takeIf { it.length in 1..VERSION_NAME_MAX && it.all { c -> c in ' '..'~' } }
|
name.trim().takeIf { it.length in 1..VERSION_NAME_MAX && it.all { c -> c in ' '..'~' } }
|
||||||
const val SERVER_VERSION_HEADER = "X-Kvitt-Server-Version"
|
const val SERVER_VERSION_HEADER = "X-Kvitt-Server-Version"
|
||||||
|
|
||||||
|
/** The reverse proxy's gate (D-126); the server never reads it and it is not in `contracts/openapi.yaml`. */
|
||||||
|
const val APP_KEY_HEADER = "X-Kvitt-App"
|
||||||
|
|
||||||
|
/** The header value, or null when the build has no app key (empty: the header is not sent). */
|
||||||
|
fun appKeyHeader(key: String): String? = key.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,29 @@ import io.ktor.client.plugins.api.ClientPlugin
|
|||||||
import io.ktor.client.plugins.api.createClientPlugin
|
import io.ktor.client.plugins.api.createClientPlugin
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
|
|
||||||
/** The Coil client's plugin: the §14.3.1 headers and the bearer token, only on requests to the paired server. */
|
/**
|
||||||
|
* The Coil client's plugin: the §14.3.1 headers, the app key (`X-Kvitt-App`, D-126, when the build has one) and the
|
||||||
|
* bearer token, only on requests to the paired server.
|
||||||
|
*/
|
||||||
class PictureAuthConfig {
|
class PictureAuthConfig {
|
||||||
var target: suspend () -> ApiTarget? = { null }
|
var target: suspend () -> ApiTarget? = { null }
|
||||||
var appVersionCode: Int = 0
|
var appVersionCode: Int = 0
|
||||||
var appVersionName: String = ""
|
var appVersionName: String = ""
|
||||||
|
var appKey: String = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val PictureAuth: ClientPlugin<PictureAuthConfig> = createClientPlugin("KvittPictureAuth", ::PictureAuthConfig) {
|
val PictureAuth: ClientPlugin<PictureAuthConfig> = createClientPlugin("KvittPictureAuth", ::PictureAuthConfig) {
|
||||||
val target = pluginConfig.target
|
val target = pluginConfig.target
|
||||||
val versionCode = pluginConfig.appVersionCode
|
val versionCode = pluginConfig.appVersionCode
|
||||||
val versionName = KvittApi.versionNameHeader(pluginConfig.appVersionName)
|
val versionName = KvittApi.versionNameHeader(pluginConfig.appVersionName)
|
||||||
|
val appKey = KvittApi.appKeyHeader(pluginConfig.appKey)
|
||||||
onRequest { request, _ ->
|
onRequest { request, _ ->
|
||||||
val t = target() ?: return@onRequest
|
val t = target() ?: return@onRequest
|
||||||
if (!request.url.buildString().startsWith(t.baseUrl.trimEnd('/') + "/")) return@onRequest
|
if (!request.url.buildString().startsWith(t.baseUrl.trimEnd('/') + "/")) return@onRequest
|
||||||
request.headers.append(KvittApi.PROTOCOL_HEADER, KvittApi.PROTOCOL_VERSION)
|
request.headers.append(KvittApi.PROTOCOL_HEADER, KvittApi.PROTOCOL_VERSION)
|
||||||
request.headers.append(KvittApi.APP_VERSION_HEADER, versionCode.toString())
|
request.headers.append(KvittApi.APP_VERSION_HEADER, versionCode.toString())
|
||||||
versionName?.let { request.headers.append(KvittApi.APP_VERSION_NAME_HEADER, it) }
|
versionName?.let { request.headers.append(KvittApi.APP_VERSION_NAME_HEADER, it) }
|
||||||
|
appKey?.let { request.headers.append(KvittApi.APP_KEY_HEADER, it) }
|
||||||
t.token?.let { request.headers.append(HttpHeaders.Authorization, "Bearer $it") }
|
t.token?.let { request.headers.append(HttpHeaders.Authorization, "Bearer $it") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,10 +88,22 @@ import javax.inject.Qualifier
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What `:app` provides: its version (§10.6), build type (AC-2.8, AC-16.5) and release server `https://<host>`
|
* What `:app` provides: its version (§10.6), build type (AC-2.8, AC-16.5), release server `https://<host>`
|
||||||
* (`RELEASE_SERVER_URL`, from the Gradle property `kvitt.publicHost`, D-121), which S-22 shows before pairing (D-122).
|
* (`RELEASE_SERVER_URL`, from the Gradle property `kvitt.publicHost`, D-121), which S-22 shows before pairing (D-122),
|
||||||
|
* and the app key of the reverse proxy (`BuildConfig.APP_KEY`, from the Gradle property `kvitt.appKey`; empty when the
|
||||||
|
* build has none, D-126). The key is a secret: [toString] leaves it out.
|
||||||
*/
|
*/
|
||||||
data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean, val releaseServerUrl: String)
|
data class AppBuildInfo(
|
||||||
|
val versionCode: Int,
|
||||||
|
val versionName: String,
|
||||||
|
val debug: Boolean,
|
||||||
|
val releaseServerUrl: String,
|
||||||
|
val appKey: String,
|
||||||
|
) {
|
||||||
|
override fun toString(): String =
|
||||||
|
"AppBuildInfo(versionCode=$versionCode, versionName=$versionName, debug=$debug, releaseServerUrl=$releaseServerUrl, " +
|
||||||
|
"appKey=${if (appKey.isEmpty()) "none" else "set"})"
|
||||||
|
}
|
||||||
|
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
@@ -157,6 +169,7 @@ object DataModule {
|
|||||||
target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } }
|
target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } }
|
||||||
appVersionCode = build.versionCode
|
appVersionCode = build.versionCode
|
||||||
appVersionName = build.versionName
|
appVersionName = build.versionName
|
||||||
|
appKey = build.appKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Pictures.imageLoader(context, client) { base.value }
|
return Pictures.imageLoader(context, client) { base.value }
|
||||||
@@ -182,6 +195,7 @@ object DataModule {
|
|||||||
info = object : ApiClientInfo {
|
info = object : ApiClientInfo {
|
||||||
override val appVersionCode: Int = build.versionCode
|
override val appVersionCode: Int = build.versionCode
|
||||||
override val appVersionName: String = build.versionName
|
override val appVersionName: String = build.versionName
|
||||||
|
override val appKey: String = build.appKey
|
||||||
override fun acceptLanguage(): String = Locale.getDefault().toLanguageTag()
|
override fun acceptLanguage(): String = Locale.getDefault().toLanguageTag()
|
||||||
},
|
},
|
||||||
events = session,
|
events = session,
|
||||||
|
|||||||
@@ -108,9 +108,10 @@ class FakeSyncWork : SyncWork {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything `:data` needs, on an in-memory Room database (framework driver under Robolectric), DataStore files in a
|
* Everything `:data` needs, on an in-memory Room database (framework driver under Robolectric), DataStore files in a
|
||||||
* temporary directory and a [FakeServer] behind Ktor's MockEngine.
|
* temporary directory and a [FakeServer] behind Ktor's MockEngine. [appKey] is the build's app key (D-126); empty is a
|
||||||
|
* build without one.
|
||||||
*/
|
*/
|
||||||
class Harness {
|
class Harness(val appKey: String = APP_KEY) {
|
||||||
val clock = TestClock()
|
val clock = TestClock()
|
||||||
val ids = SequentialIds()
|
val ids = SequentialIds()
|
||||||
val server = FakeServer(clock)
|
val server = FakeServer(clock)
|
||||||
@@ -145,6 +146,7 @@ class Harness {
|
|||||||
object : ApiClientInfo {
|
object : ApiClientInfo {
|
||||||
override val appVersionCode = APP_VERSION
|
override val appVersionCode = APP_VERSION
|
||||||
override val appVersionName = APP_VERSION_NAME
|
override val appVersionName = APP_VERSION_NAME
|
||||||
|
override val appKey = this@Harness.appKey
|
||||||
override fun acceptLanguage() = "sv-SE"
|
override fun acceptLanguage() = "sv-SE"
|
||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
@@ -188,6 +190,9 @@ class Harness {
|
|||||||
companion object {
|
companion object {
|
||||||
const val APP_VERSION = 41
|
const val APP_VERSION = 41
|
||||||
const val APP_VERSION_NAME = "1.1"
|
const val APP_VERSION_NAME = "1.1"
|
||||||
|
|
||||||
|
/** A made-up app key (D-126); never a real one. */
|
||||||
|
const val APP_KEY = "test-app-key-0123456789"
|
||||||
const val BASE_URL = "https://kvitt.test"
|
const val BASE_URL = "https://kvitt.test"
|
||||||
const val TOKEN = "kvt_testtoken0000000000000000000000000000000000"
|
const val TOKEN = "kvt_testtoken0000000000000000000000000000000000"
|
||||||
const val ME = "0192a000-0000-7000-8000-000000000001"
|
const val ME = "0192a000-0000-7000-8000-000000000001"
|
||||||
|
|||||||
@@ -42,11 +42,51 @@ class KvittApiTest {
|
|||||||
assertEquals(Harness.APP_VERSION.toString(), r.headers[KvittApi.APP_VERSION_HEADER])
|
assertEquals(Harness.APP_VERSION.toString(), r.headers[KvittApi.APP_VERSION_HEADER])
|
||||||
assertEquals(Harness.APP_VERSION_NAME, r.headers[KvittApi.APP_VERSION_NAME_HEADER])
|
assertEquals(Harness.APP_VERSION_NAME, r.headers[KvittApi.APP_VERSION_NAME_HEADER])
|
||||||
assertEquals("sv-SE", r.headers[HttpHeaders.AcceptLanguage])
|
assertEquals("sv-SE", r.headers[HttpHeaders.AcceptLanguage])
|
||||||
|
assertEquals(Harness.APP_KEY, r.headers[KvittApi.APP_KEY_HEADER])
|
||||||
assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization])
|
assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization])
|
||||||
assertFalse("the token is never in a URL", r.url.toString().contains(Harness.TOKEN))
|
assertFalse("the token is never in a URL", r.url.toString().contains(Harness.TOKEN))
|
||||||
|
assertFalse("the app key is never in a URL", r.url.toString().contains(Harness.APP_KEY))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unauthenticatedCallsCarryTheAppKey() = runSync {
|
||||||
|
h.api.health()
|
||||||
|
h.api.apkLatest()
|
||||||
|
h.api.acceptInvite("https://other.test", AcceptInviteRequest("7K3MQX9D2HPA", "Galaxy", null))
|
||||||
|
assertEquals(3, h.requests.size)
|
||||||
|
for (r in h.requests) {
|
||||||
|
assertNull(r.headers[HttpHeaders.Authorization])
|
||||||
|
assertEquals(Harness.APP_KEY, r.headers[KvittApi.APP_KEY_HEADER])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBuildWithoutAnAppKeySendsNoAppKeyHeader() = runSync {
|
||||||
|
val bare = Harness(appKey = "").ready()
|
||||||
|
try {
|
||||||
|
bare.addExpense("ICA")
|
||||||
|
bare.engine.sync()
|
||||||
|
bare.api.health()
|
||||||
|
bare.api.acceptInvite("https://other.test", AcceptInviteRequest("7K3MQX9D2HPA", "Galaxy", null))
|
||||||
|
assertTrue(bare.requests.size >= 4)
|
||||||
|
assertTrue(bare.requests.any { it.headers[HttpHeaders.Authorization] != null })
|
||||||
|
assertTrue(bare.requests.any { it.headers[HttpHeaders.Authorization] == null })
|
||||||
|
for (r in bare.requests) {
|
||||||
|
assertFalse(r.headers.contains(KvittApi.APP_KEY_HEADER))
|
||||||
|
assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bare.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appKeyHeaderIsSentOnlyWhenTheBuildHasAKey() {
|
||||||
|
assertEquals(Harness.APP_KEY, KvittApi.appKeyHeader(Harness.APP_KEY))
|
||||||
|
assertNull(KvittApi.appKeyHeader(""))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun versionNameHeaderIsSentOnlyWhenTheServerWouldStoreIt() {
|
fun versionNameHeaderIsSentOnlyWhenTheServerWouldStoreIt() {
|
||||||
assertEquals("1.1", KvittApi.versionNameHeader(" 1.1 "))
|
assertEquals("1.1", KvittApi.versionNameHeader(" 1.1 "))
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package se.hagstedt.kvitt.data.api
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.mock.MockEngine
|
||||||
|
import io.ktor.client.engine.mock.respondOk
|
||||||
|
import io.ktor.client.request.HttpRequestData
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import se.hagstedt.kvitt.data.Harness
|
||||||
|
import se.hagstedt.kvitt.data.runSync
|
||||||
|
|
||||||
|
/** The Coil client's headers (§14.3.1, D-126): only on requests to the paired server. */
|
||||||
|
class PictureAuthTest {
|
||||||
|
private val requests = mutableListOf<HttpRequestData>()
|
||||||
|
|
||||||
|
private val engine = MockEngine { request ->
|
||||||
|
requests += request
|
||||||
|
respondOk()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(appKey: String) = HttpClient(engine) {
|
||||||
|
install(PictureAuth) {
|
||||||
|
target = { ApiTarget(Harness.BASE_URL, Harness.TOKEN) }
|
||||||
|
appVersionCode = Harness.APP_VERSION
|
||||||
|
appVersionName = Harness.APP_VERSION_NAME
|
||||||
|
this.appKey = appKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun picturesFromThePairedServerCarryTheAppKeyAndTheToken() = runSync {
|
||||||
|
client(Harness.APP_KEY).get("${Harness.BASE_URL}/users/${Harness.ME}/picture")
|
||||||
|
val r = requests.single()
|
||||||
|
assertEquals(Harness.APP_KEY, r.headers[KvittApi.APP_KEY_HEADER])
|
||||||
|
assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER])
|
||||||
|
assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBuildWithoutAnAppKeySendsNoAppKeyHeader() = runSync {
|
||||||
|
client("").get("${Harness.BASE_URL}/users/${Harness.ME}/picture")
|
||||||
|
val r = requests.single()
|
||||||
|
assertFalse(r.headers.contains(KvittApi.APP_KEY_HEADER))
|
||||||
|
assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun otherHostsGetNeitherTheAppKeyNorTheToken() = runSync {
|
||||||
|
client(Harness.APP_KEY).get("https://elsewhere.test/picture.jpg")
|
||||||
|
val r = requests.single()
|
||||||
|
assertNull(r.headers[KvittApi.APP_KEY_HEADER])
|
||||||
|
assertNull(r.headers[HttpHeaders.Authorization])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user