Spaces:
Paused
Paused
PNRxA commited on
Commit ·
dd7a3f0
1
Parent(s): 54e4063
init tests
Browse files- .containerignore +4 -0
- .github/workflows/test.yml +114 -0
- Containerfile +28 -0
- README.md +20 -0
- api/Cargo.lock +31 -0
- api/Cargo.toml +9 -1
- api/src/cache/mod.rs +87 -0
- api/src/config.rs +2 -0
- api/src/error.rs +63 -0
- api/src/handlers/auth.rs +67 -0
- api/src/lib.rs +121 -0
- api/src/main.rs +18 -123
- api/src/routes/auth.rs +1 -1
- api/src/services/ratings.rs +39 -0
- api/src/services/validation.rs +65 -0
- api/tests/api_keys.rs +157 -0
- api/tests/auth_flow.rs +267 -0
- api/tests/common/mod.rs +131 -0
- api/tests/middleware.rs +134 -0
- docker-compose.yml +47 -0
- scripts/test.sh +67 -0
- web/e2e/api-keys.spec.ts +58 -0
- web/e2e/global-setup.ts +62 -0
- web/e2e/global-teardown.ts +24 -0
- web/e2e/login-flow.spec.ts +55 -0
- web/e2e/setup-flow.spec.ts +24 -0
- web/e2e/vue.spec.ts +0 -8
- web/playwright.config.ts +20 -61
- web/src/__tests__/App.spec.ts +7 -4
- web/src/__tests__/lib/api.spec.ts +105 -0
- web/src/__tests__/router/index.spec.ts +67 -0
- web/src/__tests__/stores/auth.spec.ts +133 -0
- web/src/__tests__/views/ApiKeysView.spec.ts +115 -0
- web/src/__tests__/views/LoginView.spec.ts +82 -0
- web/src/__tests__/views/SetupView.spec.ts +91 -0
- web/vite.config.ts +1 -1
.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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 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 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 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 |
-
|
| 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 |
-
/*
|
| 37 |
-
baseURL:
|
| 38 |
|
| 39 |
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
| 40 |
trace: 'on-first-retry',
|
| 41 |
|
| 42 |
-
|
| 43 |
-
headless: !!process.env.CI,
|
| 44 |
},
|
| 45 |
|
| 46 |
/* Configure projects for major browsers */
|
| 47 |
projects: [
|
| 48 |
{
|
| 49 |
-
name: '
|
|
|
|
| 50 |
use: {
|
| 51 |
...devices['Desktop Chrome'],
|
| 52 |
},
|
| 53 |
},
|
| 54 |
{
|
| 55 |
-
name: '
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
},
|
| 59 |
-
},
|
| 60 |
-
{
|
| 61 |
-
name: 'webkit',
|
| 62 |
use: {
|
| 63 |
-
...devices['Desktop
|
| 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 |
-
/
|
| 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
|
| 8 |
-
const wrapper = mount(App
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
})
|