feat(server): add POST /api/admin/version endpoint for APK deploy without restart

- Add VersionStore that persists versionCode/versionName to data/version.json
- Add POST /api/admin/version secured by BOLLWERK_ADMIN_TOKEN bearer auth
- GET /api/version now reads from VersionStore (fallback to env-vars)
- Update publish-apk.ps1 to use API call instead of SSH+sed+restart
- Update publish SKILL.md and vps-deploy SKILL.md documentation

Closes #100
This commit is contained in:
Jens Reinemann 2026-05-18 08:40:31 +02:00
parent dad15b9e94
commit 01a6d911ec
10 changed files with 325 additions and 91 deletions

View file

@ -32,10 +32,17 @@ scp → /opt/bollwerk/data/ └── GET /static/* → Dateie
2. `CheckForUpdateUseCase` vergleicht `versionInfo.versionCode > BuildConfig.VERSION_CODE`
3. Falls neuer → zeigt Download-Button, lädt APK herunter, installiert via `ApkInstaller`
### Wie die Version gesetzt wird
- `POST /api/admin/version` mit `Authorization: Bearer <BOLLWERK_ADMIN_TOKEN>` Header
- Server speichert `versionCode` + `versionName` persistent in `data/version.json`
- `GET /api/version` liest aus `VersionStore` (Fallback auf Env-Vars, falls Datei fehlt)
- **Kein Container-Neustart nötig!**
### Wie die Homepage funktioniert
- `GET /` liefert HTML mit QR-Code (via qrcodejs) + Download-Link auf `/static/app-latest.apk`
- Version wird aus `BOLLWERK_APP_VERSION_CODE` / `BOLLWERK_APP_VERSION_NAME` Env-Vars gelesen
- Version wird aus `VersionStore` gelesen (persistent in `data/version.json`)
---
@ -64,28 +71,31 @@ scp app/build/outputs/apk/debug/app-debug.apk root@195.246.231.210:/opt/bollwerk
**Voraussetzung:** SSH-Agent muss laufen und Key geladen sein (siehe vps-deploy Skill).
### Schritt 4 Server-Version aktualisieren
### Schritt 4 Server über neue Version benachrichtigen
Die Version wird über Environment-Variablen in der `docker-compose.yml` auf dem VPS gesetzt:
Die Version wird per API-Call gesetzt (kein Container-Neustart nötig):
```powershell
# Neue Werte per sed in docker-compose.yml eintragen
ssh root@195.246.231.210 "cd /opt/bollwerk && sed -i 's/BOLLWERK_APP_VERSION_CODE=.*/BOLLWERK_APP_VERSION_CODE=<neuer_code>/' docker-compose.yml && sed -i 's/BOLLWERK_APP_VERSION_NAME=.*/BOLLWERK_APP_VERSION_NAME=<neuer_name>/' docker-compose.yml"
$body = @{ versionCode = $CODE; versionName = $NAME } | ConvertTo-Json -Compress
Invoke-WebRequest -Uri "https://bollwerk.online/api/admin/version" `
-Method POST `
-Headers @{ "Authorization" = "Bearer $env:BOLLWERK_ADMIN_TOKEN"; "Content-Type" = "application/json" } `
-Body $body `
-UseBasicParsing
```
Falls die Env-Vars noch nicht in der docker-compose.yml stehen, müssen sie einmalig hinzugefügt werden:
Alternativ mit curl:
```powershell
ssh root@195.246.231.210 "cd /opt/bollwerk && sed -i '/BOLLWERK_JWT_SECRET/a\ - BOLLWERK_APP_VERSION_CODE=<code>\n - BOLLWERK_APP_VERSION_NAME=<name>' docker-compose.yml"
```bash
curl -s -X POST https://bollwerk.online/api/admin/version \
-H "Authorization: Bearer $BOLLWERK_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"versionCode\": $CODE, \"versionName\": \"$NAME\"}"
```
### Schritt 5 Server neustarten
**Voraussetzung:** Die Umgebungsvariable `BOLLWERK_ADMIN_TOKEN` muss gesetzt sein. Das Token muss mit dem `BOLLWERK_ADMIN_TOKEN` auf dem VPS übereinstimmen.
```powershell
ssh root@195.246.231.210 "cd /opt/bollwerk && docker compose up -d"
```
### Schritt 6 Verifizieren
### Schritt 5 Verifizieren
```powershell
# Version-API prüfen
@ -95,22 +105,21 @@ Invoke-WebRequest -Uri "https://bollwerk.online/api/version" -UseBasicParsing |
Invoke-WebRequest -Uri "https://bollwerk.online/" -UseBasicParsing | Select-Object StatusCode, StatusDescription
```
Erwartete Ausgabe von `/api/version`:
```json
{"versionCode":<neuer_code>,"versionName":"<neuer_name>","apkUrl":"https://bollwerk.online/static/app-latest.apk"}
```
---
## Automatisiertes Skript
Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 36:
Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 35:
```powershell
& ".github/skills/publish/publish-apk.ps1" -VersionCode 4 -VersionName "1.3"
```
**Voraussetzungen:**
- SSH-Agent muss laufen (für APK-Upload per SCP)
- `$env:BOLLWERK_ADMIN_TOKEN` muss gesetzt sein (für Version-Notify API-Call)
**Parameter:**
| Parameter | Pflicht | Beschreibung |
@ -127,16 +136,26 @@ Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 36
| Datei | Beschreibung |
| -------------------------------------------------------- | ------------------------------------------ |
| `app/build.gradle.kts` (L18-19) | `versionCode` / `versionName` |
| `server/src/main/resources/application.conf` (L18-21) | Server-Version-Defaults + Env-Var-Override |
| `server/src/main/kotlin/.../routes/VersionRoutes.kt` | Homepage + `/api/version` Endpoint |
| `server/src/main/resources/application.conf` | Server-Version-Defaults + Env-Var-Override |
| `server/src/main/kotlin/.../store/VersionStore.kt` | Persistente Version in `data/version.json` |
| `server/src/main/kotlin/.../routes/VersionRoutes.kt` | Homepage + `/api/version` + POST-Endpoint |
| `server/src/main/kotlin/.../plugins/Routing.kt` | `staticFiles("/static", File("data"))` |
| `app/src/main/java/.../usecase/CheckForUpdateUseCase.kt` | Update-Prüfung in der App |
| `app/src/main/java/.../ui/update/UpdateViewModel.kt` | Update-UI + Download-Logik |
---
## Environment-Variablen (VPS)
| Variable | Pflicht | Beschreibung |
| ------------------------------- | ------- | --------------------------------------------------------- |
| `BOLLWERK_ADMIN_TOKEN` | ja | Bearer-Token für `POST /api/admin/version` (min. 32 Zeichen) |
| `BOLLWERK_APP_VERSION_CODE` | nein | Fallback-VersionCode (nur wenn `data/version.json` fehlt) |
| `BOLLWERK_APP_VERSION_NAME` | nein | Fallback-VersionName (nur wenn `data/version.json` fehlt) |
---
## Bekannte Lücken / TODOs
- **Kein Release-Build:** Aktuell wird `assembleDebug` verwendet. Für Produktion → Signing-Config + `assembleRelease` einrichten.
- **Kein HTTPS:** Server läuft auf HTTP. QR-Code zeigt `http://`-URL. Für Play Store / Sicherheit → Caddy als Reverse Proxy.
- **Manuelle Version-Env-Vars:** Beim ersten Publish müssen die Env-Vars in die VPS docker-compose eingefügt werden. Das Skript erledigt das automatisch.
- **Env-Vars optional:** `BOLLWERK_APP_VERSION_CODE` / `BOLLWERK_APP_VERSION_NAME` können aus der docker-compose.yml entfernt werden, sobald `data/version.json` initial gesetzt ist.

View file

@ -2,8 +2,8 @@
.SYNOPSIS
Publiziert eine APK auf den Bollwerk VPS.
.DESCRIPTION
Lädt die APK auf den VPS hoch, aktualisiert die Version in der
docker-compose.yml und startet den Server-Container neu.
Lädt die APK auf den VPS hoch und benachrichtigt den Server über die neue
Version via API-Endpoint (kein Container-Neustart nötig).
.PARAMETER VersionCode
Neuer versionCode (Integer, muss > aktueller sein).
.PARAMETER VersionName
@ -23,6 +23,14 @@ param(
$ErrorActionPreference = "Stop"
$VPS = "root@195.246.231.210"
$RemoteDir = "/opt/bollwerk"
$ServerUrl = "https://bollwerk.online"
# --- Admin-Token laden ---
$AdminToken = $env:BOLLWERK_ADMIN_TOKEN
if (-not $AdminToken) {
Write-Error "Umgebungsvariable BOLLWERK_ADMIN_TOKEN ist nicht gesetzt. Bitte setzen: `$env:BOLLWERK_ADMIN_TOKEN = 'dein-token'"
exit 1
}
# --- Preflight ---
Write-Host "=== Publish APK v$VersionName (build $VersionCode) ===" -ForegroundColor Cyan
@ -41,60 +49,58 @@ if ($LASTEXITCODE -ne 0) {
Write-Host "[OK] SSH-Agent aktiv" -ForegroundColor Green
# --- Schritt 1: APK hochladen ---
Write-Host "`n[1/4] APK hochladen..." -ForegroundColor Yellow
Write-Host "`n[1/3] APK hochladen..." -ForegroundColor Yellow
ssh $VPS "mkdir -p $RemoteDir/data"
scp $ApkPath "${VPS}:${RemoteDir}/data/app-latest.apk"
if ($LASTEXITCODE -ne 0) { Write-Error "APK-Upload fehlgeschlagen."; exit 1 }
Write-Host "[OK] APK hochgeladen" -ForegroundColor Green
# --- Schritt 2: Version in docker-compose.yml aktualisieren ---
Write-Host "`n[2/4] Version in docker-compose.yml aktualisieren..." -ForegroundColor Yellow
# --- Schritt 2: Server über neue Version benachrichtigen ---
Write-Host "`n[2/3] Server-Version aktualisieren (API-Call)..." -ForegroundColor Yellow
# Prüfen ob Env-Vars bereits vorhanden sind (bash-Syntax in einfachen Anführungszeichen)
$checkCmd = 'grep -c "BOLLWERK_APP_VERSION_CODE" ' + $RemoteDir + '/docker-compose.yml 2>/dev/null || echo 0'
$checkResult = ssh $VPS $checkCmd
# SSH kann Array zurückgeben (z.B. Banner + Ergebnis) - letzte Zeile nehmen
if ($checkResult -is [array]) { $checkResult = $checkResult[-1] }
$hasVersionCode = [int]($checkResult.Trim())
if ($hasVersionCode -gt 0) {
# Update bestehende Einträge
$sedCmd = "cd $RemoteDir; sed -i 's/BOLLWERK_APP_VERSION_CODE=.*/BOLLWERK_APP_VERSION_CODE=$VersionCode/' docker-compose.yml; sed -i 's/BOLLWERK_APP_VERSION_NAME=.*/BOLLWERK_APP_VERSION_NAME=$VersionName/' docker-compose.yml"
ssh $VPS $sedCmd
} else {
# Erstmalig hinzufügen (nach JWT_SECRET-Zeile)
$addCmd = "cd $RemoteDir; sed -i '/BOLLWERK_JWT_SECRET/a\ - BOLLWERK_APP_VERSION_CODE=$VersionCode' docker-compose.yml; sed -i '/BOLLWERK_APP_VERSION_CODE/a\ - BOLLWERK_APP_VERSION_NAME=$VersionName' docker-compose.yml"
ssh $VPS $addCmd
$body = @{ versionCode = $VersionCode; versionName = $VersionName } | ConvertTo-Json -Compress
$headers = @{
"Authorization" = "Bearer $AdminToken"
"Content-Type" = "application/json"
}
try {
$response = Invoke-WebRequest -Uri "$ServerUrl/api/admin/version" `
-Method POST `
-Headers $headers `
-Body $body `
-UseBasicParsing
if ($response.StatusCode -ne 200) {
Write-Error "Version-Update fehlgeschlagen: HTTP $($response.StatusCode)"
exit 1
}
} catch {
Write-Error "Version-Update fehlgeschlagen: $_"
exit 1
}
if ($LASTEXITCODE -ne 0) { Write-Error "Version-Update fehlgeschlagen."; exit 1 }
Write-Host "[OK] Version gesetzt: $VersionName (build $VersionCode)" -ForegroundColor Green
# --- Schritt 3: Server neustarten ---
Write-Host "`n[3/4] Server-Container neustarten..." -ForegroundColor Yellow
ssh $VPS "cd $RemoteDir; docker compose up -d"
if ($LASTEXITCODE -ne 0) { Write-Error "Container-Neustart fehlgeschlagen."; exit 1 }
Write-Host "[OK] Container neu gestartet" -ForegroundColor Green
# --- Schritt 4: Verifizieren ---
# --- Schritt 3: Verifizieren ---
if ($SkipVerify) {
Write-Host "`n[4/4] Verifizierung uebersprungen." -ForegroundColor DarkGray
Write-Host "`n[3/3] Verifizierung uebersprungen." -ForegroundColor DarkGray
} else {
Write-Host "`n[4/4] Verifizieren (warte 5s auf Server-Start)..." -ForegroundColor Yellow
Start-Sleep -Seconds 5
Write-Host "`n[3/3] Verifizieren..." -ForegroundColor Yellow
Start-Sleep -Seconds 2
try {
$response = Invoke-WebRequest -Uri "https://bollwerk.online/api/version" -UseBasicParsing -TimeoutSec 10
$versionJson = $response.Content | ConvertFrom-Json
$versionResponse = Invoke-WebRequest -Uri "$ServerUrl/api/version" -UseBasicParsing
$versionJson = $versionResponse.Content | ConvertFrom-Json
if ($versionJson.versionCode -eq $VersionCode -and $versionJson.versionName -eq $VersionName) {
Write-Host "[OK] Server meldet Version $($versionJson.versionName) (build $($versionJson.versionCode))" -ForegroundColor Green
Write-Host (" APK-URL: " + $versionJson.apkUrl) -ForegroundColor DarkGray
} else {
Write-Warning ("Server meldet unerwartete Version: " + ($versionJson | ConvertTo-Json -Compress))
}
} catch {
Write-Warning "Verifizierung fehlgeschlagen: $_"
Write-Host "Manuell pruefen: https://bollwerk.online/api/version" -ForegroundColor DarkGray
if ($versionJson.versionCode -eq $VersionCode -and $versionJson.versionName -eq $VersionName) {
Write-Host "[OK] /api/version: versionCode=$($versionJson.versionCode), versionName=$($versionJson.versionName)" -ForegroundColor Green
} else {
Write-Warning "Version stimmt nicht ueberein! Erwartet: $VersionCode/$VersionName, Erhalten: $($versionJson.versionCode)/$($versionJson.versionName)"
}
$homepageResponse = Invoke-WebRequest -Uri "$ServerUrl/" -UseBasicParsing
if ($homepageResponse.StatusCode -eq 200) {
Write-Host "[OK] Homepage erreichbar (HTTP $($homepageResponse.StatusCode))" -ForegroundColor Green
} else {
Write-Warning "Homepage liefert HTTP $($homepageResponse.StatusCode)"
}
}

View file

@ -137,25 +137,28 @@ Der Server nutzt JWT-basierte Authentifizierung (kein API-Key mehr).
| Variable | Pflicht | Beschreibung |
| ------------------------- | ------- | -------------------------------------------------- |
| `BOLLWERK_JWT_SECRET` | ja | Secret für JWT-Token-Signierung (mind. 32 Zeichen) |
| `BOLLWERK_ADMIN_TOKEN` | ja | Bearer-Token für POST /api/admin/version (mind. 32 Zeichen) |
| `BOLLWERK_ADMIN_PASSWORD` | nein | Admin-Passwort beim ersten Start (sonst auto-gen.) |
---
## Server-Endpunkte
| Endpunkt | Auth | Beschreibung |
| --------------------------------- | ----- | ------------------------------------- |
| `GET /api/health` | nein | Health-Check → "OK" |
| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) |
| `POST /api/auth/refresh` | nein | Access-Token erneuern |
| `GET /api/inventory` | JWT | Inventar des Users abrufen |
| `PUT /api/inventory` | JWT | Inventar des Users hochladen |
| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten |
| `GET /api/admin/users` | Admin | Alle User auflisten |
| `POST /api/admin/users` | Admin | Neuen User anlegen |
| `PUT /api/admin/users/{id}` | Admin | Passwort ändern |
| `DELETE /api/admin/users/{id}` | Admin | User löschen |
| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen |
| Endpunkt | Auth | Beschreibung |
| --------------------------------- | ------------ | ------------------------------------- |
| `GET /api/health` | nein | Health-Check → "OK" |
| `GET /api/version` | nein | Version + APK-URL (JSON) |
| `POST /api/admin/version` | Admin-Token | Version setzen (kein Neustart nötig) |
| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) |
| `POST /api/auth/refresh` | nein | Access-Token erneuern |
| `GET /api/inventory` | JWT | Inventar des Users abrufen |
| `PUT /api/inventory` | JWT | Inventar des Users hochladen |
| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten |
| `GET /api/admin/users` | Admin (JWT) | Alle User auflisten |
| `POST /api/admin/users` | Admin (JWT) | Neuen User anlegen |
| `PUT /api/admin/users/{id}` | Admin (JWT) | Passwort ändern |
| `DELETE /api/admin/users/{id}` | Admin (JWT) | User löschen |
| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen |
JWT wird als `Authorization: Bearer <accessToken>` Header mitgeschickt.

View file

@ -7,6 +7,7 @@ import de.bollwerk.server.plugins.configureRateLimiting
import de.bollwerk.server.plugins.configureRouting
import de.bollwerk.server.plugins.configureSerialization
import de.bollwerk.server.plugins.configureStatusPages
import de.bollwerk.server.store.VersionStore
import io.ktor.server.application.*
import io.ktor.server.netty.*
import java.io.File
@ -26,11 +27,11 @@ internal fun Application.module() {
configurePlugins()
}
internal fun Application.configurePlugins(backupDir: File = File("/backups")) {
internal fun Application.configurePlugins(backupDir: File = File("/backups"), versionStore: VersionStore? = null) {
configureSerialization()
configureStatusPages()
configureCallLogging()
configureRateLimiting()
configureAuthentication()
configureRouting(backupDir = backupDir)
configureRouting(backupDir = backupDir, versionStore = versionStore)
}

View file

@ -12,6 +12,7 @@ import de.bollwerk.server.routes.userRoutes
import de.bollwerk.server.routes.versionRoutes
import de.bollwerk.server.routes.webSocketRoutes
import de.bollwerk.server.security.JwtService
import de.bollwerk.server.store.VersionStore
import de.bollwerk.server.websocket.WebSocketManager
import io.ktor.http.*
import io.ktor.server.application.*
@ -34,13 +35,22 @@ internal fun Application.configureRouting(
messageRepository: MessageRepository = MessageRepository(),
jwtService: JwtService = JwtService(environment.config),
wsManager: WebSocketManager = WebSocketManager(),
backupDir: File = File("/backups")
backupDir: File = File("/backups"),
versionStore: VersionStore? = null
) {
val config = environment.config
val appVersionCode = config.propertyOrNull("bollwerk.appVersionCode")
?.getString()?.toIntOrNull() ?: 1
val appVersionName = config.propertyOrNull("bollwerk.appVersionName")
?.getString() ?: "1.0.0"
val adminToken = config.propertyOrNull("bollwerk.adminToken")
?.getString() ?: ""
val effectiveVersionStore = versionStore ?: VersionStore(
dataDir = File("data"),
fallbackVersionCode = appVersionCode,
fallbackVersionName = appVersionName
)
install(WebSockets) {
pingPeriod = 15.seconds
@ -64,7 +74,7 @@ internal fun Application.configureRouting(
}
// Public version endpoint & homepage
versionRoutes(appVersionCode, appVersionName)
versionRoutes(effectiveVersionStore, adminToken)
// Static APK from filesystem (server/data/)
staticFiles("/static", File("data"))

View file

@ -1,12 +1,33 @@
package de.bollwerk.server.routes
import de.bollwerk.server.model.VersionInfo
import de.bollwerk.server.store.VersionStore
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
internal fun Route.versionRoutes(versionCode: Int, versionName: String) {
@Serializable
internal data class UpdateVersionRequest(
val versionCode: Int,
val versionName: String
)
@Serializable
internal data class UpdateVersionResponse(
val status: String,
val versionCode: Int,
val versionName: String
)
@Serializable
internal data class VersionError(
val error: String
)
internal fun Route.versionRoutes(versionStore: VersionStore, adminToken: String) {
get("/api/version") {
val scheme = call.request.headers["X-Forwarded-Proto"] ?: "https"
val host = call.request.headers["X-Forwarded-Host"]
@ -14,7 +35,33 @@ internal fun Route.versionRoutes(versionCode: Int, versionName: String) {
?: "localhost"
val apkUrl = "$scheme://$host/static/app-latest.apk"
call.respond(VersionInfo(versionCode, versionName, apkUrl))
call.respond(VersionInfo(versionStore.versionCode, versionStore.versionName, apkUrl))
}
post("/api/admin/version") {
if (adminToken.isBlank()) {
call.respond(HttpStatusCode.ServiceUnavailable, VersionError("Admin token not configured"))
return@post
}
val authHeader = call.request.header(HttpHeaders.Authorization)
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
call.respond(HttpStatusCode.Unauthorized, VersionError("Missing or invalid Authorization header"))
return@post
}
val token = authHeader.removePrefix("Bearer ").trim()
if (token != adminToken) {
call.respond(HttpStatusCode.Forbidden, VersionError("Invalid admin token"))
return@post
}
val request = call.receive<UpdateVersionRequest>()
if (request.versionCode < 1 || request.versionName.isBlank()) {
call.respond(HttpStatusCode.BadRequest, VersionError("versionCode must be >= 1 and versionName must not be blank"))
return@post
}
versionStore.update(request.versionCode, request.versionName)
call.respond(HttpStatusCode.OK, UpdateVersionResponse("ok", request.versionCode, request.versionName))
}
get("/") {
@ -24,7 +71,7 @@ internal fun Route.versionRoutes(versionCode: Int, versionName: String) {
?: "localhost"
val apkUrl = "$scheme://$host/static/app-latest.apk"
val html = buildHomepageHtml(versionName, versionCode, apkUrl)
val html = buildHomepageHtml(versionStore.versionName, versionStore.versionCode, apkUrl)
call.respondText(html, ContentType.Text.Html, HttpStatusCode.OK)
}
}

View file

@ -0,0 +1,48 @@
package de.bollwerk.server.store
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
@Serializable
internal data class StoredVersion(
val versionCode: Int,
val versionName: String
)
internal class VersionStore(
private val dataDir: File,
private val fallbackVersionCode: Int,
private val fallbackVersionName: String
) {
private val versionFile = File(dataDir, "version.json")
private val json = Json { prettyPrint = true }
@Volatile
private var cached: StoredVersion? = null
init {
cached = readFromFile()
}
val versionCode: Int get() = cached?.versionCode ?: fallbackVersionCode
val versionName: String get() = cached?.versionName ?: fallbackVersionName
@Synchronized
fun update(versionCode: Int, versionName: String) {
val version = StoredVersion(versionCode, versionName)
dataDir.mkdirs()
versionFile.writeText(json.encodeToString(version))
cached = version
}
private fun readFromFile(): StoredVersion? {
if (!versionFile.exists()) return null
return try {
json.decodeFromString<StoredVersion>(versionFile.readText())
} catch (_: Exception) {
null
}
}
}

View file

@ -19,4 +19,6 @@ bollwerk {
appVersionCode = ${?BOLLWERK_APP_VERSION_CODE}
appVersionName = "1.0.0"
appVersionName = ${?BOLLWERK_APP_VERSION_NAME}
adminToken = ""
adminToken = ${?BOLLWERK_ADMIN_TOKEN}
}

View file

@ -11,6 +11,7 @@ internal const val TEST_USER_ID = "test-user-id-001"
internal const val TEST_USERNAME = "testuser"
internal const val TEST_ADMIN_ID = "test-admin-id-001"
internal const val TEST_ADMIN_USERNAME = "testadmin"
internal const val TEST_ADMIN_TOKEN = "test-admin-token-for-version-updates"
internal fun testMapConfig(vararg extra: Pair<String, String>) = listOf(
"bollwerk.jwtSecret" to TEST_JWT_SECRET,
@ -19,7 +20,8 @@ internal fun testMapConfig(vararg extra: Pair<String, String>) = listOf(
"bollwerk.accessTokenExpiryMs" to "3600000",
"bollwerk.refreshTokenExpiryMs" to "2592000000",
"bollwerk.appVersionCode" to "42",
"bollwerk.appVersionName" to "2.1.0"
"bollwerk.appVersionName" to "2.1.0",
"bollwerk.adminToken" to TEST_ADMIN_TOKEN
) + extra.toList()
internal fun createTestAccessToken(

View file

@ -2,6 +2,7 @@ package de.bollwerk.server
import de.bollwerk.server.db.DatabaseFactory
import de.bollwerk.server.model.VersionInfo
import de.bollwerk.server.store.VersionStore
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
@ -11,6 +12,7 @@ import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.file.Files
class VersionEndpointTest {
@ -20,8 +22,15 @@ class VersionEndpointTest {
extraConfig: List<Pair<String, String>> = emptyList(),
block: suspend ApplicationTestBuilder.() -> Unit
) = testApplication {
val tempDir = Files.createTempDirectory("bollwerk-test-version").toFile()
tempDir.deleteOnExit()
val config = testMapConfig() + extraConfig
val versionCode = config.lastOrNull { it.first == "bollwerk.appVersionCode" }?.second?.toIntOrNull() ?: 42
val versionName = config.lastOrNull { it.first == "bollwerk.appVersionName" }?.second ?: "2.1.0"
val store = VersionStore(tempDir, versionCode, versionName)
environment {
config = MapApplicationConfig(*(testMapConfig() + extraConfig).toTypedArray())
this.config = MapApplicationConfig(*config.toTypedArray())
}
application {
DatabaseFactory.init(
@ -29,7 +38,7 @@ class VersionEndpointTest {
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
configurePlugins(versionStore = store)
}
block()
}
@ -158,4 +167,91 @@ class VersionEndpointTest {
// Then
assertEquals(HttpStatusCode.NotFound, response.status)
}
// --- POST /api/admin/version tests ---
@Test
fun test_postVersion_updatesVersion() = testApp {
// When
val response = client.post("/api/admin/version") {
header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"versionCode": 50, "versionName": "3.0.0"}""")
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
// Verify GET reflects the new version
val getResponse = client.get("/api/version")
val versionInfo = json.decodeFromString<VersionInfo>(getResponse.bodyAsText())
assertEquals(50, versionInfo.versionCode)
assertEquals("3.0.0", versionInfo.versionName)
}
@Test
fun test_postVersion_noAuthHeader_returns401() = testApp {
// When
val response = client.post("/api/admin/version") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"versionCode": 50, "versionName": "3.0.0"}""")
}
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_postVersion_wrongToken_returns403() = testApp {
// When
val response = client.post("/api/admin/version") {
header(HttpHeaders.Authorization, "Bearer wrong-token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"versionCode": 50, "versionName": "3.0.0"}""")
}
// Then
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun test_postVersion_invalidVersionCode_returns400() = testApp {
// When
val response = client.post("/api/admin/version") {
header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"versionCode": 0, "versionName": "1.0"}""")
}
// Then
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_postVersion_blankVersionName_returns400() = testApp {
// When
val response = client.post("/api/admin/version") {
header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"versionCode": 5, "versionName": ""}""")
}
// Then
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_postVersion_noAdminTokenConfigured_returns503() = testApp(
extraConfig = listOf("bollwerk.adminToken" to "")
) {
// When
val response = client.post("/api/admin/version") {
header(HttpHeaders.Authorization, "Bearer some-token")
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"versionCode": 5, "versionName": "1.0"}""")
}
// Then
assertEquals(HttpStatusCode.ServiceUnavailable, response.status)
}
}