PNRxA commited on
Commit
dd7a3f0
·
1 Parent(s): 54e4063

init tests

Browse files
.containerignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ api/target
2
+ web/node_modules
3
+ web/dist
4
+ .git
.github/workflows/test.yml ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ env:
10
+ CARGO_TERM_COLOR: always
11
+
12
+ jobs:
13
+ backend:
14
+ name: Backend tests
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: dtolnay/rust-toolchain@stable
20
+
21
+ - uses: Swatinem/rust-cache@v2
22
+ with:
23
+ workspaces: api
24
+
25
+ - name: Run tests
26
+ working-directory: api
27
+ run: cargo test
28
+
29
+ frontend-unit:
30
+ name: Frontend unit tests
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+
35
+ - uses: actions/setup-node@v4
36
+ with:
37
+ node-version: 22
38
+ cache: npm
39
+ cache-dependency-path: web/package-lock.json
40
+
41
+ - name: Install dependencies
42
+ working-directory: web
43
+ run: npm ci
44
+
45
+ - name: Run tests
46
+ working-directory: web
47
+ run: npx vitest run
48
+
49
+ e2e:
50
+ name: E2E tests
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+
55
+ - uses: dtolnay/rust-toolchain@stable
56
+
57
+ - uses: Swatinem/rust-cache@v2
58
+ with:
59
+ workspaces: api
60
+
61
+ - uses: actions/setup-node@v4
62
+ with:
63
+ node-version: 22
64
+ cache: npm
65
+ cache-dependency-path: web/package-lock.json
66
+
67
+ - name: Install frontend dependencies
68
+ working-directory: web
69
+ run: npm ci
70
+
71
+ - name: Install Playwright browsers
72
+ working-directory: web
73
+ run: npx playwright install --with-deps chromium
74
+
75
+ - name: Build frontend
76
+ working-directory: web
77
+ run: npm run build-only
78
+
79
+ - name: Build backend
80
+ working-directory: api
81
+ run: cargo build --release
82
+
83
+ - name: Start backend
84
+ env:
85
+ TMDB_API_KEY: test
86
+ MDBLIST_API_KEY: test
87
+ JWT_SECRET: abababababababababababababababababababababababababababababababab
88
+ LISTEN_ADDR: 0.0.0.0:3333
89
+ COOKIE_SECURE: "false"
90
+ CACHE_DIR: /tmp/openposterdb-e2e
91
+ STATIC_DIR: web/dist
92
+ run: |
93
+ mkdir -p "$CACHE_DIR"
94
+ api/target/release/openposterdb-api &
95
+ for i in $(seq 1 30); do
96
+ if curl -sf http://127.0.0.1:3333/api/auth/status > /dev/null 2>&1; then
97
+ echo "Backend ready"
98
+ break
99
+ fi
100
+ sleep 1
101
+ done
102
+
103
+ - name: Run E2E tests
104
+ working-directory: web
105
+ env:
106
+ CI: true
107
+ run: npx playwright test --project=setup --project=chromium
108
+
109
+ - uses: actions/upload-artifact@v4
110
+ if: ${{ !cancelled() }}
111
+ with:
112
+ name: playwright-report
113
+ path: web/playwright-report/
114
+ retention-days: 7
Containerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM rust:1-bookworm AS api-builder
2
+ WORKDIR /app
3
+ COPY api/ .
4
+ ARG CARGO_FEATURES=""
5
+ RUN cargo build --release --features "${CARGO_FEATURES}"
6
+
7
+ FROM node:22-bookworm AS web-builder
8
+ WORKDIR /app
9
+ COPY web/package.json web/package-lock.json ./
10
+ RUN npm ci
11
+ COPY web/ .
12
+ RUN npm run build-only
13
+
14
+ FROM debian:bookworm-slim
15
+ RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/* \
16
+ && groupadd --system opdb && useradd --system --gid opdb --create-home opdb
17
+ COPY --from=api-builder /app/target/release/openposterdb-api /usr/local/bin/openposterdb
18
+ COPY --from=web-builder /app/dist /app/dist
19
+
20
+ RUN mkdir -p /data/cache && chown -R opdb:opdb /data
21
+ USER opdb
22
+
23
+ ENV STATIC_DIR=/app/dist
24
+ ENV CACHE_DIR=/data/cache
25
+ EXPOSE 3000
26
+ HEALTHCHECK --interval=10s --timeout=3s --start-period=5s \
27
+ CMD curl -sf http://localhost:3000/api/auth/status || exit 1
28
+ CMD ["openposterdb"]
README.md CHANGED
@@ -51,6 +51,26 @@ npm run dev # development
51
  npm run build # production
52
  ```
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  ## Configuration
55
 
56
  | Variable | Default | Description |
 
51
  npm run build # production
52
  ```
53
 
54
+ ### Docker
55
+
56
+ ```bash
57
+ # Create a .env file
58
+ cat > .env << EOF
59
+ TMDB_API_KEY=your_key
60
+ OMDB_API_KEY=your_key # and/or MDBLIST_API_KEY
61
+ JWT_SECRET=$(openssl rand -hex 32)
62
+ EOF
63
+
64
+ # Build and start
65
+ docker compose up -d
66
+ ```
67
+
68
+ The web UI will be available at `http://localhost:3000`. On first visit you'll be prompted to create an admin account.
69
+
70
+ If you access the UI over plain HTTP (no reverse proxy with TLS), add `COOKIE_SECURE=false` to your `.env` — otherwise the browser will silently drop auth cookies and login will appear broken.
71
+
72
+ See [docker-compose.yml](docker-compose.yml) for the full compose configuration.
73
+
74
  ## Configuration
75
 
76
  | Variable | Default | Description |
api/Cargo.lock CHANGED
@@ -1577,6 +1577,12 @@ dependencies = [
1577
  "pin-project-lite",
1578
  ]
1579
 
 
 
 
 
 
 
1580
  [[package]]
1581
  name = "httparse"
1582
  version = "1.10.1"
@@ -2146,6 +2152,16 @@ version = "0.3.17"
2146
  source = "registry+https://github.com/rust-lang/crates.io-index"
2147
  checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2148
 
 
 
 
 
 
 
 
 
 
 
2149
  [[package]]
2150
  name = "miniz_oxide"
2151
  version = "0.8.9"
@@ -2393,10 +2409,12 @@ dependencies = [
2393
  "chrono",
2394
  "dashmap",
2395
  "dotenvy",
 
2396
  "image",
2397
  "imageproc",
2398
  "jsonwebtoken",
2399
  "moka",
 
2400
  "rand 0.9.2",
2401
  "reqwest",
2402
  "sea-orm",
@@ -2406,6 +2424,7 @@ dependencies = [
2406
  "sqlx",
2407
  "thiserror 2.0.18",
2408
  "tokio",
 
2409
  "tower-http",
2410
  "tower_governor",
2411
  "tracing",
@@ -4368,7 +4387,13 @@ dependencies = [
4368
  "futures-util",
4369
  "http",
4370
  "http-body",
 
 
 
4371
  "iri-string",
 
 
 
4372
  "pin-project-lite",
4373
  "tokio",
4374
  "tokio-util",
@@ -4497,6 +4522,12 @@ version = "1.19.0"
4497
  source = "registry+https://github.com/rust-lang/crates.io-index"
4498
  checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
4499
 
 
 
 
 
 
 
4500
  [[package]]
4501
  name = "unicode-bidi"
4502
  version = "0.3.18"
 
1577
  "pin-project-lite",
1578
  ]
1579
 
1580
+ [[package]]
1581
+ name = "http-range-header"
1582
+ version = "0.4.2"
1583
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1584
+ checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
1585
+
1586
  [[package]]
1587
  name = "httparse"
1588
  version = "1.10.1"
 
2152
  source = "registry+https://github.com/rust-lang/crates.io-index"
2153
  checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2154
 
2155
+ [[package]]
2156
+ name = "mime_guess"
2157
+ version = "2.0.5"
2158
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2159
+ checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
2160
+ dependencies = [
2161
+ "mime",
2162
+ "unicase",
2163
+ ]
2164
+
2165
  [[package]]
2166
  name = "miniz_oxide"
2167
  version = "0.8.9"
 
2409
  "chrono",
2410
  "dashmap",
2411
  "dotenvy",
2412
+ "http-body-util",
2413
  "image",
2414
  "imageproc",
2415
  "jsonwebtoken",
2416
  "moka",
2417
+ "openposterdb-api",
2418
  "rand 0.9.2",
2419
  "reqwest",
2420
  "sea-orm",
 
2424
  "sqlx",
2425
  "thiserror 2.0.18",
2426
  "tokio",
2427
+ "tower",
2428
  "tower-http",
2429
  "tower_governor",
2430
  "tracing",
 
4387
  "futures-util",
4388
  "http",
4389
  "http-body",
4390
+ "http-body-util",
4391
+ "http-range-header",
4392
+ "httpdate",
4393
  "iri-string",
4394
+ "mime",
4395
+ "mime_guess",
4396
+ "percent-encoding",
4397
  "pin-project-lite",
4398
  "tokio",
4399
  "tokio-util",
 
4522
  source = "registry+https://github.com/rust-lang/crates.io-index"
4523
  checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
4524
 
4525
+ [[package]]
4526
+ name = "unicase"
4527
+ version = "2.9.0"
4528
+ source = "registry+https://github.com/rust-lang/crates.io-index"
4529
+ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
4530
+
4531
  [[package]]
4532
  name = "unicode-bidi"
4533
  version = "0.3.18"
api/Cargo.toml CHANGED
@@ -15,7 +15,7 @@ ab_glyph = "0.2.32"
15
  tracing = "0.1.44"
16
  tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
17
  thiserror = "2.0.18"
18
- tower-http = { version = "0.6.8", features = ["trace", "cors", "compression-gzip"] }
19
  dashmap = "6.1.0"
20
  moka = { version = "0.12.14", features = ["future", "sync"] }
21
  dotenvy = "0.15.7"
@@ -29,3 +29,11 @@ rand = "0.9.2"
29
  chrono = "0.4.44"
30
  zeroize = { version = "1.8.2", features = ["derive"] }
31
  tower_governor = "0.8.0"
 
 
 
 
 
 
 
 
 
15
  tracing = "0.1.44"
16
  tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
17
  thiserror = "2.0.18"
18
+ tower-http = { version = "0.6.8", features = ["trace", "cors", "compression-gzip", "fs"] }
19
  dashmap = "6.1.0"
20
  moka = { version = "0.12.14", features = ["future", "sync"] }
21
  dotenvy = "0.15.7"
 
29
  chrono = "0.4.44"
30
  zeroize = { version = "1.8.2", features = ["derive"] }
31
  tower_governor = "0.8.0"
32
+
33
+ [features]
34
+ test-support = []
35
+
36
+ [dev-dependencies]
37
+ openposterdb-api = { path = ".", features = ["test-support"] }
38
+ tower = { version = "0.5", features = ["util"] }
39
+ http-body-util = "0.1"
api/src/cache/mod.rs CHANGED
@@ -150,3 +150,90 @@ pub fn compute_stale_secs(
150
  // Linear interpolation: min_stale at age=0, approaches max_age at age=max_age
151
  min_stale + film_age * (max_age - min_stale) / max_age
152
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  // Linear interpolation: min_stale at age=0, approaches max_age at age=max_age
151
  min_stale + film_age * (max_age - min_stale) / max_age
152
  }
153
+
154
+ #[cfg(test)]
155
+ mod tests {
156
+ use super::*;
157
+
158
+ #[test]
159
+ fn compute_stale_no_release_date() {
160
+ let result = compute_stale_secs(None, 86400, 31_536_000);
161
+ assert_eq!(result, 86400);
162
+ }
163
+
164
+ #[test]
165
+ fn compute_stale_invalid_date() {
166
+ let result = compute_stale_secs(Some("not-a-date"), 86400, 31_536_000);
167
+ assert_eq!(result, 86400);
168
+ }
169
+
170
+ #[test]
171
+ fn compute_stale_future_film() {
172
+ let result = compute_stale_secs(Some("2099-01-01"), 86400, 31_536_000);
173
+ assert_eq!(result, 86400);
174
+ }
175
+
176
+ #[test]
177
+ fn compute_stale_old_film() {
178
+ // Film from 2000 — age far exceeds max_age of 1 year
179
+ let result = compute_stale_secs(Some("2000-01-01"), 86400, 31_536_000);
180
+ assert_eq!(result, 0);
181
+ }
182
+
183
+ #[test]
184
+ fn date_str_to_epoch_known_value() {
185
+ // 1970-01-02 should be exactly 86400 seconds
186
+ assert_eq!(date_str_to_epoch("1970-01-02"), Some(86400));
187
+ }
188
+
189
+ #[test]
190
+ fn date_str_to_epoch_epoch_start() {
191
+ assert_eq!(date_str_to_epoch("1970-01-01"), Some(0));
192
+ }
193
+
194
+ #[test]
195
+ fn date_str_to_epoch_invalid_month() {
196
+ assert_eq!(date_str_to_epoch("2020-13-01"), None);
197
+ }
198
+
199
+ #[test]
200
+ fn date_str_to_epoch_invalid_day() {
201
+ assert_eq!(date_str_to_epoch("2020-01-32"), None);
202
+ }
203
+
204
+ #[test]
205
+ fn date_str_to_epoch_pre_epoch() {
206
+ assert_eq!(date_str_to_epoch("1969-01-01"), None);
207
+ }
208
+
209
+ #[test]
210
+ fn date_str_to_epoch_garbage() {
211
+ assert_eq!(date_str_to_epoch("garbage"), None);
212
+ }
213
+
214
+ #[test]
215
+ fn cache_path_construction() {
216
+ let p = cache_path("/tmp/cache", "imdb", "tt1234567");
217
+ assert_eq!(p, PathBuf::from("/tmp/cache/imdb/tt1234567.jpg"));
218
+ }
219
+
220
+ #[test]
221
+ fn poster_cache_path_strips_leading_slash() {
222
+ let p = poster_cache_path("/tmp/cache", "/abc123.jpg");
223
+ assert_eq!(p, PathBuf::from("/tmp/cache/posters/abc123.jpg"));
224
+ }
225
+
226
+ #[test]
227
+ fn poster_cache_path_no_leading_slash() {
228
+ let p = poster_cache_path("/tmp/cache", "abc123.jpg");
229
+ assert_eq!(p, PathBuf::from("/tmp/cache/posters/abc123.jpg"));
230
+ }
231
+
232
+ #[test]
233
+ fn is_leap_year_cases() {
234
+ assert!(is_leap(2000)); // divisible by 400
235
+ assert!(is_leap(2024)); // divisible by 4, not by 100
236
+ assert!(!is_leap(1900)); // divisible by 100, not by 400
237
+ assert!(!is_leap(2023)); // not divisible by 4
238
+ }
239
+ }
api/src/config.rs CHANGED
@@ -12,6 +12,7 @@ pub struct Config {
12
  pub poster_quality: u8,
13
  pub mdblist_api_key: Option<String>,
14
  pub poster_mem_cache_mb: u64,
 
15
  }
16
 
17
  impl Config {
@@ -42,6 +43,7 @@ impl Config {
42
  .ok()
43
  .and_then(|v| v.parse().ok())
44
  .unwrap_or(512),
 
45
  };
46
 
47
  if config.omdb_api_key.is_none() && config.mdblist_api_key.is_none() {
 
12
  pub poster_quality: u8,
13
  pub mdblist_api_key: Option<String>,
14
  pub poster_mem_cache_mb: u64,
15
+ pub static_dir: Option<String>,
16
  }
17
 
18
  impl Config {
 
43
  .ok()
44
  .and_then(|v| v.parse().ok())
45
  .unwrap_or(512),
46
+ static_dir: env::var("STATIC_DIR").ok(),
47
  };
48
 
49
  if config.omdb_api_key.is_none() && config.mdblist_api_key.is_none() {
api/src/error.rs CHANGED
@@ -52,3 +52,66 @@ impl IntoResponse for AppError {
52
  (status, axum::Json(json!({ "error": message }))).into_response()
53
  }
54
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  (status, axum::Json(json!({ "error": message }))).into_response()
53
  }
54
  }
55
+
56
+ #[cfg(test)]
57
+ mod tests {
58
+ use super::*;
59
+ use axum::http::StatusCode;
60
+
61
+ fn status_of(err: AppError) -> StatusCode {
62
+ err.into_response().status()
63
+ }
64
+
65
+ #[test]
66
+ fn invalid_id_type_is_400() {
67
+ assert_eq!(
68
+ status_of(AppError::InvalidIdType("x".into())),
69
+ StatusCode::BAD_REQUEST
70
+ );
71
+ }
72
+
73
+ #[test]
74
+ fn id_not_found_is_404() {
75
+ assert_eq!(
76
+ status_of(AppError::IdNotFound("x".into())),
77
+ StatusCode::NOT_FOUND
78
+ );
79
+ }
80
+
81
+ #[test]
82
+ fn unauthorized_is_401() {
83
+ assert_eq!(status_of(AppError::Unauthorized), StatusCode::UNAUTHORIZED);
84
+ }
85
+
86
+ #[test]
87
+ fn forbidden_is_403() {
88
+ assert_eq!(
89
+ status_of(AppError::Forbidden("x".into())),
90
+ StatusCode::FORBIDDEN
91
+ );
92
+ }
93
+
94
+ #[test]
95
+ fn bad_request_is_400() {
96
+ assert_eq!(
97
+ status_of(AppError::BadRequest("x".into())),
98
+ StatusCode::BAD_REQUEST
99
+ );
100
+ }
101
+
102
+ #[test]
103
+ fn db_error_is_500() {
104
+ assert_eq!(
105
+ status_of(AppError::DbError("x".into())),
106
+ StatusCode::INTERNAL_SERVER_ERROR
107
+ );
108
+ }
109
+
110
+ #[test]
111
+ fn other_is_500() {
112
+ assert_eq!(
113
+ status_of(AppError::Other("x".into())),
114
+ StatusCode::INTERNAL_SERVER_ERROR
115
+ );
116
+ }
117
+ }
api/src/handlers/auth.rs CHANGED
@@ -337,4 +337,71 @@ mod tests {
337
  let b = hash_refresh_token("token_b");
338
  assert_ne!(a, b);
339
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  }
 
337
  let b = hash_refresh_token("token_b");
338
  assert_ne!(a, b);
339
  }
340
+
341
+ #[test]
342
+ fn refresh_cookie_secure() {
343
+ let cookie = refresh_cookie("abc123", 604800, true);
344
+ assert!(cookie.contains("refresh_token=abc123"));
345
+ assert!(cookie.contains("HttpOnly"));
346
+ assert!(cookie.contains("SameSite=Strict"));
347
+ assert!(cookie.contains("Path=/api/auth/refresh"));
348
+ assert!(cookie.contains("Max-Age=604800"));
349
+ assert!(cookie.contains("; Secure"));
350
+ }
351
+
352
+ #[test]
353
+ fn refresh_cookie_insecure() {
354
+ let cookie = refresh_cookie("abc123", 604800, false);
355
+ assert!(cookie.contains("refresh_token=abc123"));
356
+ assert!(!cookie.contains("; Secure"));
357
+ }
358
+
359
+ #[test]
360
+ fn refresh_cookie_zero_max_age() {
361
+ let cookie = refresh_cookie("", 0, false);
362
+ assert!(cookie.contains("refresh_token="));
363
+ assert!(cookie.contains("Max-Age=0"));
364
+ }
365
+
366
+ #[test]
367
+ fn extract_refresh_token_present() {
368
+ let mut headers = HeaderMap::new();
369
+ headers.insert(
370
+ header::COOKIE,
371
+ HeaderValue::from_static("refresh_token=mytoken123"),
372
+ );
373
+ assert_eq!(
374
+ extract_refresh_token_from_cookies(&headers),
375
+ Some("mytoken123".to_string())
376
+ );
377
+ }
378
+
379
+ #[test]
380
+ fn extract_refresh_token_missing() {
381
+ let mut headers = HeaderMap::new();
382
+ headers.insert(
383
+ header::COOKIE,
384
+ HeaderValue::from_static("other=value"),
385
+ );
386
+ assert_eq!(extract_refresh_token_from_cookies(&headers), None);
387
+ }
388
+
389
+ #[test]
390
+ fn extract_refresh_token_multiple_cookies() {
391
+ let mut headers = HeaderMap::new();
392
+ headers.insert(
393
+ header::COOKIE,
394
+ HeaderValue::from_static("foo=bar; refresh_token=mytoken; baz=qux"),
395
+ );
396
+ assert_eq!(
397
+ extract_refresh_token_from_cookies(&headers),
398
+ Some("mytoken".to_string())
399
+ );
400
+ }
401
+
402
+ #[test]
403
+ fn extract_refresh_token_no_cookie_header() {
404
+ let headers = HeaderMap::new();
405
+ assert_eq!(extract_refresh_token_from_cookies(&headers), None);
406
+ }
407
  }
api/src/lib.rs ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pub mod cache;
2
+ pub mod config;
3
+ pub mod entity;
4
+ pub mod error;
5
+ pub mod handlers;
6
+ pub mod id;
7
+ pub mod poster;
8
+ pub mod routes;
9
+ pub mod services;
10
+
11
+ use std::sync::Arc;
12
+
13
+ use ab_glyph::FontArc;
14
+ use axum::middleware;
15
+ use axum::Router;
16
+ use dashmap::DashMap;
17
+ use sea_orm::DatabaseConnection;
18
+ use tower_http::compression::CompressionLayer;
19
+ use tower_http::cors::CorsLayer;
20
+ use tower_http::trace::TraceLayer;
21
+ use zeroize::Zeroizing;
22
+
23
+ use cache::MemCacheEntry;
24
+ use config::Config;
25
+ use id::ResolvedId;
26
+ use services::mdblist::MdblistClient;
27
+ use services::omdb::OmdbClient;
28
+ use services::ratings::RatingBadge;
29
+ use services::tmdb::TmdbClient;
30
+
31
+ #[derive(Clone)]
32
+ pub struct AppState {
33
+ pub config: Config,
34
+ pub tmdb: TmdbClient,
35
+ pub omdb: Option<OmdbClient>,
36
+ pub mdblist: Option<MdblistClient>,
37
+ pub http: reqwest::Client,
38
+ pub font: FontArc,
39
+ pub refresh_locks: moka::sync::Cache<String, ()>,
40
+ pub db: DatabaseConnection,
41
+ pub jwt_secret: Zeroizing<Vec<u8>>,
42
+ pub secure_cookies: bool,
43
+ pub api_key_cache: moka::future::Cache<String, Option<i32>>,
44
+ pub poster_inflight: moka::future::Cache<String, bytes::Bytes>,
45
+ pub id_cache: moka::future::Cache<String, ResolvedId>,
46
+ pub ratings_cache: moka::future::Cache<String, Vec<RatingBadge>>,
47
+ pub poster_mem_cache: moka::future::Cache<String, MemCacheEntry>,
48
+ pub pending_last_used: Arc<DashMap<i32, ()>>,
49
+ }
50
+
51
+ pub static FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/Inter-Bold.ttf");
52
+
53
+ pub const SCHEMA_SQL: &[&str] = &[
54
+ "CREATE TABLE IF NOT EXISTS poster_meta (
55
+ cache_key TEXT PRIMARY KEY,
56
+ release_date TEXT,
57
+ created_at INTEGER NOT NULL,
58
+ updated_at INTEGER NOT NULL
59
+ )",
60
+ "CREATE TABLE IF NOT EXISTS admin_users (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ username TEXT NOT NULL UNIQUE,
63
+ password_hash TEXT NOT NULL,
64
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
65
+ )",
66
+ "CREATE TABLE IF NOT EXISTS refresh_tokens (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
69
+ token_hash TEXT NOT NULL UNIQUE,
70
+ expires_at TEXT NOT NULL,
71
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
72
+ )",
73
+ "CREATE TABLE IF NOT EXISTS api_keys (
74
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ name TEXT NOT NULL,
76
+ key_hash TEXT NOT NULL UNIQUE,
77
+ key_prefix TEXT NOT NULL,
78
+ created_by INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
79
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
80
+ last_used_at TEXT
81
+ )",
82
+ ];
83
+
84
+ pub fn build_app(state: Arc<AppState>) -> Router {
85
+ let admin_routes = routes::api_keys::api_key_routes()
86
+ .route(
87
+ "/api/auth/logout",
88
+ axum::routing::post(handlers::auth::logout),
89
+ )
90
+ .layer(middleware::from_fn_with_state(
91
+ state.clone(),
92
+ handlers::middleware::require_auth,
93
+ ));
94
+
95
+ let compressed_routes = Router::new()
96
+ .merge(routes::auth::auth_routes())
97
+ .merge(admin_routes)
98
+ .layer(CompressionLayer::new());
99
+
100
+ let mut app = Router::new()
101
+ .route(
102
+ "/{api_key}/{id_type}/poster-default/{id_value}",
103
+ axum::routing::get(routes::poster::handler),
104
+ )
105
+ .merge(compressed_routes);
106
+
107
+ // Serve static frontend files when STATIC_DIR is set.
108
+ // Falls back to index.html for SPA client-side routing.
109
+ if let Some(ref dir) = state.config.static_dir {
110
+ use tower_http::services::{ServeDir, ServeFile};
111
+ let index = format!("{dir}/index.html");
112
+ app = app.fallback_service(ServeDir::new(dir).fallback(ServeFile::new(index)));
113
+ }
114
+
115
+ app.layer(middleware::from_fn(
116
+ handlers::middleware::validate_origin,
117
+ ))
118
+ .layer(TraceLayer::new_for_http())
119
+ .layer(CorsLayer::permissive())
120
+ .with_state(state)
121
+ }
api/src/main.rs CHANGED
@@ -1,64 +1,29 @@
1
- mod cache;
2
- mod config;
3
- mod entity;
4
- mod error;
5
- mod handlers;
6
- mod id;
7
- mod poster;
8
- mod routes;
9
- mod services;
10
-
11
  use std::net::SocketAddr;
12
  use std::sync::Arc;
13
  use std::time::Duration;
14
 
15
  use ab_glyph::FontArc;
16
- use axum::middleware;
17
- use axum::Router;
18
  use dashmap::DashMap;
19
  use sea_orm::{ConnectionTrait, DatabaseConnection, SqlxSqliteConnector};
20
- use tower_http::compression::CompressionLayer;
21
- use tower_http::cors::CorsLayer;
22
- use tower_http::trace::TraceLayer;
23
  use tracing_subscriber::EnvFilter;
24
- use zeroize::Zeroizing;
25
-
26
- use cache::MemCacheEntry;
27
- use config::Config;
28
- use id::ResolvedId;
29
- use services::db;
30
- use services::mdblist::MdblistClient;
31
- use services::omdb::OmdbClient;
32
- use services::ratings::RatingBadge;
33
- use services::tmdb::TmdbClient;
34
 
35
- #[derive(Clone)]
36
- pub struct AppState {
37
- pub config: Config,
38
- pub tmdb: TmdbClient,
39
- pub omdb: Option<OmdbClient>,
40
- pub mdblist: Option<MdblistClient>,
41
- pub http: reqwest::Client,
42
- pub font: FontArc,
43
- pub refresh_locks: moka::sync::Cache<String, ()>,
44
- pub db: DatabaseConnection,
45
- pub jwt_secret: Zeroizing<Vec<u8>>,
46
- pub secure_cookies: bool,
47
- pub api_key_cache: moka::future::Cache<String, Option<i32>>,
48
- pub poster_inflight: moka::future::Cache<String, bytes::Bytes>,
49
- pub id_cache: moka::future::Cache<String, ResolvedId>,
50
- pub ratings_cache: moka::future::Cache<String, Vec<RatingBadge>>,
51
- pub poster_mem_cache: moka::future::Cache<String, MemCacheEntry>,
52
- pub pending_last_used: Arc<DashMap<i32, ()>>,
53
- }
54
-
55
- static FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/Inter-Bold.ttf");
56
 
57
  #[tokio::main]
58
  async fn main() {
59
  // Load .env file if present (ignored if missing)
60
  dotenvy::dotenv().ok();
61
 
 
 
 
62
  tracing_subscriber::fmt()
63
  .with_env_filter(
64
  EnvFilter::try_from_default_env()
@@ -110,54 +75,12 @@ async fn main() {
110
  .await
111
  .expect("failed to connect to database");
112
  let database: DatabaseConnection = SqlxSqliteConnector::from_sqlx_sqlite_pool(pool);
113
- database
114
- .execute_unprepared(
115
- "CREATE TABLE IF NOT EXISTS poster_meta (
116
- cache_key TEXT PRIMARY KEY,
117
- release_date TEXT,
118
- created_at INTEGER NOT NULL,
119
- updated_at INTEGER NOT NULL
120
- )",
121
- )
122
- .await
123
- .expect("failed to create poster_meta table");
124
- database
125
- .execute_unprepared(
126
- "CREATE TABLE IF NOT EXISTS admin_users (
127
- id INTEGER PRIMARY KEY AUTOINCREMENT,
128
- username TEXT NOT NULL UNIQUE,
129
- password_hash TEXT NOT NULL,
130
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
131
- )",
132
- )
133
- .await
134
- .expect("failed to create admin_users table");
135
- database
136
- .execute_unprepared(
137
- "CREATE TABLE IF NOT EXISTS refresh_tokens (
138
- id INTEGER PRIMARY KEY AUTOINCREMENT,
139
- user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
140
- token_hash TEXT NOT NULL UNIQUE,
141
- expires_at TEXT NOT NULL,
142
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
143
- )",
144
- )
145
- .await
146
- .expect("failed to create refresh_tokens table");
147
- database
148
- .execute_unprepared(
149
- "CREATE TABLE IF NOT EXISTS api_keys (
150
- id INTEGER PRIMARY KEY AUTOINCREMENT,
151
- name TEXT NOT NULL,
152
- key_hash TEXT NOT NULL UNIQUE,
153
- key_prefix TEXT NOT NULL,
154
- created_by INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
155
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
156
- last_used_at TEXT
157
- )",
158
- )
159
- .await
160
- .expect("failed to create api_keys table");
161
 
162
  // Clean up expired refresh tokens
163
  match db::delete_expired_refresh_tokens(&database).await {
@@ -270,35 +193,7 @@ async fn main() {
270
  });
271
  }
272
 
273
- // Admin-protected routes (JWT required)
274
- let admin_routes = routes::api_keys::api_key_routes()
275
- .route(
276
- "/api/auth/logout",
277
- axum::routing::post(handlers::auth::logout),
278
- )
279
- .layer(middleware::from_fn_with_state(
280
- state.clone(),
281
- handlers::middleware::require_auth,
282
- ));
283
-
284
- // Auth + admin routes get compression (JSON responses benefit)
285
- let compressed_routes = Router::new()
286
- .merge(routes::auth::auth_routes())
287
- .merge(admin_routes)
288
- .layer(CompressionLayer::new());
289
-
290
- let app = Router::new()
291
- .route(
292
- "/{api_key}/{id_type}/poster-default/{id_value}",
293
- axum::routing::get(routes::poster::handler),
294
- )
295
- .merge(compressed_routes)
296
- .layer(middleware::from_fn(
297
- handlers::middleware::validate_origin,
298
- ))
299
- .layer(TraceLayer::new_for_http())
300
- .layer(CorsLayer::permissive())
301
- .with_state(state.clone());
302
 
303
  let listener = tokio::net::TcpListener::bind(&config.listen_addr)
304
  .await
 
 
 
 
 
 
 
 
 
 
 
1
  use std::net::SocketAddr;
2
  use std::sync::Arc;
3
  use std::time::Duration;
4
 
5
  use ab_glyph::FontArc;
 
 
6
  use dashmap::DashMap;
7
  use sea_orm::{ConnectionTrait, DatabaseConnection, SqlxSqliteConnector};
 
 
 
8
  use tracing_subscriber::EnvFilter;
 
 
 
 
 
 
 
 
 
 
9
 
10
+ use openposterdb_api::cache::MemCacheEntry;
11
+ use openposterdb_api::config::Config;
12
+ use openposterdb_api::handlers;
13
+ use openposterdb_api::services::db;
14
+ use openposterdb_api::services::mdblist::MdblistClient;
15
+ use openposterdb_api::services::omdb::OmdbClient;
16
+ use openposterdb_api::services::tmdb::TmdbClient;
17
+ use openposterdb_api::{build_app, AppState, FONT_BYTES, SCHEMA_SQL};
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  #[tokio::main]
20
  async fn main() {
21
  // Load .env file if present (ignored if missing)
22
  dotenvy::dotenv().ok();
23
 
24
+ #[cfg(feature = "test-support")]
25
+ eprintln!("WARNING: test-support feature is enabled — rate limiting is disabled. Do not use in production.");
26
+
27
  tracing_subscriber::fmt()
28
  .with_env_filter(
29
  EnvFilter::try_from_default_env()
 
75
  .await
76
  .expect("failed to connect to database");
77
  let database: DatabaseConnection = SqlxSqliteConnector::from_sqlx_sqlite_pool(pool);
78
+ for sql in SCHEMA_SQL {
79
+ database
80
+ .execute_unprepared(sql)
81
+ .await
82
+ .expect("failed to create table");
83
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  // Clean up expired refresh tokens
86
  match db::delete_expired_refresh_tokens(&database).await {
 
193
  });
194
  }
195
 
196
+ let app = build_app(state.clone());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
  let listener = tokio::net::TcpListener::bind(&config.listen_addr)
199
  .await
api/src/routes/auth.rs CHANGED
@@ -12,7 +12,7 @@ pub fn auth_routes() -> Router<Arc<AppState>> {
12
  .route("/api/auth/login", post(handlers::auth::login))
13
  .route("/api/auth/refresh", post(handlers::auth::refresh));
14
 
15
- #[cfg(not(test))]
16
  let rate_limited = {
17
  use axum::http::StatusCode;
18
  use axum::response::IntoResponse;
 
12
  .route("/api/auth/login", post(handlers::auth::login))
13
  .route("/api/auth/refresh", post(handlers::auth::refresh));
14
 
15
+ #[cfg(not(any(test, feature = "test-support")))]
16
  let rate_limited = {
17
  use axum::http::StatusCode;
18
  use axum::response::IntoResponse;
api/src/services/ratings.rs CHANGED
@@ -192,6 +192,45 @@ async fn fetch_omdb_ratings(imdb_id: Option<&str>, omdb: Option<&OmdbClient>) ->
192
  Some(badges)
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  async fn fetch_mdblist_ratings(
196
  resolved: &ResolvedId,
197
  mdblist: Option<&MdblistClient>,
 
192
  Some(badges)
193
  }
194
 
195
+ #[cfg(test)]
196
+ mod tests {
197
+ use super::*;
198
+
199
+ #[test]
200
+ fn rating_source_labels() {
201
+ assert_eq!(RatingSource::Imdb.label(), "IMDb");
202
+ assert_eq!(RatingSource::Tmdb.label(), "TMDB");
203
+ assert_eq!(RatingSource::Rt.label(), "RTC");
204
+ assert_eq!(RatingSource::RtAudience.label(), "RTA");
205
+ assert_eq!(RatingSource::Metacritic.label(), "MC");
206
+ assert_eq!(RatingSource::Trakt.label(), "Trakt");
207
+ assert_eq!(RatingSource::Letterboxd.label(), "LB");
208
+ assert_eq!(RatingSource::Mal.label(), "MAL");
209
+ }
210
+
211
+ #[test]
212
+ fn rating_source_colors_unique_per_source() {
213
+ assert_eq!(RatingSource::Imdb.color(), Rgba([180, 145, 15, 255]));
214
+ assert_eq!(RatingSource::Tmdb.color(), Rgba([1, 155, 88, 255]));
215
+ assert_eq!(RatingSource::Rt.color(), Rgba([185, 35, 8, 255]));
216
+ assert_eq!(RatingSource::Metacritic.color(), Rgba([75, 150, 38, 255]));
217
+ assert_eq!(RatingSource::Trakt.color(), Rgba([175, 15, 45, 255]));
218
+ assert_eq!(RatingSource::Letterboxd.color(), Rgba([0, 155, 88, 255]));
219
+ assert_eq!(RatingSource::Mal.color(), Rgba([34, 60, 120, 255]));
220
+ }
221
+
222
+ #[test]
223
+ fn rt_and_rt_audience_share_color() {
224
+ assert_eq!(RatingSource::Rt.color(), RatingSource::RtAudience.color());
225
+ }
226
+
227
+ #[test]
228
+ fn rating_source_equality() {
229
+ assert_eq!(RatingSource::Imdb, RatingSource::Imdb);
230
+ assert_ne!(RatingSource::Imdb, RatingSource::Tmdb);
231
+ }
232
+ }
233
+
234
  async fn fetch_mdblist_ratings(
235
  resolved: &ResolvedId,
236
  mdblist: Option<&MdblistClient>,
api/src/services/validation.rs CHANGED
@@ -24,3 +24,68 @@ pub fn validate_password(password: &str) -> Result<(), AppError> {
24
  }
25
  Ok(())
26
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
  Ok(())
26
  }
27
+
28
+ #[cfg(test)]
29
+ mod tests {
30
+ use super::*;
31
+
32
+ #[test]
33
+ fn validate_username_empty() {
34
+ assert!(validate_username("").is_err());
35
+ }
36
+
37
+ #[test]
38
+ fn validate_username_whitespace() {
39
+ assert!(validate_username("hello world").is_err());
40
+ }
41
+
42
+ #[test]
43
+ fn validate_username_tab() {
44
+ assert!(validate_username("hello\tworld").is_err());
45
+ }
46
+
47
+ #[test]
48
+ fn validate_username_control_chars() {
49
+ assert!(validate_username("hello\x00world").is_err());
50
+ }
51
+
52
+ #[test]
53
+ fn validate_username_valid() {
54
+ assert!(validate_username("admin").is_ok());
55
+ }
56
+
57
+ #[test]
58
+ fn validate_username_valid_with_special_chars() {
59
+ assert!(validate_username("admin-user_1").is_ok());
60
+ }
61
+
62
+ #[test]
63
+ fn validate_password_empty() {
64
+ assert!(validate_password("").is_err());
65
+ }
66
+
67
+ #[test]
68
+ fn validate_password_too_short() {
69
+ assert!(validate_password("short").is_err());
70
+ }
71
+
72
+ #[test]
73
+ fn validate_password_seven_chars() {
74
+ assert!(validate_password("1234567").is_err());
75
+ }
76
+
77
+ #[test]
78
+ fn validate_password_control_chars() {
79
+ assert!(validate_password("password\x01long_enough").is_err());
80
+ }
81
+
82
+ #[test]
83
+ fn validate_password_valid() {
84
+ assert!(validate_password("password123").is_ok());
85
+ }
86
+
87
+ #[test]
88
+ fn validate_password_exactly_8_chars() {
89
+ assert!(validate_password("12345678").is_ok());
90
+ }
91
+ }
api/tests/api_keys.rs ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod common;
2
+
3
+ use axum::body::Body;
4
+ use axum::http::{Request, StatusCode};
5
+ use http_body_util::BodyExt;
6
+ use tower::ServiceExt;
7
+
8
+ fn json_body(json: serde_json::Value) -> Body {
9
+ Body::from(json.to_string())
10
+ }
11
+
12
+ #[tokio::test]
13
+ async fn create_key_authenticated() {
14
+ let (app, _state) = common::setup_test_app().await;
15
+ let token = common::setup_admin(&app).await;
16
+
17
+ let req = Request::builder()
18
+ .method("POST")
19
+ .uri("/api/keys")
20
+ .header("content-type", "application/json")
21
+ .header("authorization", format!("Bearer {token}"))
22
+ .body(json_body(serde_json::json!({"name": "test-key"})))
23
+ .unwrap();
24
+
25
+ let res = app.oneshot(req).await.unwrap();
26
+ assert_eq!(res.status(), StatusCode::OK);
27
+
28
+ let body = res.into_body().collect().await.unwrap().to_bytes();
29
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
30
+ assert!(json["key"].is_string());
31
+ assert!(json["key_prefix"].is_string());
32
+ assert_eq!(json["name"], "test-key");
33
+ }
34
+
35
+ #[tokio::test]
36
+ async fn list_keys_authenticated() {
37
+ let (app, _state) = common::setup_test_app().await;
38
+ let token = common::setup_admin(&app).await;
39
+
40
+ // Create a key first
41
+ let req = Request::builder()
42
+ .method("POST")
43
+ .uri("/api/keys")
44
+ .header("content-type", "application/json")
45
+ .header("authorization", format!("Bearer {token}"))
46
+ .body(json_body(serde_json::json!({"name": "my-key"})))
47
+ .unwrap();
48
+ app.clone().oneshot(req).await.unwrap();
49
+
50
+ // List keys
51
+ let req = Request::builder()
52
+ .uri("/api/keys")
53
+ .header("authorization", format!("Bearer {token}"))
54
+ .body(Body::empty())
55
+ .unwrap();
56
+
57
+ let res = app.oneshot(req).await.unwrap();
58
+ assert_eq!(res.status(), StatusCode::OK);
59
+
60
+ let body = res.into_body().collect().await.unwrap().to_bytes();
61
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
62
+ let keys = json.as_array().unwrap();
63
+ assert_eq!(keys.len(), 1);
64
+ assert_eq!(keys[0]["name"], "my-key");
65
+ }
66
+
67
+ #[tokio::test]
68
+ async fn delete_key_authenticated() {
69
+ let (app, _state) = common::setup_test_app().await;
70
+ let token = common::setup_admin(&app).await;
71
+
72
+ // Create a key
73
+ let req = Request::builder()
74
+ .method("POST")
75
+ .uri("/api/keys")
76
+ .header("content-type", "application/json")
77
+ .header("authorization", format!("Bearer {token}"))
78
+ .body(json_body(serde_json::json!({"name": "to-delete"})))
79
+ .unwrap();
80
+
81
+ let res = app.clone().oneshot(req).await.unwrap();
82
+ let body = res.into_body().collect().await.unwrap().to_bytes();
83
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
84
+ let id = json["id"].as_i64().unwrap();
85
+
86
+ // Delete the key
87
+ let req = Request::builder()
88
+ .method("DELETE")
89
+ .uri(format!("/api/keys/{id}"))
90
+ .header("authorization", format!("Bearer {token}"))
91
+ .body(Body::empty())
92
+ .unwrap();
93
+
94
+ let res = app.clone().oneshot(req).await.unwrap();
95
+ assert_eq!(res.status(), StatusCode::OK);
96
+
97
+ // Verify it's gone
98
+ let req = Request::builder()
99
+ .uri("/api/keys")
100
+ .header("authorization", format!("Bearer {token}"))
101
+ .body(Body::empty())
102
+ .unwrap();
103
+
104
+ let res = app.oneshot(req).await.unwrap();
105
+ let body = res.into_body().collect().await.unwrap().to_bytes();
106
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
107
+ assert_eq!(json.as_array().unwrap().len(), 0);
108
+ }
109
+
110
+ #[tokio::test]
111
+ async fn endpoints_without_auth_return_401() {
112
+ let (app, _state) = common::setup_test_app().await;
113
+
114
+ // GET /api/keys without auth
115
+ let req = Request::builder()
116
+ .uri("/api/keys")
117
+ .body(Body::empty())
118
+ .unwrap();
119
+ let res = app.clone().oneshot(req).await.unwrap();
120
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
121
+
122
+ // POST /api/keys without auth
123
+ let req = Request::builder()
124
+ .method("POST")
125
+ .uri("/api/keys")
126
+ .header("content-type", "application/json")
127
+ .body(json_body(serde_json::json!({"name": "test"})))
128
+ .unwrap();
129
+ let res = app.clone().oneshot(req).await.unwrap();
130
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
131
+
132
+ // DELETE /api/keys/1 without auth
133
+ let req = Request::builder()
134
+ .method("DELETE")
135
+ .uri("/api/keys/1")
136
+ .body(Body::empty())
137
+ .unwrap();
138
+ let res = app.oneshot(req).await.unwrap();
139
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
140
+ }
141
+
142
+ #[tokio::test]
143
+ async fn create_key_with_empty_name() {
144
+ let (app, _state) = common::setup_test_app().await;
145
+ let token = common::setup_admin(&app).await;
146
+
147
+ let req = Request::builder()
148
+ .method("POST")
149
+ .uri("/api/keys")
150
+ .header("content-type", "application/json")
151
+ .header("authorization", format!("Bearer {token}"))
152
+ .body(json_body(serde_json::json!({"name": ""})))
153
+ .unwrap();
154
+
155
+ let res = app.oneshot(req).await.unwrap();
156
+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
157
+ }
api/tests/auth_flow.rs ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod common;
2
+
3
+ use axum::body::Body;
4
+ use axum::http::{Request, StatusCode};
5
+ use http_body_util::BodyExt;
6
+ use tower::ServiceExt;
7
+
8
+ fn json_body(json: serde_json::Value) -> Body {
9
+ Body::from(json.to_string())
10
+ }
11
+
12
+ #[tokio::test]
13
+ async fn setup_creates_admin_and_returns_token() {
14
+ let (app, _state) = common::setup_test_app().await;
15
+
16
+ let req = Request::builder()
17
+ .method("POST")
18
+ .uri("/api/auth/setup")
19
+ .header("content-type", "application/json")
20
+ .body(json_body(serde_json::json!({
21
+ "username": "admin",
22
+ "password": "testpassword123"
23
+ })))
24
+ .unwrap();
25
+
26
+ let res = app.oneshot(req).await.unwrap();
27
+ assert_eq!(res.status(), StatusCode::OK);
28
+
29
+ let set_cookie = common::extract_set_cookie(res.headers());
30
+ assert!(set_cookie.is_some(), "should set refresh cookie");
31
+
32
+ let body = res.into_body().collect().await.unwrap().to_bytes();
33
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
34
+ assert!(json["token"].is_string());
35
+ }
36
+
37
+ #[tokio::test]
38
+ async fn setup_fails_when_admin_exists() {
39
+ let (app, _state) = common::setup_test_app().await;
40
+
41
+ // First setup succeeds
42
+ common::setup_admin(&app).await;
43
+
44
+ // Second setup fails
45
+ let req = Request::builder()
46
+ .method("POST")
47
+ .uri("/api/auth/setup")
48
+ .header("content-type", "application/json")
49
+ .body(json_body(serde_json::json!({
50
+ "username": "admin2",
51
+ "password": "testpassword123"
52
+ })))
53
+ .unwrap();
54
+
55
+ let res = app.oneshot(req).await.unwrap();
56
+ assert_eq!(res.status(), StatusCode::FORBIDDEN);
57
+ }
58
+
59
+ #[tokio::test]
60
+ async fn login_with_valid_credentials() {
61
+ let (app, _state) = common::setup_test_app().await;
62
+ common::setup_admin(&app).await;
63
+
64
+ let req = Request::builder()
65
+ .method("POST")
66
+ .uri("/api/auth/login")
67
+ .header("content-type", "application/json")
68
+ .body(json_body(serde_json::json!({
69
+ "username": "admin",
70
+ "password": "testpassword123"
71
+ })))
72
+ .unwrap();
73
+
74
+ let res = app.oneshot(req).await.unwrap();
75
+ assert_eq!(res.status(), StatusCode::OK);
76
+
77
+ let body = res.into_body().collect().await.unwrap().to_bytes();
78
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
79
+ assert!(json["token"].is_string());
80
+ }
81
+
82
+ #[tokio::test]
83
+ async fn login_with_wrong_password() {
84
+ let (app, _state) = common::setup_test_app().await;
85
+ common::setup_admin(&app).await;
86
+
87
+ let req = Request::builder()
88
+ .method("POST")
89
+ .uri("/api/auth/login")
90
+ .header("content-type", "application/json")
91
+ .body(json_body(serde_json::json!({
92
+ "username": "admin",
93
+ "password": "wrongpassword"
94
+ })))
95
+ .unwrap();
96
+
97
+ let res = app.oneshot(req).await.unwrap();
98
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
99
+ }
100
+
101
+ #[tokio::test]
102
+ async fn login_with_unknown_user() {
103
+ let (app, _state) = common::setup_test_app().await;
104
+ common::setup_admin(&app).await;
105
+
106
+ let req = Request::builder()
107
+ .method("POST")
108
+ .uri("/api/auth/login")
109
+ .header("content-type", "application/json")
110
+ .body(json_body(serde_json::json!({
111
+ "username": "nonexistent",
112
+ "password": "testpassword123"
113
+ })))
114
+ .unwrap();
115
+
116
+ let res = app.oneshot(req).await.unwrap();
117
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
118
+ }
119
+
120
+ #[tokio::test]
121
+ async fn refresh_with_valid_cookie() {
122
+ let (app, _state) = common::setup_test_app().await;
123
+
124
+ // Setup to get refresh token
125
+ let req = Request::builder()
126
+ .method("POST")
127
+ .uri("/api/auth/setup")
128
+ .header("content-type", "application/json")
129
+ .body(json_body(serde_json::json!({
130
+ "username": "admin",
131
+ "password": "testpassword123"
132
+ })))
133
+ .unwrap();
134
+
135
+ let res = app.clone().oneshot(req).await.unwrap();
136
+ let set_cookie = common::extract_set_cookie(res.headers()).unwrap();
137
+ let refresh_token = common::extract_refresh_token(&set_cookie).unwrap();
138
+
139
+ // Use refresh token
140
+ let req = Request::builder()
141
+ .method("POST")
142
+ .uri("/api/auth/refresh")
143
+ .header("cookie", format!("refresh_token={refresh_token}"))
144
+ .body(Body::empty())
145
+ .unwrap();
146
+
147
+ let res = app.oneshot(req).await.unwrap();
148
+ assert_eq!(res.status(), StatusCode::OK);
149
+
150
+ // Should get new tokens
151
+ let new_set_cookie = common::extract_set_cookie(res.headers());
152
+ assert!(new_set_cookie.is_some(), "should set new refresh cookie");
153
+
154
+ let body = res.into_body().collect().await.unwrap().to_bytes();
155
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
156
+ assert!(json["token"].is_string());
157
+ }
158
+
159
+ #[tokio::test]
160
+ async fn refresh_token_rotation_invalidates_old() {
161
+ let (app, _state) = common::setup_test_app().await;
162
+
163
+ // Setup to get refresh token
164
+ let req = Request::builder()
165
+ .method("POST")
166
+ .uri("/api/auth/setup")
167
+ .header("content-type", "application/json")
168
+ .body(json_body(serde_json::json!({
169
+ "username": "admin",
170
+ "password": "testpassword123"
171
+ })))
172
+ .unwrap();
173
+
174
+ let res = app.clone().oneshot(req).await.unwrap();
175
+ let set_cookie = common::extract_set_cookie(res.headers()).unwrap();
176
+ let old_refresh = common::extract_refresh_token(&set_cookie).unwrap();
177
+
178
+ // Use refresh token (first time — should work)
179
+ let req = Request::builder()
180
+ .method("POST")
181
+ .uri("/api/auth/refresh")
182
+ .header("cookie", format!("refresh_token={old_refresh}"))
183
+ .body(Body::empty())
184
+ .unwrap();
185
+
186
+ let res = app.clone().oneshot(req).await.unwrap();
187
+ assert_eq!(res.status(), StatusCode::OK);
188
+
189
+ // Use same old refresh token again — should fail (rotated)
190
+ let req = Request::builder()
191
+ .method("POST")
192
+ .uri("/api/auth/refresh")
193
+ .header("cookie", format!("refresh_token={old_refresh}"))
194
+ .body(Body::empty())
195
+ .unwrap();
196
+
197
+ let res = app.oneshot(req).await.unwrap();
198
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
199
+ }
200
+
201
+ #[tokio::test]
202
+ async fn refresh_without_cookie_returns_401() {
203
+ let (app, _state) = common::setup_test_app().await;
204
+
205
+ let req = Request::builder()
206
+ .method("POST")
207
+ .uri("/api/auth/refresh")
208
+ .body(Body::empty())
209
+ .unwrap();
210
+
211
+ let res = app.oneshot(req).await.unwrap();
212
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
213
+ }
214
+
215
+ #[tokio::test]
216
+ async fn logout_clears_tokens() {
217
+ let (app, _state) = common::setup_test_app().await;
218
+ let token = common::setup_admin(&app).await;
219
+
220
+ let req = Request::builder()
221
+ .method("POST")
222
+ .uri("/api/auth/logout")
223
+ .header("authorization", format!("Bearer {token}"))
224
+ .body(Body::empty())
225
+ .unwrap();
226
+
227
+ let res = app.oneshot(req).await.unwrap();
228
+ assert_eq!(res.status(), StatusCode::OK);
229
+
230
+ let set_cookie = common::extract_set_cookie(res.headers()).unwrap();
231
+ assert!(set_cookie.contains("Max-Age=0"), "should clear cookie");
232
+ }
233
+
234
+ #[tokio::test]
235
+ async fn auth_status_setup_required_true() {
236
+ let (app, _state) = common::setup_test_app().await;
237
+
238
+ let req = Request::builder()
239
+ .uri("/api/auth/status")
240
+ .body(Body::empty())
241
+ .unwrap();
242
+
243
+ let res = app.oneshot(req).await.unwrap();
244
+ assert_eq!(res.status(), StatusCode::OK);
245
+
246
+ let body = res.into_body().collect().await.unwrap().to_bytes();
247
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
248
+ assert_eq!(json["setup_required"], true);
249
+ }
250
+
251
+ #[tokio::test]
252
+ async fn auth_status_setup_required_false() {
253
+ let (app, _state) = common::setup_test_app().await;
254
+ common::setup_admin(&app).await;
255
+
256
+ let req = Request::builder()
257
+ .uri("/api/auth/status")
258
+ .body(Body::empty())
259
+ .unwrap();
260
+
261
+ let res = app.oneshot(req).await.unwrap();
262
+ assert_eq!(res.status(), StatusCode::OK);
263
+
264
+ let body = res.into_body().collect().await.unwrap().to_bytes();
265
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
266
+ assert_eq!(json["setup_required"], false);
267
+ }
api/tests/common/mod.rs ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+ use std::time::Duration;
3
+
4
+ use ab_glyph::FontArc;
5
+ use dashmap::DashMap;
6
+ use sea_orm::{ConnectionTrait, DatabaseConnection, SqlxSqliteConnector};
7
+ use zeroize::Zeroizing;
8
+
9
+ use openposterdb_api::config::Config;
10
+ use openposterdb_api::services::tmdb::TmdbClient;
11
+ use openposterdb_api::{build_app, AppState, FONT_BYTES, SCHEMA_SQL};
12
+
13
+ pub async fn setup_test_app() -> (axum::Router, Arc<AppState>) {
14
+ let sqlite_opts = sqlx::sqlite::SqliteConnectOptions::new()
15
+ .filename(":memory:")
16
+ .create_if_missing(true);
17
+ let pool = sqlx::sqlite::SqlitePoolOptions::new()
18
+ .max_connections(1)
19
+ .connect_with(sqlite_opts)
20
+ .await
21
+ .expect("failed to connect to test database");
22
+ let db: DatabaseConnection = SqlxSqliteConnector::from_sqlx_sqlite_pool(pool);
23
+
24
+ for sql in SCHEMA_SQL {
25
+ db.execute_unprepared(sql).await.unwrap();
26
+ }
27
+
28
+ let jwt_secret = Zeroizing::new(vec![0xAB; 32]);
29
+ let http = reqwest::Client::new();
30
+ let font = FontArc::try_from_slice(FONT_BYTES).expect("failed to load font");
31
+
32
+ let api_key_cache = moka::future::Cache::builder()
33
+ .max_capacity(100)
34
+ .time_to_live(Duration::from_secs(300))
35
+ .build();
36
+ let poster_inflight = moka::future::Cache::builder()
37
+ .max_capacity(100)
38
+ .time_to_live(Duration::from_secs(30))
39
+ .build();
40
+ let id_cache = moka::future::Cache::builder()
41
+ .max_capacity(100)
42
+ .time_to_live(Duration::from_secs(3600))
43
+ .build();
44
+ let ratings_cache = moka::future::Cache::builder()
45
+ .max_capacity(100)
46
+ .time_to_live(Duration::from_secs(1800))
47
+ .build();
48
+ let poster_mem_cache = moka::future::Cache::builder()
49
+ .max_capacity(1024 * 1024)
50
+ .time_to_live(Duration::from_secs(3600))
51
+ .build();
52
+ let refresh_locks = moka::sync::Cache::builder()
53
+ .max_capacity(100)
54
+ .time_to_live(Duration::from_secs(300))
55
+ .build();
56
+
57
+ let state = Arc::new(AppState {
58
+ config: Config {
59
+ tmdb_api_key: "test".into(),
60
+ omdb_api_key: None,
61
+ cache_dir: "/tmp/openposterdb-test".into(),
62
+ listen_addr: "127.0.0.1:0".into(),
63
+ ratings_min_stale_secs: 86400,
64
+ ratings_max_age_secs: 31_536_000,
65
+ poster_stale_secs: 0,
66
+ poster_quality: 85,
67
+ mdblist_api_key: None,
68
+ poster_mem_cache_mb: 1,
69
+ static_dir: None,
70
+ },
71
+ tmdb: TmdbClient::new("test".into(), http.clone()),
72
+ omdb: None,
73
+ mdblist: None,
74
+ http,
75
+ font,
76
+ refresh_locks,
77
+ db,
78
+ jwt_secret,
79
+ secure_cookies: false,
80
+ api_key_cache,
81
+ poster_inflight,
82
+ id_cache,
83
+ ratings_cache,
84
+ poster_mem_cache,
85
+ pending_last_used: Arc::new(DashMap::new()),
86
+ });
87
+
88
+ let app = build_app(state.clone());
89
+ (app, state)
90
+ }
91
+
92
+ /// Helper to perform setup and get back an access token.
93
+ pub async fn setup_admin(app: &axum::Router) -> String {
94
+ use axum::body::Body;
95
+ use axum::http::{Request, StatusCode};
96
+ use http_body_util::BodyExt;
97
+ use tower::ServiceExt;
98
+
99
+ let req = Request::builder()
100
+ .method("POST")
101
+ .uri("/api/auth/setup")
102
+ .header("content-type", "application/json")
103
+ .body(Body::from(
104
+ serde_json::json!({"username": "admin", "password": "testpassword123"}).to_string(),
105
+ ))
106
+ .unwrap();
107
+
108
+ let res = app.clone().oneshot(req).await.unwrap();
109
+ assert_eq!(res.status(), StatusCode::OK);
110
+
111
+ let body = res.into_body().collect().await.unwrap().to_bytes();
112
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
113
+ json["token"].as_str().unwrap().to_string()
114
+ }
115
+
116
+ /// Extract Set-Cookie header value from a response.
117
+ pub fn extract_set_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
118
+ headers
119
+ .get(axum::http::header::SET_COOKIE)
120
+ .and_then(|v| v.to_str().ok())
121
+ .map(|s| s.to_string())
122
+ }
123
+
124
+ /// Extract the refresh_token value from a Set-Cookie header.
125
+ pub fn extract_refresh_token(set_cookie: &str) -> Option<String> {
126
+ set_cookie
127
+ .split(';')
128
+ .next()
129
+ .and_then(|s| s.strip_prefix("refresh_token="))
130
+ .map(|s| s.to_string())
131
+ }
api/tests/middleware.rs ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod common;
2
+
3
+ use axum::body::Body;
4
+ use axum::http::{Request, StatusCode};
5
+ use http_body_util::BodyExt;
6
+ use tower::ServiceExt;
7
+
8
+ #[tokio::test]
9
+ async fn origin_validation_get_without_origin_allowed() {
10
+ let (app, _state) = common::setup_test_app().await;
11
+
12
+ let req = Request::builder()
13
+ .uri("/api/auth/status")
14
+ .body(Body::empty())
15
+ .unwrap();
16
+
17
+ let res = app.oneshot(req).await.unwrap();
18
+ // GET without origin should pass (no CORS_ORIGIN env set in tests)
19
+ assert_eq!(res.status(), StatusCode::OK);
20
+ }
21
+
22
+ #[tokio::test]
23
+ async fn auth_middleware_missing_header() {
24
+ let (app, _state) = common::setup_test_app().await;
25
+
26
+ let req = Request::builder()
27
+ .uri("/api/keys")
28
+ .body(Body::empty())
29
+ .unwrap();
30
+
31
+ let res = app.oneshot(req).await.unwrap();
32
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
33
+ }
34
+
35
+ #[tokio::test]
36
+ async fn auth_middleware_invalid_token() {
37
+ let (app, _state) = common::setup_test_app().await;
38
+
39
+ let req = Request::builder()
40
+ .uri("/api/keys")
41
+ .header("authorization", "Bearer invalid.jwt.token")
42
+ .body(Body::empty())
43
+ .unwrap();
44
+
45
+ let res = app.oneshot(req).await.unwrap();
46
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
47
+ }
48
+
49
+ #[tokio::test]
50
+ async fn auth_middleware_missing_bearer_prefix() {
51
+ let (app, _state) = common::setup_test_app().await;
52
+
53
+ let req = Request::builder()
54
+ .uri("/api/keys")
55
+ .header("authorization", "NotBearer sometoken")
56
+ .body(Body::empty())
57
+ .unwrap();
58
+
59
+ let res = app.oneshot(req).await.unwrap();
60
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
61
+ }
62
+
63
+ #[tokio::test]
64
+ async fn auth_middleware_valid_token_passes() {
65
+ let (app, _state) = common::setup_test_app().await;
66
+ let token = common::setup_admin(&app).await;
67
+
68
+ let req = Request::builder()
69
+ .uri("/api/keys")
70
+ .header("authorization", format!("Bearer {token}"))
71
+ .body(Body::empty())
72
+ .unwrap();
73
+
74
+ let res = app.oneshot(req).await.unwrap();
75
+ assert_eq!(res.status(), StatusCode::OK);
76
+ }
77
+
78
+ #[tokio::test]
79
+ async fn poster_endpoint_rejects_invalid_api_key() {
80
+ let (app, _state) = common::setup_test_app().await;
81
+
82
+ let req = Request::builder()
83
+ .uri("/bogus-key/imdb/poster-default/tt0000001.jpg")
84
+ .body(Body::empty())
85
+ .unwrap();
86
+
87
+ let res = app.oneshot(req).await.unwrap();
88
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
89
+ }
90
+
91
+ #[tokio::test]
92
+ async fn poster_endpoint_accepts_valid_api_key() {
93
+ let (app, _state) = common::setup_test_app().await;
94
+ let token = common::setup_admin(&app).await;
95
+
96
+ // Create an API key via the admin endpoint
97
+ let req = Request::builder()
98
+ .method("POST")
99
+ .uri("/api/keys")
100
+ .header("content-type", "application/json")
101
+ .header("authorization", format!("Bearer {token}"))
102
+ .body(Body::from(r#"{"name":"poster-test"}"#))
103
+ .unwrap();
104
+
105
+ let res = app.clone().oneshot(req).await.unwrap();
106
+ assert_eq!(res.status(), StatusCode::OK);
107
+ let body = res.into_body().collect().await.unwrap().to_bytes();
108
+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
109
+ let api_key = json["key"].as_str().unwrap();
110
+
111
+ // Use the API key on the poster endpoint — the TMDB call will fail since
112
+ // we use a fake API key, but the response should NOT be 401 (key is valid).
113
+ let req = Request::builder()
114
+ .uri(format!("/{api_key}/imdb/poster-default/tt0000001.jpg"))
115
+ .body(Body::empty())
116
+ .unwrap();
117
+
118
+ let res = app.oneshot(req).await.unwrap();
119
+ assert_ne!(res.status(), StatusCode::UNAUTHORIZED, "valid API key should not return 401");
120
+ }
121
+
122
+ #[tokio::test]
123
+ async fn logout_requires_auth() {
124
+ let (app, _state) = common::setup_test_app().await;
125
+
126
+ let req = Request::builder()
127
+ .method("POST")
128
+ .uri("/api/auth/logout")
129
+ .body(Body::empty())
130
+ .unwrap();
131
+
132
+ let res = app.oneshot(req).await.unwrap();
133
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
134
+ }
docker-compose.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Example docker-compose for OpenPosterDB.
2
+ #
3
+ # Copy this file and create a .env alongside it with the required values:
4
+ #
5
+ # TMDB_API_KEY=<your key from https://www.themoviedb.org/settings/api>
6
+ # JWT_SECRET=<64 hex chars — generate with: openssl rand -hex 32>
7
+ #
8
+ # At least one of OMDB_API_KEY or MDBLIST_API_KEY is also required for ratings:
9
+ #
10
+ # OMDB_API_KEY=<your key from https://www.omdbapi.com/apikey.aspx>
11
+ # MDBLIST_API_KEY=<your key from https://mdblist.com/preferences/>
12
+ #
13
+ # Then run:
14
+ #
15
+ # docker compose up -d
16
+ #
17
+ # The web UI will be available at http://localhost:3000.
18
+ # On first visit you'll be prompted to create an admin account.
19
+ #
20
+ # Optional settings:
21
+ #
22
+ # COOKIE_SECURE=false — Set this if you access the UI over plain HTTP
23
+ # (e.g. localhost without a reverse proxy).
24
+ # Defaults to true, which requires HTTPS — the
25
+ # browser will silently ignore auth cookies
26
+ # without it.
27
+
28
+ services:
29
+ openposterdb:
30
+ build: .
31
+ ports:
32
+ - "3000:3000"
33
+ environment:
34
+ TMDB_API_KEY: ${TMDB_API_KEY:?Set TMDB_API_KEY in .env}
35
+ JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env — generate with: openssl rand -hex 32}
36
+ OMDB_API_KEY: ${OMDB_API_KEY:-}
37
+ MDBLIST_API_KEY: ${MDBLIST_API_KEY:-}
38
+ COOKIE_SECURE: ${COOKIE_SECURE:-true}
39
+ LISTEN_ADDR: 0.0.0.0:3000
40
+ CACHE_DIR: /data/cache
41
+ STATIC_DIR: /app/dist
42
+ volumes:
43
+ - openposterdb-data:/data
44
+ restart: unless-stopped
45
+
46
+ volumes:
47
+ openposterdb-data:
scripts/test.sh ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ IMAGE_NAME="openposterdb-test"
7
+ CONTAINER_NAME="openposterdb-test"
8
+
9
+ if command -v podman &>/dev/null; then
10
+ CTR=podman
11
+ elif command -v docker &>/dev/null; then
12
+ CTR=docker
13
+ else
14
+ echo "Error: neither podman nor docker found" >&2
15
+ exit 1
16
+ fi
17
+
18
+ echo "=== Backend tests ==="
19
+ (cd api && cargo test)
20
+
21
+ echo ""
22
+ echo "=== Frontend unit tests ==="
23
+ (cd web && npx vitest run)
24
+
25
+ echo ""
26
+ echo "=== E2E tests ==="
27
+
28
+ cleanup() {
29
+ echo "=== Tearing down ==="
30
+ $CTR rm -f "$CONTAINER_NAME" 2>/dev/null || true
31
+ }
32
+ trap cleanup EXIT
33
+
34
+ echo "Building container image..."
35
+ $CTR build -t "$IMAGE_NAME" --build-arg CARGO_FEATURES=test-support -f Containerfile .
36
+
37
+ echo "Starting container..."
38
+ $CTR rm -f "$CONTAINER_NAME" 2>/dev/null || true
39
+ $CTR run -d --name "$CONTAINER_NAME" \
40
+ -p 3333:3000 \
41
+ --tmpfs /tmp/openposterdb-e2e \
42
+ -e TMDB_API_KEY=test \
43
+ -e MDBLIST_API_KEY=test \
44
+ -e JWT_SECRET=abababababababababababababababababababababababababababababababab \
45
+ -e LISTEN_ADDR=0.0.0.0:3000 \
46
+ -e COOKIE_SECURE=false \
47
+ -e CACHE_DIR=/tmp/openposterdb-e2e \
48
+ "$IMAGE_NAME"
49
+
50
+ echo "Waiting for backend..."
51
+ for i in $(seq 1 60); do
52
+ if curl -sf http://127.0.0.1:3333/api/auth/status > /dev/null 2>&1; then
53
+ echo "Backend ready"
54
+ break
55
+ fi
56
+ if [ "$i" -eq 60 ]; then
57
+ echo "Backend did not start within 60 seconds"
58
+ $CTR logs "$CONTAINER_NAME"
59
+ exit 1
60
+ fi
61
+ sleep 1
62
+ done
63
+
64
+ (cd web && npx playwright test --project=setup --project=chromium)
65
+
66
+ echo ""
67
+ echo "All tests passed."
web/e2e/api-keys.spec.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ test.describe('api keys', () => {
4
+ test.beforeEach(async ({ page, request }) => {
5
+ // Ensure admin exists and login
6
+ await request.post('/api/auth/setup', {
7
+ data: { username: 'admin', password: 'testpassword123' },
8
+ })
9
+
10
+ await page.goto('/login')
11
+ await page.fill('#username', 'admin')
12
+ await page.fill('#password', 'testpassword123')
13
+ await page.click('button[type="submit"]')
14
+ await expect(page).toHaveURL(/\/keys/)
15
+ })
16
+
17
+ test('create a new API key and see its value', async ({ page }) => {
18
+ await page.fill('input[placeholder*="Key name"]', 'test-key')
19
+ await page.click('button:has-text("Create")')
20
+
21
+ // Key value banner should appear
22
+ await expect(page.locator('text=Copy your API key now')).toBeVisible()
23
+ await expect(page.locator('code')).toBeVisible()
24
+ })
25
+
26
+ test('dismiss key banner', async ({ page }) => {
27
+ await page.fill('input[placeholder*="Key name"]', 'dismiss-test')
28
+ await page.click('button:has-text("Create")')
29
+
30
+ await expect(page.locator('text=Copy your API key now')).toBeVisible()
31
+ await page.click('button:has-text("Dismiss")')
32
+ await expect(page.locator('text=Copy your API key now')).not.toBeVisible()
33
+ })
34
+
35
+ test('key appears in list with correct name', async ({ page }) => {
36
+ await page.fill('input[placeholder*="Key name"]', 'my-prod-key')
37
+ await page.click('button:has-text("Create")')
38
+
39
+ // Key should appear in the list
40
+ await expect(page.locator('text=my-prod-key')).toBeVisible()
41
+ })
42
+
43
+ test('delete a key removes it from list', async ({ page }) => {
44
+ // Create a key
45
+ await page.fill('input[placeholder*="Key name"]', 'to-delete')
46
+ await page.click('button:has-text("Create")')
47
+ await expect(page.locator('text=to-delete')).toBeVisible()
48
+
49
+ // Accept the confirm dialog
50
+ page.on('dialog', (dialog) => dialog.accept())
51
+
52
+ // Click delete on the key
53
+ await page.locator('text=to-delete').locator('..').locator('..').locator('button:has-text("Delete")').click()
54
+
55
+ // Key should be gone
56
+ await expect(page.locator('text=to-delete')).not.toBeVisible()
57
+ })
58
+ })
web/e2e/global-setup.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { execSync } from 'node:child_process'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+ const PROJECT_ROOT = resolve(__dirname, '../..')
7
+ const IMAGE_NAME = 'openposterdb-test'
8
+ const CONTAINER_NAME = 'openposterdb-test'
9
+ const BACKEND_URL = 'http://127.0.0.1:3333/api/auth/status'
10
+
11
+ function containerCmd(): string {
12
+ try {
13
+ execSync('podman --version', { stdio: 'ignore' })
14
+ return 'podman'
15
+ } catch {
16
+ return 'docker'
17
+ }
18
+ }
19
+
20
+ export default async function globalSetup() {
21
+ const cmd = containerCmd()
22
+
23
+ console.log(`[e2e] Building container image via "${cmd}"...`)
24
+ execSync(
25
+ `${cmd} build -t ${IMAGE_NAME} --build-arg CARGO_FEATURES=test-support -f Containerfile .`,
26
+ { cwd: PROJECT_ROOT, stdio: 'inherit' },
27
+ )
28
+
29
+ console.log('[e2e] Starting container...')
30
+ try { execSync(`${cmd} rm -f ${CONTAINER_NAME}`, { stdio: 'ignore' }) } catch { /* ignore */ }
31
+ execSync(
32
+ [
33
+ `${cmd} run -d --name ${CONTAINER_NAME}`,
34
+ '-p 3333:3000',
35
+ '--tmpfs /tmp/openposterdb-e2e',
36
+ '-e TMDB_API_KEY=test',
37
+ '-e MDBLIST_API_KEY=test',
38
+ '-e JWT_SECRET=abababababababababababababababababababababababababababababababab',
39
+ '-e LISTEN_ADDR=0.0.0.0:3000',
40
+ '-e COOKIE_SECURE=false',
41
+ '-e CACHE_DIR=/tmp/openposterdb-e2e',
42
+ IMAGE_NAME,
43
+ ].join(' '),
44
+ { cwd: PROJECT_ROOT, stdio: 'inherit' },
45
+ )
46
+
47
+ console.log('[e2e] Waiting for backend to be ready...')
48
+ const deadline = Date.now() + 60_000
49
+ while (Date.now() < deadline) {
50
+ try {
51
+ const res = await fetch(BACKEND_URL)
52
+ if (res.ok) {
53
+ console.log('[e2e] Backend is ready')
54
+ return
55
+ }
56
+ } catch {
57
+ // not ready yet
58
+ }
59
+ await new Promise((r) => setTimeout(r, 500))
60
+ }
61
+ throw new Error('Backend did not start within 60 seconds')
62
+ }
web/e2e/global-teardown.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { execSync } from 'node:child_process'
2
+
3
+ const CONTAINER_NAME = 'openposterdb-test'
4
+
5
+ function containerCmd(): string {
6
+ try {
7
+ execSync('podman --version', { stdio: 'ignore' })
8
+ return 'podman'
9
+ } catch {
10
+ return 'docker'
11
+ }
12
+ }
13
+
14
+ export default async function globalTeardown() {
15
+ const cmd = containerCmd()
16
+
17
+ console.log('[e2e] Stopping and removing container...')
18
+ try {
19
+ execSync(`${cmd} rm -f ${CONTAINER_NAME}`, { stdio: 'inherit' })
20
+ } catch {
21
+ // may already be removed
22
+ }
23
+ console.log('[e2e] Cleanup complete')
24
+ }
web/e2e/login-flow.spec.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ test.describe('login flow', () => {
4
+ // Setup is assumed to be done by the setup-flow project dependency
5
+
6
+ test('login with valid credentials', async ({ page, request }) => {
7
+ // Ensure admin exists via API
8
+ await request.post('/api/auth/setup', {
9
+ data: { username: 'admin', password: 'testpassword123' },
10
+ })
11
+
12
+ await page.goto('/login')
13
+ await page.fill('#username', 'admin')
14
+ await page.fill('#password', 'testpassword123')
15
+ await page.click('button[type="submit"]')
16
+
17
+ await expect(page).toHaveURL(/\/keys/)
18
+ })
19
+
20
+ test('login with invalid credentials shows error', async ({ page, request }) => {
21
+ // Ensure admin exists
22
+ await request.post('/api/auth/setup', {
23
+ data: { username: 'admin', password: 'testpassword123' },
24
+ })
25
+
26
+ await page.goto('/login')
27
+ await page.fill('#username', 'admin')
28
+ await page.fill('#password', 'wrongpassword')
29
+ await page.click('button[type="submit"]')
30
+
31
+ await expect(page.locator('.text-destructive')).toBeVisible()
32
+ })
33
+
34
+ test('logout redirects to /login', async ({ page, request }) => {
35
+ // Setup and login via API
36
+ await request.post('/api/auth/setup', {
37
+ data: { username: 'admin', password: 'testpassword123' },
38
+ })
39
+
40
+ await page.goto('/login')
41
+ await page.fill('#username', 'admin')
42
+ await page.fill('#password', 'testpassword123')
43
+ await page.click('button[type="submit"]')
44
+ await expect(page).toHaveURL(/\/keys/)
45
+
46
+ // Click logout
47
+ await page.click('text=Sign out')
48
+ await expect(page).toHaveURL(/\/login/)
49
+ })
50
+
51
+ test('accessing /keys when logged out redirects to /login', async ({ page }) => {
52
+ await page.goto('/keys')
53
+ await expect(page).toHaveURL(/\/login|\/setup/)
54
+ })
55
+ })
web/e2e/setup-flow.spec.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ test.describe.serial('setup flow', () => {
4
+ test('fresh app redirects to /setup', async ({ page }) => {
5
+ await page.goto('/')
6
+ await expect(page).toHaveURL(/\/setup/)
7
+ })
8
+
9
+ test('create admin account and redirect to /keys', async ({ page }) => {
10
+ await page.goto('/setup')
11
+
12
+ await page.fill('#username', 'admin')
13
+ await page.fill('#password', 'testpassword123')
14
+ await page.fill('#confirm-password', 'testpassword123')
15
+ await page.click('button[type="submit"]')
16
+
17
+ await expect(page).toHaveURL(/\/keys/)
18
+ })
19
+
20
+ test('revisiting /setup after account created redirects to /login', async ({ page }) => {
21
+ await page.goto('/setup')
22
+ await expect(page).toHaveURL(/\/login/)
23
+ })
24
+ })
web/e2e/vue.spec.ts DELETED
@@ -1,8 +0,0 @@
1
- import { test, expect } from '@playwright/test'
2
-
3
- // See here how to get started:
4
- // https://playwright.dev/docs/intro
5
- test('visits the app root url', async ({ page }) => {
6
- await page.goto('/')
7
- await expect(page.locator('h1')).toHaveText('You did it!')
8
- })
 
 
 
 
 
 
 
 
 
web/playwright.config.ts CHANGED
@@ -1,12 +1,6 @@
1
  import process from 'node:process'
2
  import { defineConfig, devices } from '@playwright/test'
3
 
4
- /**
5
- * Read environment variables from file.
6
- * https://github.com/motdotla/dotenv
7
- */
8
- // require('dotenv').config();
9
-
10
  /**
11
  * See https://playwright.dev/docs/test-configuration.
12
  */
@@ -29,82 +23,47 @@ export default defineConfig({
29
  workers: process.env.CI ? 1 : undefined,
30
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
31
  reporter: 'html',
 
 
 
 
 
 
 
 
 
 
32
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
33
  use: {
34
  /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
35
  actionTimeout: 0,
36
- /* Base URL to use in actions like `await page.goto('/')`. */
37
- baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
38
 
39
  /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
40
  trace: 'on-first-retry',
41
 
42
- /* Only on CI systems run the tests headless */
43
- headless: !!process.env.CI,
44
  },
45
 
46
  /* Configure projects for major browsers */
47
  projects: [
48
  {
49
- name: 'chromium',
 
50
  use: {
51
  ...devices['Desktop Chrome'],
52
  },
53
  },
54
  {
55
- name: 'firefox',
56
- use: {
57
- ...devices['Desktop Firefox'],
58
- },
59
- },
60
- {
61
- name: 'webkit',
62
  use: {
63
- ...devices['Desktop Safari'],
64
  },
65
  },
66
-
67
- /* Test against mobile viewports. */
68
- // {
69
- // name: 'Mobile Chrome',
70
- // use: {
71
- // ...devices['Pixel 5'],
72
- // },
73
- // },
74
- // {
75
- // name: 'Mobile Safari',
76
- // use: {
77
- // ...devices['iPhone 12'],
78
- // },
79
- // },
80
-
81
- /* Test against branded browsers. */
82
- // {
83
- // name: 'Microsoft Edge',
84
- // use: {
85
- // channel: 'msedge',
86
- // },
87
- // },
88
- // {
89
- // name: 'Google Chrome',
90
- // use: {
91
- // channel: 'chrome',
92
- // },
93
- // },
94
  ],
95
 
96
- /* Folder for test artifacts such as screenshots, videos, traces, etc. */
97
- // outputDir: 'test-results/',
98
-
99
- /* Run your local dev server before starting the tests */
100
- webServer: {
101
- /**
102
- * Use the dev server by default for faster feedback loop.
103
- * Use the preview server on CI for more realistic testing.
104
- * Playwright will re-use the local server if there is already a dev-server running.
105
- */
106
- command: process.env.CI ? 'npm run preview' : 'npm run dev',
107
- port: process.env.CI ? 4173 : 5173,
108
- reuseExistingServer: !process.env.CI,
109
- },
110
  })
 
1
  import process from 'node:process'
2
  import { defineConfig, devices } from '@playwright/test'
3
 
 
 
 
 
 
 
4
  /**
5
  * See https://playwright.dev/docs/test-configuration.
6
  */
 
23
  workers: process.env.CI ? 1 : undefined,
24
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
25
  reporter: 'html',
26
+
27
+ // On CI the backend is started by the workflow directly; locally we use
28
+ // docker/podman compose via global-setup/teardown.
29
+ ...(process.env.CI
30
+ ? {}
31
+ : {
32
+ globalSetup: './e2e/global-setup.ts',
33
+ globalTeardown: './e2e/global-teardown.ts',
34
+ }),
35
+
36
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
37
  use: {
38
  /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
39
  actionTimeout: 0,
40
+ /* Both CI and local: backend serves frontend + API on one port. */
41
+ baseURL: 'http://127.0.0.1:3333',
42
 
43
  /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
44
  trace: 'on-first-retry',
45
 
46
+ headless: true,
 
47
  },
48
 
49
  /* Configure projects for major browsers */
50
  projects: [
51
  {
52
+ name: 'setup',
53
+ testMatch: /setup-flow\.spec\.ts/,
54
  use: {
55
  ...devices['Desktop Chrome'],
56
  },
57
  },
58
  {
59
+ name: 'chromium',
60
+ dependencies: ['setup'],
61
+ testIgnore: /setup-flow\.spec\.ts/,
 
 
 
 
62
  use: {
63
+ ...devices['Desktop Chrome'],
64
  },
65
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  ],
67
 
68
+ // No webServer needed — backend serves the frontend via STATIC_DIR.
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  })
web/src/__tests__/App.spec.ts CHANGED
@@ -1,11 +1,14 @@
1
  import { describe, it, expect } from 'vitest'
2
-
3
  import { mount } from '@vue/test-utils'
4
  import App from '../App.vue'
5
 
6
  describe('App', () => {
7
- it('mounts renders properly', () => {
8
- const wrapper = mount(App)
9
- expect(wrapper.text()).toContain('You did it!')
 
 
 
 
10
  })
11
  })
 
1
  import { describe, it, expect } from 'vitest'
 
2
  import { mount } from '@vue/test-utils'
3
  import App from '../App.vue'
4
 
5
  describe('App', () => {
6
+ it('mounts without error', () => {
7
+ const wrapper = mount(App, {
8
+ global: {
9
+ stubs: ['RouterView'],
10
+ },
11
+ })
12
+ expect(wrapper.exists()).toBe(true)
13
  })
14
  })
web/src/__tests__/lib/api.spec.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+
4
+ const mockAuthStore = {
5
+ token: 'test-token',
6
+ refresh: vi.fn(),
7
+ logout: vi.fn(),
8
+ }
9
+
10
+ vi.mock('@/stores/auth', () => ({
11
+ useAuthStore: () => mockAuthStore,
12
+ }))
13
+
14
+ const mockRouter = {
15
+ push: vi.fn(),
16
+ }
17
+
18
+ vi.mock('@/router', () => ({
19
+ default: mockRouter,
20
+ }))
21
+
22
+ import { get, post, del } from '@/lib/api'
23
+
24
+ function makeFetchResponse(status: number, body: unknown = {}) {
25
+ return {
26
+ ok: status >= 200 && status < 300,
27
+ status,
28
+ json: () => Promise.resolve(body),
29
+ text: () => Promise.resolve(JSON.stringify(body)),
30
+ }
31
+ }
32
+
33
+ describe('api', () => {
34
+ beforeEach(() => {
35
+ setActivePinia(createPinia())
36
+ vi.restoreAllMocks()
37
+ mockAuthStore.token = 'test-token'
38
+ mockAuthStore.refresh = vi.fn()
39
+ mockAuthStore.logout = vi.fn()
40
+ mockRouter.push = vi.fn()
41
+ })
42
+
43
+ it('get adds Authorization header when token exists', async () => {
44
+ const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200, { data: 'ok' }))
45
+ vi.stubGlobal('fetch', fetchMock)
46
+
47
+ await get('/api/test')
48
+
49
+ expect(fetchMock).toHaveBeenCalledTimes(1)
50
+ const [url, options] = fetchMock.mock.calls[0]
51
+ expect(url).toBe('/api/test')
52
+ expect(options.headers.get('Authorization')).toBe('Bearer test-token')
53
+ })
54
+
55
+ it('post sends JSON body', async () => {
56
+ const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
57
+ vi.stubGlobal('fetch', fetchMock)
58
+
59
+ await post('/api/items', { name: 'test' })
60
+
61
+ const [, options] = fetchMock.mock.calls[0]
62
+ expect(options.method).toBe('POST')
63
+ expect(options.headers.get('Content-Type')).toBe('application/json')
64
+ expect(options.body).toBe(JSON.stringify({ name: 'test' }))
65
+ })
66
+
67
+ it('del uses DELETE method', async () => {
68
+ const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
69
+ vi.stubGlobal('fetch', fetchMock)
70
+
71
+ await del('/api/items/1')
72
+
73
+ const [, options] = fetchMock.mock.calls[0]
74
+ expect(options.method).toBe('DELETE')
75
+ })
76
+
77
+ it('401 handling: attempts refresh, retries request on success', async () => {
78
+ mockAuthStore.refresh.mockResolvedValue(true)
79
+ mockAuthStore.token = 'refreshed-token'
80
+
81
+ const fetchMock = vi
82
+ .fn()
83
+ .mockResolvedValueOnce(makeFetchResponse(401))
84
+ .mockResolvedValueOnce(makeFetchResponse(200, { data: 'ok' }))
85
+ vi.stubGlobal('fetch', fetchMock)
86
+
87
+ await get('/api/protected')
88
+
89
+ expect(mockAuthStore.refresh).toHaveBeenCalled()
90
+ expect(fetchMock).toHaveBeenCalledTimes(2)
91
+ })
92
+
93
+ it('401 + failed refresh: calls logout', async () => {
94
+ mockAuthStore.refresh.mockResolvedValue(false)
95
+
96
+ const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(401))
97
+ vi.stubGlobal('fetch', fetchMock)
98
+
99
+ await get('/api/protected')
100
+
101
+ expect(mockAuthStore.refresh).toHaveBeenCalled()
102
+ expect(mockAuthStore.logout).toHaveBeenCalled()
103
+ expect(mockRouter.push).toHaveBeenCalledWith({ name: 'login' })
104
+ })
105
+ })
web/src/__tests__/router/index.spec.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { createRouter, createWebHistory } from 'vue-router'
4
+ import { useAuthStore } from '@/stores/auth'
5
+
6
+ // We need to create a fresh router for each test to avoid guard state leaking.
7
+ // We also mock checkSetupRequired so it doesn't actually fetch.
8
+
9
+ function makeRouter() {
10
+ return createRouter({
11
+ history: createWebHistory(),
12
+ routes: [
13
+ { path: '/', redirect: '/keys' },
14
+ { path: '/login', name: 'login', component: { template: '<div>Login</div>' } },
15
+ { path: '/setup', name: 'setup', component: { template: '<div>Setup</div>' } },
16
+ {
17
+ path: '/keys',
18
+ name: 'keys',
19
+ component: { template: '<div>Keys</div>' },
20
+ meta: { requiresAuth: true },
21
+ },
22
+ ],
23
+ })
24
+ }
25
+
26
+ describe('router', () => {
27
+ beforeEach(() => {
28
+ setActivePinia(createPinia())
29
+ })
30
+
31
+ it('unauthenticated user visiting /keys gets redirected to /login', async () => {
32
+ const router = makeRouter()
33
+ const auth = useAuthStore()
34
+
35
+ vi.spyOn(auth, 'checkSetupRequired').mockResolvedValue(false)
36
+
37
+ router.beforeEach(async (to) => {
38
+ if (to.meta.requiresAuth && !auth.isAuthenticated) {
39
+ return { name: 'login' }
40
+ }
41
+ })
42
+
43
+ await router.push('/keys')
44
+ await router.isReady()
45
+
46
+ expect(router.currentRoute.value.name).toBe('login')
47
+ })
48
+
49
+ it('authenticated user visiting /login gets redirected to /keys', async () => {
50
+ const router = makeRouter()
51
+ const auth = useAuthStore()
52
+ auth.token = 'valid-token'
53
+
54
+ vi.spyOn(auth, 'checkSetupRequired').mockResolvedValue(false)
55
+
56
+ router.beforeEach(async (to) => {
57
+ if ((to.name === 'login' || to.name === 'setup') && auth.isAuthenticated) {
58
+ return { name: 'keys' }
59
+ }
60
+ })
61
+
62
+ await router.push('/login')
63
+ await router.isReady()
64
+
65
+ expect(router.currentRoute.value.name).toBe('keys')
66
+ })
67
+ })
web/src/__tests__/stores/auth.spec.ts ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { useAuthStore } from '@/stores/auth'
4
+
5
+ const localStorageMock: Record<string, string> = {}
6
+ const localStorageStub = {
7
+ getItem: vi.fn((key: string) => localStorageMock[key] ?? null),
8
+ setItem: vi.fn((key: string, value: string) => {
9
+ localStorageMock[key] = value
10
+ }),
11
+ removeItem: vi.fn((key: string) => {
12
+ delete localStorageMock[key]
13
+ }),
14
+ }
15
+
16
+ vi.stubGlobal('localStorage', localStorageStub)
17
+
18
+ function mockFetchSuccess(data: Record<string, unknown>, ok = true) {
19
+ return vi.fn().mockResolvedValue({
20
+ ok,
21
+ status: ok ? 200 : 401,
22
+ json: () => Promise.resolve(data),
23
+ })
24
+ }
25
+
26
+ describe('auth store', () => {
27
+ beforeEach(() => {
28
+ setActivePinia(createPinia())
29
+ Object.keys(localStorageMock).forEach((k) => delete localStorageMock[k])
30
+ vi.restoreAllMocks()
31
+ vi.stubGlobal('localStorage', localStorageStub)
32
+ })
33
+
34
+ it('token is set via login and stored in localStorage', async () => {
35
+ vi.stubGlobal('fetch', mockFetchSuccess({ token: 'abc123' }))
36
+ const auth = useAuthStore()
37
+ await auth.login('user', 'pass')
38
+ expect(auth.token).toBe('abc123')
39
+ expect(localStorageStub.setItem).toHaveBeenCalledWith('token', 'abc123')
40
+ })
41
+
42
+ it('logout clears token from localStorage and sets null', async () => {
43
+ vi.stubGlobal('fetch', mockFetchSuccess({ token: 'abc123' }))
44
+ const auth = useAuthStore()
45
+ await auth.login('user', 'pass')
46
+ auth.logout()
47
+ expect(auth.token).toBeNull()
48
+ expect(localStorageStub.removeItem).toHaveBeenCalledWith('token')
49
+ })
50
+
51
+ it('isAuthenticated is true when token set', async () => {
52
+ vi.stubGlobal('fetch', mockFetchSuccess({ token: 'abc123' }))
53
+ const auth = useAuthStore()
54
+ await auth.login('user', 'pass')
55
+ expect(auth.isAuthenticated).toBe(true)
56
+ })
57
+
58
+ it('isAuthenticated is false when token is null', () => {
59
+ const auth = useAuthStore()
60
+ expect(auth.isAuthenticated).toBe(false)
61
+ })
62
+
63
+ it('login returns true on success', async () => {
64
+ const fetchMock = mockFetchSuccess({ token: 'new-token' })
65
+ vi.stubGlobal('fetch', fetchMock)
66
+ const auth = useAuthStore()
67
+
68
+ const result = await auth.login('user', 'pass')
69
+
70
+ expect(result).toBe(true)
71
+ expect(auth.token).toBe('new-token')
72
+ expect(fetchMock).toHaveBeenCalledWith(
73
+ expect.stringContaining('/api/auth/login'),
74
+ expect.objectContaining({ method: 'POST' }),
75
+ )
76
+ })
77
+
78
+ it('login returns false on failure', async () => {
79
+ const fetchMock = mockFetchSuccess({}, false)
80
+ vi.stubGlobal('fetch', fetchMock)
81
+ const auth = useAuthStore()
82
+
83
+ const result = await auth.login('user', 'wrong')
84
+
85
+ expect(result).toBe(false)
86
+ expect(auth.token).toBeNull()
87
+ })
88
+
89
+ it('setup sets token and setupRequired=false', async () => {
90
+ const fetchMock = mockFetchSuccess({ token: 'setup-token' })
91
+ vi.stubGlobal('fetch', fetchMock)
92
+ const auth = useAuthStore()
93
+
94
+ const result = await auth.setup('admin', 'password123')
95
+
96
+ expect(result).toBe(true)
97
+ expect(auth.token).toBe('setup-token')
98
+ expect(auth.setupRequired).toBe(false)
99
+ })
100
+
101
+ it('refresh updates token', async () => {
102
+ const fetchMock = mockFetchSuccess({ token: 'refreshed-token' })
103
+ vi.stubGlobal('fetch', fetchMock)
104
+ const auth = useAuthStore()
105
+
106
+ const result = await auth.refresh()
107
+
108
+ expect(result).toBe(true)
109
+ expect(auth.token).toBe('refreshed-token')
110
+ })
111
+
112
+ it('logout clears token and sets setupRequired to null', async () => {
113
+ vi.stubGlobal('fetch', mockFetchSuccess({ token: 'abc' }))
114
+ const auth = useAuthStore()
115
+ await auth.login('user', 'pass')
116
+ auth.logout()
117
+ expect(auth.token).toBeNull()
118
+ expect(auth.setupRequired).toBeNull()
119
+ })
120
+
121
+ it('checkSetupRequired caches result on second call', async () => {
122
+ const fetchMock = mockFetchSuccess({ setup_required: true })
123
+ vi.stubGlobal('fetch', fetchMock)
124
+ const auth = useAuthStore()
125
+
126
+ const first = await auth.checkSetupRequired()
127
+ const second = await auth.checkSetupRequired()
128
+
129
+ expect(first).toBe(true)
130
+ expect(second).toBe(true)
131
+ expect(fetchMock).toHaveBeenCalledTimes(1)
132
+ })
133
+ })
web/src/__tests__/views/ApiKeysView.spec.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import { createPinia, setActivePinia } from 'pinia'
4
+ import ApiKeysView from '@/views/ApiKeysView.vue'
5
+
6
+ const mockRouter = {
7
+ push: vi.fn(),
8
+ }
9
+
10
+ const mockAuthStore = {
11
+ logout: vi.fn(),
12
+ }
13
+
14
+ const mockApi = {
15
+ get: vi.fn(),
16
+ post: vi.fn(),
17
+ del: vi.fn(),
18
+ }
19
+
20
+ vi.mock('vue-router', () => ({
21
+ useRouter: () => mockRouter,
22
+ }))
23
+
24
+ vi.mock('@/stores/auth', () => ({
25
+ useAuthStore: () => mockAuthStore,
26
+ }))
27
+
28
+ vi.mock('@/lib/api', () => ({
29
+ get: (...args: unknown[]) => mockApi.get(...args),
30
+ post: (...args: unknown[]) => mockApi.post(...args),
31
+ del: (...args: unknown[]) => mockApi.del(...args),
32
+ }))
33
+
34
+ const sampleKeys = [
35
+ {
36
+ id: 1,
37
+ name: 'jellyfin-prod',
38
+ key_prefix: 'opdb_abc',
39
+ created_at: '2026-01-01',
40
+ last_used_at: null,
41
+ },
42
+ {
43
+ id: 2,
44
+ name: 'plex-dev',
45
+ key_prefix: 'opdb_xyz',
46
+ created_at: '2026-02-01',
47
+ last_used_at: '2026-03-01',
48
+ },
49
+ ]
50
+
51
+ function mountView() {
52
+ return mount(ApiKeysView, {
53
+ global: {
54
+ plugins: [createPinia()],
55
+ stubs: {
56
+ Button: {
57
+ template: '<button @click="$emit(\'click\')"><slot /></button>',
58
+ props: ['disabled', 'variant', 'size'],
59
+ },
60
+ },
61
+ },
62
+ })
63
+ }
64
+
65
+ describe('ApiKeysView', () => {
66
+ beforeEach(() => {
67
+ setActivePinia(createPinia())
68
+ vi.clearAllMocks()
69
+ mockApi.get.mockResolvedValue({
70
+ ok: true,
71
+ json: () => Promise.resolve([]),
72
+ })
73
+ })
74
+
75
+ it('renders key list from mocked API response', async () => {
76
+ mockApi.get.mockResolvedValue({
77
+ ok: true,
78
+ json: () => Promise.resolve(sampleKeys),
79
+ })
80
+
81
+ const wrapper = mountView()
82
+ await flushPromises()
83
+
84
+ expect(wrapper.text()).toContain('jellyfin-prod')
85
+ expect(wrapper.text()).toContain('plex-dev')
86
+ expect(wrapper.text()).toContain('opdb_abc')
87
+ expect(wrapper.text()).toContain('opdb_xyz')
88
+ })
89
+
90
+ it('shows "No API keys yet." when empty', async () => {
91
+ mockApi.get.mockResolvedValue({
92
+ ok: true,
93
+ json: () => Promise.resolve([]),
94
+ })
95
+
96
+ const wrapper = mountView()
97
+ await flushPromises()
98
+
99
+ expect(wrapper.text()).toContain('No API keys yet.')
100
+ })
101
+
102
+ it('logout button calls auth.logout', async () => {
103
+ const wrapper = mountView()
104
+ await flushPromises()
105
+
106
+ const buttons = wrapper.findAll('button')
107
+ const logoutButton = buttons.find((b) => b.text().includes('Sign out'))
108
+ expect(logoutButton).toBeDefined()
109
+
110
+ await logoutButton!.trigger('click')
111
+
112
+ expect(mockAuthStore.logout).toHaveBeenCalled()
113
+ expect(mockRouter.push).toHaveBeenCalledWith('/login')
114
+ })
115
+ })
web/src/__tests__/views/LoginView.spec.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import { createPinia, setActivePinia } from 'pinia'
4
+ import LoginView from '@/views/LoginView.vue'
5
+
6
+ const mockRouter = {
7
+ push: vi.fn(),
8
+ replace: vi.fn(),
9
+ }
10
+
11
+ const mockAuthStore = {
12
+ checkSetupRequired: vi.fn().mockResolvedValue(false),
13
+ login: vi.fn(),
14
+ isAuthenticated: false,
15
+ }
16
+
17
+ vi.mock('vue-router', () => ({
18
+ useRouter: () => mockRouter,
19
+ }))
20
+
21
+ vi.mock('@/stores/auth', () => ({
22
+ useAuthStore: () => mockAuthStore,
23
+ }))
24
+
25
+ function mountView() {
26
+ return mount(LoginView, {
27
+ global: {
28
+ plugins: [createPinia()],
29
+ stubs: {
30
+ Button: {
31
+ template: '<button><slot /></button>',
32
+ props: ['disabled'],
33
+ },
34
+ },
35
+ },
36
+ })
37
+ }
38
+
39
+ describe('LoginView', () => {
40
+ beforeEach(() => {
41
+ setActivePinia(createPinia())
42
+ vi.clearAllMocks()
43
+ mockAuthStore.checkSetupRequired.mockResolvedValue(false)
44
+ mockAuthStore.login.mockReset()
45
+ })
46
+
47
+ it('renders login form', async () => {
48
+ const wrapper = mountView()
49
+ await flushPromises()
50
+
51
+ expect(wrapper.find('input#username').exists()).toBe(true)
52
+ expect(wrapper.find('input#password').exists()).toBe(true)
53
+ expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
54
+ })
55
+
56
+ it('shows error message on failed login', async () => {
57
+ mockAuthStore.login.mockResolvedValue(false)
58
+ const wrapper = mountView()
59
+ await flushPromises()
60
+
61
+ await wrapper.find('input#username').setValue('user')
62
+ await wrapper.find('input#password').setValue('wrong')
63
+ await wrapper.find('form').trigger('submit')
64
+ await flushPromises()
65
+
66
+ expect(wrapper.text()).toContain('Invalid username or password')
67
+ })
68
+
69
+ it('calls auth.login on form submit', async () => {
70
+ mockAuthStore.login.mockResolvedValue(true)
71
+ const wrapper = mountView()
72
+ await flushPromises()
73
+
74
+ await wrapper.find('input#username').setValue('admin')
75
+ await wrapper.find('input#password').setValue('secret')
76
+ await wrapper.find('form').trigger('submit')
77
+ await flushPromises()
78
+
79
+ expect(mockAuthStore.login).toHaveBeenCalledWith('admin', 'secret')
80
+ expect(mockRouter.push).toHaveBeenCalledWith('/keys')
81
+ })
82
+ })
web/src/__tests__/views/SetupView.spec.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import { createPinia, setActivePinia } from 'pinia'
4
+ import SetupView from '@/views/SetupView.vue'
5
+
6
+ const mockRouter = {
7
+ push: vi.fn(),
8
+ }
9
+
10
+ const mockAuthStore = {
11
+ setup: vi.fn(),
12
+ }
13
+
14
+ vi.mock('vue-router', () => ({
15
+ useRouter: () => mockRouter,
16
+ }))
17
+
18
+ vi.mock('@/stores/auth', () => ({
19
+ useAuthStore: () => mockAuthStore,
20
+ }))
21
+
22
+ function mountView() {
23
+ return mount(SetupView, {
24
+ global: {
25
+ plugins: [createPinia()],
26
+ stubs: {
27
+ Button: {
28
+ template: '<button><slot /></button>',
29
+ props: ['disabled'],
30
+ },
31
+ },
32
+ },
33
+ })
34
+ }
35
+
36
+ describe('SetupView', () => {
37
+ beforeEach(() => {
38
+ setActivePinia(createPinia())
39
+ vi.clearAllMocks()
40
+ mockAuthStore.setup.mockReset()
41
+ })
42
+
43
+ it('renders setup form with confirm password field', () => {
44
+ const wrapper = mountView()
45
+
46
+ expect(wrapper.find('input#username').exists()).toBe(true)
47
+ expect(wrapper.find('input#password').exists()).toBe(true)
48
+ expect(wrapper.find('input#confirm-password').exists()).toBe(true)
49
+ expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
50
+ })
51
+
52
+ it('shows error when passwords do not match', async () => {
53
+ const wrapper = mountView()
54
+
55
+ await wrapper.find('input#username').setValue('admin')
56
+ await wrapper.find('input#password').setValue('password123')
57
+ await wrapper.find('input#confirm-password').setValue('different')
58
+ await wrapper.find('form').trigger('submit')
59
+ await flushPromises()
60
+
61
+ expect(wrapper.text()).toContain('Passwords do not match')
62
+ expect(mockAuthStore.setup).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('shows error when password is less than 8 characters', async () => {
66
+ const wrapper = mountView()
67
+
68
+ await wrapper.find('input#username').setValue('admin')
69
+ await wrapper.find('input#password').setValue('short')
70
+ await wrapper.find('input#confirm-password').setValue('short')
71
+ await wrapper.find('form').trigger('submit')
72
+ await flushPromises()
73
+
74
+ expect(wrapper.text()).toContain('Password must be at least 8 characters')
75
+ expect(mockAuthStore.setup).not.toHaveBeenCalled()
76
+ })
77
+
78
+ it('calls auth.setup on valid submit', async () => {
79
+ mockAuthStore.setup.mockResolvedValue(true)
80
+ const wrapper = mountView()
81
+
82
+ await wrapper.find('input#username').setValue('admin')
83
+ await wrapper.find('input#password').setValue('password123')
84
+ await wrapper.find('input#confirm-password').setValue('password123')
85
+ await wrapper.find('form').trigger('submit')
86
+ await flushPromises()
87
+
88
+ expect(mockAuthStore.setup).toHaveBeenCalledWith('admin', 'password123')
89
+ expect(mockRouter.push).toHaveBeenCalledWith('/keys')
90
+ })
91
+ })
web/vite.config.ts CHANGED
@@ -19,7 +19,7 @@ export default defineConfig({
19
  },
20
  server: {
21
  proxy: {
22
- '/api': 'http://localhost:3000',
23
  },
24
  },
25
  })
 
19
  },
20
  server: {
21
  proxy: {
22
+ '/api': process.env.E2E_BACKEND_URL || 'http://localhost:3000',
23
  },
24
  },
25
  })