diff --git a/justfile b/justfile index f4527f55..8dbc1b11 100644 --- a/justfile +++ b/justfile @@ -64,10 +64,14 @@ front-lint: front-rules: stylelint static/css/ -# end-to-end tests +# end-to-end Playwright tests front-test: cd tests/e2e && npm test # update images snapshots front-test-update-snapshot: cd tests/e2e && npm test -- --update-snapshots + +# Hurl API functional tests (starts postgres + server, tears down after) +api-test: + bash tests/api/run.sh diff --git a/tests/api/README.md b/tests/api/README.md new file mode 100644 index 00000000..d4aa2994 --- /dev/null +++ b/tests/api/README.md @@ -0,0 +1,71 @@ +# API functional tests + +Tests are written using [Hurl](https://hurl.dev) — a plain-text, CLI-first HTTP testing tool. + +## Prerequisites + +- [Hurl](https://hurl.dev/docs/installation.html) ≥ 4.0 + Install: `cargo install hurl` or via your package manager + +## Configuration + +Edit `.env` to match your local instance. This file is the single source of +truth: `run.sh` sources it for shell variables and passes it to Hurl as +`--variables-file`. + +``` +base_url=http://localhost:8087 +username=admin +email=admin@example.com +password=TestPassword1! +``` + +## Running the tests + +```bash +# First-time setup (run once on a fresh instance) +hurl --variables-file tests/api/hurl.vars --test tests/api/setup.hurl + +# Contacts CRUD scenario +hurl --variables-file tests/api/hurl.vars --test tests/api/contacts.hurl + +# All scenarios at once +hurl --variables-file tests/api/hurl.vars --test tests/api/setup.hurl tests/api/contacts.hurl + +# With full request/response output +hurl --variables-file tests/api/hurl.vars --test --verbose tests/api/contacts.hurl + +# Generate an HTML report +hurl --variables-file tests/api/hurl.vars --test --report-html /tmp/hurl-report tests/api/contacts.hurl +``` + +## Test files + +| File | Description | +|---|---| +| `setup.hurl` | One-time admin account creation; also asserts the endpoint is locked afterwards | +| `contacts.hurl` | Full contacts CRUD scenario (13 steps, see below) | +| `.env` | Variables: `base_url`, `username`, `email`, `password` — used by both Hurl and `run.sh` | + +## Scenario: `contacts.hurl` + +| Step | Description | +|---|---| +| 1 | Login – capture JWT token | +| 2 | List address books – assert system book is present and read-only | +| 3 | Create personal address book – capture `book_id` | +| 4 | List contacts in new book – assert empty | +| 5 | Create contact John Doe – capture `contact_id` | +| 6 | List contacts – assert exactly 1 result with John Doe's id | +| 7 | Get John Doe – assert all fields, capture `ETag` | +| 8 | Update John Doe (nickname, org, notes) with `If-Match` – assert new values, capture refreshed `ETag` | +| 9 | Delete John Doe with `If-Match` | +| 10 | List contacts – assert empty again | +| 11 | Delete personal address book | +| 12 | List address books – assert `book_id` no longer present | +| 13 | List system address book – assert non-empty collection of OxiCloud users | + +## Legacy bash tests + +`test.sh` and `common.sh` are the original curl/bash scripts kept for reference. +Run them with `bash tests/api/test.sh` from the repo root (requires `jq`). diff --git a/tests/api/contacts.hurl b/tests/api/contacts.hurl new file mode 100644 index 00000000..cc49855a --- /dev/null +++ b/tests/api/contacts.hurl @@ -0,0 +1,258 @@ +# ============================================================= +# OxiCloud – Contacts API end-to-end scenario +# ============================================================= +# Run: +# hurl --variables-file tests/api/hurl.vars --test tests/api/contacts.hurl +# +# Variables required (see hurl.vars): +# base_url – e.g. http://localhost:8087 +# username – OxiCloud username +# password – OxiCloud password +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Login and capture the JWT token +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" +[Asserts] +jsonpath "$.access_token" isString +jsonpath "$.token_type" == "Bearer" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – List address books (at least the system book) +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$" isCollection +# System address book must always be present +jsonpath "$[?(@.id == 'system')].is_system" == true +jsonpath "$[?(@.id == 'system')].is_readonly" == true + + +# ───────────────────────────────────────────────────────────── +# Step 3 – Create a personal address book +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/address-books +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "Personal", + "description": "Personal address book created by Hurl tests", + "is_public": false +} + +HTTP 201 +[Captures] +book_id: jsonpath "$.id" +[Asserts] +jsonpath "$.id" isString +jsonpath "$.name" == "Personal" +jsonpath "$.description" == "Personal address book created by Hurl tests" +jsonpath "$.is_public" == false +jsonpath "$.is_system" == false +jsonpath "$.is_readonly" == false + + +# ───────────────────────────────────────────────────────────── +# Step 4 – List contacts in the new book – must be empty +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books/{{book_id}}/contacts +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$" count == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 5 – Create contact John Doe +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/address-books/{{book_id}}/contacts +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "first_name": "John", + "last_name": "Doe", + "full_name": "John Doe", + "email": [ + { + "email": "john.doe@example.com", + "type": "work", + "is_primary": true + } + ], + "phone": [ + { + "number": "+1-555-0100", + "type": "mobile", + "is_primary": true + } + ] +} + +HTTP 201 +[Captures] +contact_id: jsonpath "$.id" +[Asserts] +jsonpath "$.id" isString +jsonpath "$.address_book_id" == {{book_id}} +jsonpath "$.first_name" == "John" +jsonpath "$.last_name" == "Doe" +jsonpath "$.full_name" == "John Doe" +jsonpath "$.email" count == 1 +jsonpath "$.email[0].email" == "john.doe@example.com" +jsonpath "$.email[0].type" == "work" +jsonpath "$.email[0].is_primary" == true +jsonpath "$.phone" count == 1 +jsonpath "$.phone[0].number" == "+1-555-0100" +jsonpath "$.phone[0].type" == "mobile" +jsonpath "$.phone[0].is_primary" == true + + +# ───────────────────────────────────────────────────────────── +# Step 6 – List contacts – exactly 1 result, must be John Doe +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books/{{book_id}}/contacts +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[*].id" contains {{contact_id}} +jsonpath "$[0].first_name" == "John" +jsonpath "$[0].last_name" == "Doe" +jsonpath "$[0].full_name" == "John Doe" + + +# ───────────────────────────────────────────────────────────── +# Step 7 – Get John Doe by id and verify all fields +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}} +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +etag: header "ETag" +[Asserts] +header "ETag" exists +jsonpath "$.id" == {{contact_id}} +jsonpath "$.address_book_id" == {{book_id}} +jsonpath "$.first_name" == "John" +jsonpath "$.last_name" == "Doe" +jsonpath "$.full_name" == "John Doe" +jsonpath "$.email[0].email" == "john.doe@example.com" +jsonpath "$.email[0].type" == "work" +jsonpath "$.email[0].is_primary" == true +jsonpath "$.phone[0].number" == "+1-555-0100" +jsonpath "$.phone[0].type" == "mobile" + + +# ───────────────────────────────────────────────────────────── +# Step 8 – Update John Doe: add nickname, notes, and organisation +# Uses If-Match for optimistic concurrency +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}} +Authorization: Bearer {{token}} +Content-Type: application/json +If-Match: {{etag}} +{ + "first_name": "John", + "last_name": "Doe", + "full_name": "John Doe", + "nickname": "JD", + "organization": "ACME Corp", + "notes": "Updated via Hurl test", + "email": [ + { + "email": "john.doe@example.com", + "type": "work", + "is_primary": true + } + ], + "phone": [ + { + "number": "+1-555-0100", + "type": "mobile", + "is_primary": true + } + ] +} + +HTTP 200 +[Captures] +etag: header "ETag" +[Asserts] +header "ETag" exists +jsonpath "$.id" == {{contact_id}} +jsonpath "$.first_name" == "John" +jsonpath "$.last_name" == "Doe" +jsonpath "$.nickname" == "JD" +jsonpath "$.organization" == "ACME Corp" +jsonpath "$.notes" == "Updated via Hurl test" + + +# ───────────────────────────────────────────────────────────── +# Step 9 – Delete John Doe (uses refreshed ETag from step 8) +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}} +Authorization: Bearer {{token}} +If-Match: {{etag}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 10 – Address book must be empty again +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books/{{book_id}}/contacts +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 11 – Delete the personal address book +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/address-books/{{book_id}} +Authorization: Bearer {{token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 12 – Verify the address book is gone from the list +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not contains {{book_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 13 – List the system address book (OxiCloud users) +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books/system/contacts +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$" isCollection diff --git a/tests/api/run.sh b/tests/api/run.sh new file mode 100755 index 00000000..5808c422 --- /dev/null +++ b/tests/api/run.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Full Hurl API test runner. +# Starts postgres + OxiCloud server, runs Hurl tests, tears everything down. +# +# Usage (from repo root): +# bash tests/api/run.sh +# +# Prerequisites: docker, cargo, hurl ≥ 4.0 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +API_DIR="$REPO_ROOT/tests/api" + +# .env is the single source of truth for connection details and credentials. +# shellcheck source=.env +source "$API_DIR/.env" + +# Derive server port from base_url (e.g. http://localhost:8087 → 8087) +SERVER_PORT="${base_url##*:}" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +log() { echo "[api-test] $*"; } +die() { echo "[api-test] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 1 + done +} + +# ── Teardown (always runs on exit) ──────────────────────────────────────────── + +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + bash "$COMMON/stop-db.sh" +} + +trap cleanup EXIT + +# ── 1. Start postgres ───────────────────────────────────────────────────────── + +bash "$COMMON/spawn-db.sh" + +# ── 2. Load shared server env + port from .env ─────────────────────────────── + +set -a +# shellcheck source=../common/server.env +source "$COMMON/server.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage" +set +a + +mkdir -p "$OXICLOUD_STORAGE_PATH" + +# ── 3. Start OxiCloud server ────────────────────────────────────────────────── + +log "Building and starting OxiCloud server on port $SERVER_PORT..." +cd "$REPO_ROOT" +cargo run & +SERVER_PID=$! +log "Waiting for server at $base_url..." +wait_for_http "$base_url/ready" 120 +log "Server is ready." + +# ── 4. Run Hurl tests ───────────────────────────────────────────────────────── + +log "Running Hurl tests..." +hurl --variables-file "$API_DIR/.env" --test --jobs 1 \ + "$API_DIR/setup.hurl" \ + "$API_DIR/contacts.hurl" + +log "All tests passed." diff --git a/tests/api/setup.hurl b/tests/api/setup.hurl new file mode 100644 index 00000000..6af8691a --- /dev/null +++ b/tests/api/setup.hurl @@ -0,0 +1,43 @@ +# ============================================================= +# OxiCloud – First-time admin setup +# ============================================================= +# Creates the initial admin account. Only works once: the +# endpoint is disabled after the first admin exists. +# +# Run: +# hurl --variables-file tests/api/hurl.vars --test tests/api/setup.hurl +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Create the first admin account +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/setup +Content-Type: application/json +{ + "username": "{{username}}", + "email": "{{email}}", + "password": "{{password}}" +} + +HTTP 201 +[Asserts] +jsonpath "$.username" == "{{username}}" +jsonpath "$.email" == "{{email}}" +jsonpath "$.role" == "admin" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – Verify the endpoint is now disabled (admin exists) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/setup +Content-Type: application/json +{ + "username": "another", + "email": "another@example.com", + "password": "irrelevant" +} + +HTTP 403 +[Asserts] +jsonpath "$.error_type" == "SystemAlreadyInitialized" diff --git a/tests/e2e/docker-compose.test.yml b/tests/common/docker-compose.test.yml similarity index 100% rename from tests/e2e/docker-compose.test.yml rename to tests/common/docker-compose.test.yml diff --git a/tests/common/server.env b/tests/common/server.env new file mode 100644 index 00000000..990c30cd --- /dev/null +++ b/tests/common/server.env @@ -0,0 +1,18 @@ +# Shared test-server environment variables. +# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node). +# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here — +# each test suite sets those to avoid port/directory conflicts. + +DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_STATIC_PATH=./static +OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars +OXICLOUD_ENABLE_AUTH=true +OXICLOUD_ENABLE_TRASH=true +OXICLOUD_ENABLE_SEARCH=true +OXICLOUD_ENABLE_FILE_SHARING=true +OXICLOUD_ENABLE_MUSIC=true +OXICLOUD_EXPOSE_SYSTEM_USERS=true +OXICLOUD_WOPI_ENABLED=false +OXICLOUD_OIDC_ENABLED=false +RUST_LOG=info diff --git a/tests/common/spawn-db.sh b/tests/common/spawn-db.sh new file mode 100755 index 00000000..92d9fbad --- /dev/null +++ b/tests/common/spawn-db.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE_FILE="$(dirname "$0")/docker-compose.test.yml" + +wait_for_port() { + local host="$1" port="$2" timeout="${3:-30}" + local deadline=$(( $(date +%s) + timeout )) + until nc -z "$host" "$port" 2>/dev/null; do + [[ $(date +%s) -ge $deadline ]] && echo "Timeout waiting for $host:$port" >&2 && exit 1 + sleep 0.5 + done +} + +echo "[setup] Starting test postgres..." +docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true +docker compose -f "$COMPOSE_FILE" up -d +echo "[setup] Waiting for postgres on port 5433..." +wait_for_port 127.0.0.1 5433 +echo "[setup] Postgres is ready." diff --git a/tests/common/stop-db.sh b/tests/common/stop-db.sh new file mode 100755 index 00000000..98af3288 --- /dev/null +++ b/tests/common/stop-db.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE_FILE="$(dirname "$0")/docker-compose.test.yml" + +echo "[teardown] Stopping test postgres..." +docker compose -f "$COMPOSE_FILE" down -v diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 00000000..64564303 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,3 @@ + + + end to end tests via playwright diff --git a/tests/e2e/package.json b/tests/e2e/package.json index c80e7327..9cc18d17 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -4,9 +4,9 @@ "description": "", "main": "index.js", "scripts": { - "pretest": "node -e \"(async()=>{await require('./spawn-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\"", + "pretest": "bash ../common/spawn-db.sh", "test": "npx playwright test", - "posttest": "node -e \"(async()=>{await require('./stop-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\"" + "posttest": "bash ../common/stop-db.sh" }, "keywords": [], "author": "", diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index ebb5974c..de972f02 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -1,4 +1,21 @@ import { defineConfig, devices } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** Parse a KEY=VALUE env file, skipping blank lines and comments. */ +function loadEnv(filePath: string): Record { + const env: Record = {}; + for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + env[trimmed.slice(0, idx)] = trimmed.slice(idx + 1); + } + return env; +} + +const commonEnv = loadEnv(path.join(__dirname, '../common/server.env')); export default defineConfig({ testDir: './scenarios', @@ -39,19 +56,9 @@ export default defineConfig({ stdout: 'pipe', stderr: 'pipe', env: { - DATABASE_URL: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test', - OXICLOUD_SERVER_PORT: 8087, - OXICLOUD_DB_CONNECTION_STRING: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test', + ...commonEnv, + OXICLOUD_SERVER_PORT: '8087', OXICLOUD_STORAGE_PATH: './tests/e2e/storage', - OXICLOUD_STATIC_PATH: './static', - OXICLOUD_JWT_SECRET: 'test-secret-do-not-use-in-prod-minimum-32-chars', - OXICLOUD_ENABLE_AUTH: 'true', - OXICLOUD_ENABLE_TRASH: 'true', - OXICLOUD_ENABLE_SEARCH: 'true', - OXICLOUD_ENABLE_FILE_SHARING: 'true', - OXICLOUD_WOPI_ENABLED: 'false', - OXICLOUD_OIDC_ENABLED: 'false', - RUST_LOG: 'warn', }, }, }); diff --git a/tests/e2e/spawn-db.js b/tests/e2e/spawn-db.js deleted file mode 100644 index f43ef075..00000000 --- a/tests/e2e/spawn-db.js +++ /dev/null @@ -1,31 +0,0 @@ -const { execSync, spawnSync } = require('child_process'); -const net = require('net'); -const path = require('path'); - -const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml'); -const CMD = `docker compose -f ${COMPOSE_FILE}`; - -function waitForPort(host, port, timeoutMs = 30_000) { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve, reject) => { - function attempt() { - const sock = net.connect(port, host); - sock.once('connect', () => { sock.destroy(); resolve(); }); - sock.once('error', () => { - sock.destroy(); - if (Date.now() >= deadline) return reject(new Error(`Timeout waiting for ${host}:${port}`)); - setTimeout(attempt, 500); - }); - } - attempt(); - }); -} - -module.exports = async function globalSetup() { - console.log('[setup] Starting test postgres (database is empty) ...'); - execSync(`${CMD} down`, { stdio: 'inherit' }); - execSync(`${CMD} up -d`, { stdio: 'inherit' }); - console.log('[setup] Waiting for postgres on port 5433...'); - await waitForPort('127.0.0.1', 5433); - console.log('[setup] Postgres is ready.'); -}; diff --git a/tests/e2e/stop-db.js b/tests/e2e/stop-db.js deleted file mode 100644 index dcb91d81..00000000 --- a/tests/e2e/stop-db.js +++ /dev/null @@ -1,9 +0,0 @@ -const { execSync } = require('child_process'); -const path = require('path'); - -const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml'); - -module.exports = async function globalTeardown() { - console.log('[teardown] Stopping test postgres...'); - execSync(`docker compose -f ${COMPOSE_FILE} down -v`, { stdio: 'inherit' }); -};