feat(server): add LAN dev-server integration & end-to-end sync tests

Start scripts (PS1 + Shell), Dockerfile, E2E sync tests, and README
documentation for Phase 2 LAN server deployment.

New files:
- start-server.ps1 / start-server.sh: one-command server startup with
  auto-build, LAN-IP detection, and configurable API key
- Dockerfile: multi-stage build (Gradle → JRE Alpine) for container
  deployment with volume mount for persistent data
- .dockerignore: excludes app/, .git, build artifacts from Docker context
- EndToEndSyncTest.kt: 7 E2E tests covering full push/pull sync cycle,
  multi-client overwrite, empty DB pull, multiple round-trips, and auth
  rejection for unauthenticated requests
- README.md: project overview, build instructions, and complete Phase 2
  server setup docs (4 start options, LAN setup, API reference, security)

Changed files:
- AndroidManifest.xml: added usesCleartextTraffic=true for HTTP in LAN

Closes #46
This commit is contained in:
Jens Reinemann 2026-05-14 21:45:33 +02:00
parent a972ce34ca
commit cb576349e0
7 changed files with 570 additions and 0 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
.git
.github
.gradle
.idea
app/
build/
tmp/
memories/
Anforderungen/
local.properties
*.apk
*.aab

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
# Stage 1: Build the fat JAR
FROM gradle:8.11.1-jdk21 AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew gradlew.bat build.gradle.kts settings.gradle.kts gradle.properties ./
COPY shared/ shared/
COPY server/ server/
RUN gradle :server:buildFatJar --no-daemon
# Stage 2: Run
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/server/build/libs/server.jar server.jar
ENV KRISENVORRAT_API_KEY="change-me-to-a-secure-key-at-least-32-chars"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "server.jar"]

143
README.md Normal file
View file

@ -0,0 +1,143 @@
# Krisenvorrat
Android-App zur Verwaltung eines Krisenvorrats-Inventars mit lokaler Datenhaltung und Sync-Möglichkeit über einen LAN-Server.
## Projektstruktur
| Modul | Beschreibung |
|----------|-------------------------------------------------|
| `app` | Android-App (Kotlin, Jetpack Compose, Room) |
| `server` | Ktor REST-Server (H2-Datenbank, Fat-JAR) |
| `shared` | Gemeinsame DTOs (kotlinx.serialization) |
## Voraussetzungen
- **Android Studio** oder IntelliJ IDEA
- **JDK 21** (für Server) / JDK 11+ (für App)
- **Android SDK** (API Level 35)
## App bauen
```powershell
# Windows
.\gradlew assembleDebug
# Linux/Mac
./gradlew assembleDebug
```
## Tests ausführen
```bash
# Alle Tests (App + Server)
./gradlew test
# Nur Server-Tests
./gradlew :server:test
# Nur App-Tests
./gradlew :app:test
```
---
## Phase 2: Server-Setup (LAN-Sync)
### Übersicht
Der Sync-Server empfängt das Inventar der App als JSON und gibt es an andere App-Instanzen im selben LAN weiter. Die Kommunikation läuft über einfaches HTTP mit API-Key-Authentifizierung.
```
┌──────────┐ PUT /api/inventory ┌──────────┐
│ App A │ ───────────────────────► │ Server │
│ (Upload) │ │ :8080 │
└──────────┘ └────┬─────┘
┌──────────┐ GET /api/inventory ┌────┴─────┐
│ App B │ ◄─────────────────────── │ Server │
│(Download)│ │ :8080 │
└──────────┘ └──────────┘
```
### Schnellstart
#### Option A: Direkt starten (empfohlen für Entwicklung)
```powershell
# Windows
.\start-server.ps1
# Linux/Mac
chmod +x start-server.sh
./start-server.sh
```
Das Skript baut automatisch das Fat-JAR und zeigt die LAN-IP an.
#### Option B: Manuell mit Gradle
```bash
./gradlew :server:run
```
#### Option C: Fat-JAR
```bash
# JAR bauen
./gradlew :server:buildFatJar
# Starten
java -jar server/build/libs/server.jar
```
#### Option D: Docker
```bash
# Image bauen
docker build -t krisenvorrat-server .
# Container starten
docker run -d \
--name krisenvorrat \
-p 8080:8080 \
-e KRISENVORRAT_API_KEY="mein-sicherer-api-key" \
-v krisenvorrat-data:/app/data \
krisenvorrat-server
```
### LAN-Setup
1. **Server-IP ermitteln:**
- Windows: `ipconfig` → IPv4-Adresse des WLAN/LAN-Adapters
- Linux/Mac: `ip addr` oder `ifconfig`
- Die Start-Skripte zeigen die IP automatisch an
2. **Firewall-Regel:** Port **8080** (TCP eingehend) muss freigeschaltet sein:
- Windows: `netsh advfirewall firewall add rule name="Krisenvorrat" dir=in action=allow protocol=TCP localport=8080`
- Linux: `sudo ufw allow 8080/tcp`
3. **App konfigurieren:**
- Einstellungen → Server-URL: `http://<SERVER-IP>:8080`
- Einstellungen → API-Key: den gleichen Key wie beim Server-Start
### API-Endpunkte
| Methode | Pfad | Auth | Beschreibung |
|---------|-------------------|-----------|------------------------------------|
| GET | `/api/health` | | Health-Check, gibt "OK" zurück |
| GET | `/api/inventory` | API-Key | Inventar vom Server laden (Pull) |
| PUT | `/api/inventory` | API-Key | Inventar auf Server speichern (Push)|
**Authentifizierung:** API-Key als `X-API-Key`-Header oder `Bearer`-Token.
### Umgebungsvariablen
| Variable | Standard | Beschreibung |
|-----------------------|-------------------------------------------------|-------------------|
| `KRISENVORRAT_API_KEY` | `change-me-to-a-secure-key-at-least-32-chars` | API-Key für Auth |
### Sicherheitshinweise
- Der Dev-Server nutzt **HTTP** (kein HTTPS) nur im vertrauenswürdigen LAN verwenden.
- Für Produktionseinsatz: HTTPS mit Reverse-Proxy (z.B. nginx/Caddy) konfigurieren.
- API-Key sollte mindestens 32 Zeichen lang sein.

View file

@ -10,6 +10,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Krisenvorrat">
<activity
android:name=".MainActivity"

View file

@ -0,0 +1,279 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* End-to-End Sync-Tests: Simuliert den vollständigen Sync-Workflow
* (App exportiert Inventar Server speichert andere App importiert).
*/
class EndToEndSyncTest {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig("krisenvorrat.apiKey" to API_KEY)
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:e2e_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver"
)
configurePlugins()
}
block()
}
@Test
fun test_fullSyncCycle_pushThenPull_roundTripsCorrectly() = testApp {
// Given "Client A" hat ein vollständiges Inventar
val clientAInventory = createFullInventory()
val body = json.encodeToString(InventoryDto.serializer(), clientAInventory)
// When Client A pusht (Upload)
val pushResponse = client.put("/api/inventory") {
header("X-API-Key", API_KEY)
contentType(ContentType.Application.Json)
setBody(body)
}
// Then Push erfolgreich
assertEquals(HttpStatusCode.OK, pushResponse.status)
// When "Client B" pullt (Download)
val pullResponse = client.get("/api/inventory") {
header("X-API-Key", API_KEY)
}
// Then Client B erhält exakt die Daten von Client A
assertEquals(HttpStatusCode.OK, pullResponse.status)
val pulledInventory = json.decodeFromString<InventoryDto>(pullResponse.bodyAsText())
assertEquals(clientAInventory.categories.size, pulledInventory.categories.size)
assertEquals(clientAInventory.locations.size, pulledInventory.locations.size)
assertEquals(clientAInventory.items.size, pulledInventory.items.size)
assertEquals(clientAInventory.settings.size, pulledInventory.settings.size)
// Inhalte prüfen
assertEquals("Konserven", pulledInventory.categories[0].name)
assertEquals("Getränke", pulledInventory.categories[1].name)
assertEquals("Keller", pulledInventory.locations[0].name)
assertEquals("Dosenbrot", pulledInventory.items[0].name)
assertEquals(5.0, pulledInventory.items[0].quantity, 0.01)
assertEquals("dark", pulledInventory.settings[0].value)
}
@Test
fun test_multiClientSync_secondClientOverwritesFirst() = testApp {
// Given Client A pusht
val clientAInventory = createFullInventory()
client.put("/api/inventory") {
header("X-API-Key", API_KEY)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), clientAInventory))
}
// When Client B pusht ein anderes Inventar (überschreibt)
val clientBInventory = InventoryDto(
categories = listOf(CategoryDto(id = 10, name = "Hygiene")),
locations = listOf(LocationDto(id = 10, name = "Badezimmer")),
items = listOf(
ItemDto(
id = "item-b1",
name = "Seife",
categoryId = 10,
quantity = 3.0,
unit = "Stück",
unitPrice = 1.50,
kcalPer100g = null,
expiryDate = null,
locationId = 10,
minStock = 1.0,
notes = "",
lastUpdated = 1715100000L
)
),
settings = listOf(SettingDto(key = "lang", value = "de"))
)
val pushResponse = client.put("/api/inventory") {
header("X-API-Key", API_KEY)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), clientBInventory))
}
assertEquals(HttpStatusCode.OK, pushResponse.status)
// Then Client C pullt und sieht nur Client B's Daten
val pullResponse = client.get("/api/inventory") {
header("X-API-Key", API_KEY)
}
val pulledInventory = json.decodeFromString<InventoryDto>(pullResponse.bodyAsText())
assertEquals(1, pulledInventory.categories.size)
assertEquals("Hygiene", pulledInventory.categories[0].name)
assertEquals(1, pulledInventory.locations.size)
assertEquals("Badezimmer", pulledInventory.locations[0].name)
assertEquals(1, pulledInventory.items.size)
assertEquals("Seife", pulledInventory.items[0].name)
}
@Test
fun test_pullFromEmptyServer_returnsEmptyInventory() = testApp {
// When Pull ohne vorherigen Push
val response = client.get("/api/inventory") {
header("X-API-Key", API_KEY)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val inventory = json.decodeFromString<InventoryDto>(response.bodyAsText())
assertTrue(inventory.categories.isEmpty())
assertTrue(inventory.locations.isEmpty())
assertTrue(inventory.items.isEmpty())
assertTrue(inventory.settings.isEmpty())
}
@Test
fun test_pushPullPushPull_multipleRoundTrips() = testApp {
// Round-Trip 1: Push + Pull
val inventory1 = createFullInventory()
client.put("/api/inventory") {
header("X-API-Key", API_KEY)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory1))
}
val pull1 = client.get("/api/inventory") { header("X-API-Key", API_KEY) }
val result1 = json.decodeFromString<InventoryDto>(pull1.bodyAsText())
assertEquals(2, result1.items.size)
// Round-Trip 2: Aktualisiertes Inventar pushen
val inventory2 = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(
ItemDto(
id = "item-1",
name = "Dosenbrot",
categoryId = 1,
quantity = 10.0,
unit = "Stück",
unitPrice = 3.99,
kcalPer100g = 250,
expiryDate = "2027-06-15",
locationId = 1,
minStock = 2.0,
notes = "Menge erhöht",
lastUpdated = 1715200000L
)
),
settings = emptyList()
)
client.put("/api/inventory") {
header("X-API-Key", API_KEY)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventory2))
}
val pull2 = client.get("/api/inventory") { header("X-API-Key", API_KEY) }
val result2 = json.decodeFromString<InventoryDto>(pull2.bodyAsText())
assertEquals(1, result2.items.size)
assertEquals(10.0, result2.items[0].quantity, 0.01)
assertEquals("Menge erhöht", result2.items[0].notes)
}
@Test
fun test_healthEndpoint_alwaysAvailableWithoutAuth() = testApp {
// When
val response = client.get("/api/health")
// Then Health-Endpoint braucht keinen API-Key
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("OK", response.bodyAsText())
}
@Test
fun test_pushWithoutAuth_returns401() = testApp {
// When Push ohne API-Key
val response = client.put("/api/inventory") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), createFullInventory()))
}
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_pullWithoutAuth_returns401() = testApp {
// When Pull ohne API-Key
val response = client.get("/api/inventory")
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
private fun createFullInventory(): InventoryDto = InventoryDto(
categories = listOf(
CategoryDto(id = 1, name = "Konserven"),
CategoryDto(id = 2, name = "Getränke")
),
locations = listOf(
LocationDto(id = 1, name = "Keller"),
LocationDto(id = 2, name = "Speisekammer")
),
items = listOf(
ItemDto(
id = "item-1",
name = "Dosenbrot",
categoryId = 1,
quantity = 5.0,
unit = "Stück",
unitPrice = 3.99,
kcalPer100g = 250,
expiryDate = "2027-06-15",
locationId = 1,
minStock = 2.0,
notes = "Vollkornbrot in der Dose",
lastUpdated = 1715000000L
),
ItemDto(
id = "item-2",
name = "Mineralwasser",
categoryId = 2,
quantity = 24.0,
unit = "Liter",
unitPrice = 0.49,
kcalPer100g = 0,
expiryDate = "2028-01-01",
locationId = 2,
minStock = 12.0,
notes = "Stilles Wasser",
lastUpdated = 1715000000L
)
),
settings = listOf(
SettingDto(key = "theme", value = "dark"),
SettingDto(key = "household_size", value = "4")
)
)
private companion object {
const val API_KEY = "e2e-test-api-key-for-sync"
}
}

54
start-server.ps1 Normal file
View file

@ -0,0 +1,54 @@
<#
.SYNOPSIS
Startet den Krisenvorrat Dev-Server im LAN.
.DESCRIPTION
Baut das Fat-JAR (falls nötig) und startet den Server auf Port 8080.
Der Server ist unter der LAN-IP des Rechners erreichbar.
.PARAMETER Build
Erzwingt einen Neubau des Fat-JARs, auch wenn es bereits existiert.
.PARAMETER ApiKey
Setzt einen benutzerdefinierten API-Key. Standard: "dev-api-key-change-in-production"
.EXAMPLE
.\start-server.ps1
.\start-server.ps1 -Build
.\start-server.ps1 -ApiKey "mein-geheimer-key"
#>
param(
[switch]$Build,
[string]$ApiKey = "dev-api-key-change-in-production"
)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
$jarPath = "server/build/libs/server.jar"
# Fat-JAR bauen falls nötig
if ($Build -or -not (Test-Path $jarPath)) {
Write-Host "Building server fat JAR..." -ForegroundColor Cyan
& .\gradlew.bat :server:buildFatJar 2>&1 | Out-String | Write-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed with exit code $LASTEXITCODE"
exit 1
}
Write-Host "Build successful." -ForegroundColor Green
}
# LAN-IP ermitteln
$lanIp = (Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.InterfaceAlias -notmatch "Loopback" -and $_.IPAddress -notmatch "^169\." } |
Select-Object -First 1).IPAddress
Write-Host ""
Write-Host "=== Krisenvorrat Dev-Server ===" -ForegroundColor Green
Write-Host "Local: http://localhost:8080" -ForegroundColor Yellow
if ($lanIp) {
Write-Host "LAN: http://${lanIp}:8080" -ForegroundColor Yellow
}
Write-Host "API-Key: $ApiKey" -ForegroundColor Yellow
Write-Host "Press Ctrl+C to stop" -ForegroundColor Gray
Write-Host ""
# Server starten
$env:KRISENVORRAT_API_KEY = $ApiKey
java -jar $jarPath

62
start-server.sh Normal file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Startet den Krisenvorrat Dev-Server im LAN.
#
# Verwendung:
# ./start-server.sh # Standard API-Key
# ./start-server.sh --build # Fat-JAR neu bauen
# ./start-server.sh --api-key "mein-key" # Eigener API-Key
#
set -euo pipefail
cd "$(dirname "$0")"
API_KEY="dev-api-key-change-in-production"
FORCE_BUILD=false
while [[ $# -gt 0 ]]; do
case "$1" in
--build)
FORCE_BUILD=true
shift
;;
--api-key)
API_KEY="$2"
shift 2
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
JAR_PATH="server/build/libs/server.jar"
# Fat-JAR bauen falls nötig
if [ "$FORCE_BUILD" = true ] || [ ! -f "$JAR_PATH" ]; then
echo "Building server fat JAR..."
./gradlew :server:buildFatJar
echo "Build successful."
fi
# LAN-IP ermitteln
LAN_IP=""
if command -v ip &>/dev/null; then
LAN_IP=$(ip -4 addr show scope global | grep -oP 'inet \K[\d.]+' | head -1)
elif command -v ifconfig &>/dev/null; then
LAN_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -1)
fi
echo ""
echo "=== Krisenvorrat Dev-Server ==="
echo "Local: http://localhost:8080"
if [ -n "$LAN_IP" ]; then
echo "LAN: http://${LAN_IP}:8080"
fi
echo "API-Key: $API_KEY"
echo "Press Ctrl+C to stop"
echo ""
# Server starten
export KRISENVORRAT_API_KEY="$API_KEY"
java -jar "$JAR_PATH"