diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7e6bf3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + # ── Lint & unit tests (no database needed) ────────────────────────────────── + check: + name: Lint & Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Cargo registry & build + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run Clippy + run: cargo clippy -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Run unit tests + run: cargo test --test unit --verbose + + # ── Integration tests (requires Postgres) ─────────────────────────────────── + integration: + name: Integration Tests + runs-on: ubuntu-latest + needs: check + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: zcloudpass_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgres://test:test@localhost:5432/zcloudpass_test + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry & build + uses: Swatinem/rust-cache@v2 + + - name: Run integration tests + run: cargo test --test integration --verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dee81e7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release + +on: + push: + tags: + - "v*" + +env: + CARGO_TERM_COLOR: always + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +permissions: + contents: write + packages: write + +jobs: + release: + name: Build & Release + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry & build + uses: Swatinem/rust-cache@v2 + + - name: Build release binary + run: cargo build --release + + - name: Run tests + run: cargo test --verbose + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Upload release binary + uses: softprops/action-gh-release@v2 + with: + files: target/release/zcloudpass-backend + generate_release_notes: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebc3237 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# ── Builder stage ───────────────────────────────────────────────────────────── +FROM rust:1.85-slim AS builder + +WORKDIR /app + +# Install build dependencies for native TLS (required by sqlx native-tls) +RUN apt-get update && \ + apt-get install -y --no-install-recommends pkg-config libssl-dev && \ + rm -rf /var/lib/apt/lists/* + +# Copy manifests first for better layer caching +COPY Cargo.toml Cargo.lock ./ + +# Create a dummy main to cache dependency builds +RUN mkdir src && \ + echo "fn main() {}" > src/main.rs && \ + echo "" > src/lib.rs && \ + cargo build --release && \ + rm -rf src + +# Copy actual source and build +COPY src ./src +COPY tests ./tests +RUN touch src/main.rs src/lib.rs && \ + cargo build --release + +# ── Runtime stage ───────────────────────────────────────────────────────────── +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates libssl3 && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/zcloudpass-backend /usr/local/bin/zcloudpass-backend + +ENV BIND_ADDRESS=0.0.0.0:3000 + +EXPOSE 3000 + +ENTRYPOINT ["zcloudpass-backend"] diff --git a/src/lib.rs b/src/lib.rs index 7012ba1..d95d0e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,3 +5,54 @@ pub mod middleware; pub struct AppState { pub db: sqlx::PgPool, } + +/// Creates the `users` and `sessions` tables if they do not already exist. +/// Also attempts to enable the `pgcrypto` extension (best-effort). +pub async fn ensure_tables(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { + match sqlx::query("CREATE EXTENSION IF NOT EXISTS pgcrypto;") + .execute(pool) + .await + { + Ok(_) => println!("pgcrypto extension ensured"), + Err(e) => eprintln!( + "notice: could not create pgcrypto extension (continuing): {:?}. \ + If you need pgcrypto functionality, create the extension as a superuser.", + e + ), + } + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255), + email VARCHAR(255) UNIQUE NOT NULL, + srp_salt TEXT, + srp_verifier TEXT, + encrypted_vault TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP, + account_status VARCHAR(20) DEFAULT 'active' + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 12030c3..9e7a00c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,9 @@ async fn main() { .await .expect("Failed to connect to database"); - ensure_tables(&pool).await.expect("Failed to ensure tables"); + zcloudpass_backend::ensure_tables(&pool) + .await + .expect("Failed to ensure tables"); let app_state = AppState { db: pool.clone() }; let shared_state = Arc::new(app_state); @@ -38,51 +40,3 @@ async fn main() { axum::serve(listener, app).await.unwrap(); } -async fn ensure_tables(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { - match sqlx::query("CREATE EXTENSION IF NOT EXISTS pgcrypto;") - .execute(pool) - .await - { - Ok(_) => println!("pgcrypto extension ensured"), - Err(e) => eprintln!( - "notice: could not create pgcrypto extension (continuing): {:?}. \ - If you need pgcrypto functionality, create the extension as a superuser.", - e - ), - } - - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(255), - email VARCHAR(255) UNIQUE NOT NULL, - srp_salt TEXT, - srp_verifier TEXT, - encrypted_vault TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_login TIMESTAMP, - account_status VARCHAR(20) DEFAULT 'active' - ) - "#, - ) - .execute(pool) - .await?; - - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS sessions ( - id SERIAL PRIMARY KEY, - user_id INT REFERENCES users(id) ON DELETE CASCADE, - session_token TEXT UNIQUE NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP, - last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - "#, - ) - .execute(pool) - .await?; - - Ok(()) -} diff --git a/tests/integration/api_tests.rs b/tests/integration/api_tests.rs new file mode 100644 index 0000000..1fa2474 --- /dev/null +++ b/tests/integration/api_tests.rs @@ -0,0 +1,466 @@ +//! Integration tests for the zcloudpass-backend API. +//! +//! These tests require a running Postgres instance. +//! Set `DATABASE_URL` to point to a **test** database before running: +//! +//! DATABASE_URL="postgres://user:password@localhost:5432/zcloudpass_test" cargo test --test integration + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::routing::get; +use axum::{Extension, Router}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use std::sync::Arc; +use tower::ServiceExt; +use tower_http::cors::CorsLayer; +use zcloudpass_backend::{AppState, api}; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +/// Builds the full application router backed by a real Postgres pool. +/// Truncates all data before each test group for isolation. +async fn setup() -> (Router, PgPool) { + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://user:password@localhost:5432/zcloudpass_test".to_string()); + + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to test database — is DATABASE_URL set?"); + + zcloudpass_backend::ensure_tables(&pool) + .await + .expect("Failed to ensure tables"); + + // Clean slate + sqlx::query("TRUNCATE TABLE sessions, users RESTART IDENTITY CASCADE") + .execute(&pool) + .await + .expect("Failed to truncate tables"); + + let state = Arc::new(AppState { db: pool.clone() }); + + let app = Router::new() + .merge(api::router()) + .route("/health", get(|| async { "zcloudpass: ok" })) + .layer(CorsLayer::permissive()) + .layer(Extension(state)); + + (app, pool) +} + +/// Helper: send a JSON request and return (status, body as serde_json::Value). +async fn json_request( + app: Router, + method: &str, + uri: &str, + body: Option, + bearer_token: Option<&str>, +) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(uri); + + if body.is_some() { + builder = builder.header("content-type", "application/json"); + } + + if let Some(token) = bearer_token { + builder = builder.header("authorization", format!("Bearer {}", token)); + } + + let req_body = match body { + Some(v) => Body::from(serde_json::to_vec(&v).unwrap()), + None => Body::empty(), + }; + + let response = app.oneshot(builder.body(req_body).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + + let value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).to_string())) + }; + + (status, value) +} + +/// Register a user and return the response JSON. +async fn register(app: Router, email: &str, password: &str) -> (StatusCode, Value) { + json_request( + app, + "POST", + "/api/v1/auth/register", + Some(json!({ + "email": email, + "master_password": password + })), + None, + ) + .await +} + +/// Login and return the session token. +async fn login(app: Router, email: &str, password: &str) -> (StatusCode, Value) { + json_request( + app, + "POST", + "/api/v1/auth/login", + Some(json!({ + "email": email, + "master_password": password + })), + None, + ) + .await +} + +// ─── Health endpoints ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn health_endpoint_returns_ok() { + let (app, _pool) = setup().await; + let (status, _) = json_request(app, "GET", "/health", None, None).await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn auth_health_endpoint_returns_ok() { + let (app, _pool) = setup().await; + let (status, _) = json_request(app, "GET", "/api/v1/auth/health", None, None).await; + assert_eq!(status, StatusCode::OK); +} + +// ─── Registration ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn register_user_succeeds() { + let (app, _pool) = setup().await; + let (status, body) = register(app, "alice@test.com", "strongpassword").await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["email"], "alice@test.com"); + assert!(body["id"].is_number(), "response should contain numeric id"); +} + +#[tokio::test] +async fn register_duplicate_email_returns_conflict() { + let (app, pool) = setup().await; + + // First registration + let (status, _) = register(app, "dup@test.com", "pass1").await; + assert_eq!(status, StatusCode::OK); + + // Duplicate — rebuild router from same pool (oneshot consumes the router) + let state = Arc::new(AppState { db: pool.clone() }); + let app2 = Router::new() + .merge(api::router()) + .layer(CorsLayer::permissive()) + .layer(Extension(state)); + + let (status2, _) = register(app2, "dup@test.com", "pass2").await; + assert_eq!(status2, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn register_with_username_and_vault() { + let (app, _pool) = setup().await; + + let (status, body) = json_request( + app, + "POST", + "/api/v1/auth/register", + Some(json!({ + "username": "bob", + "email": "bob@test.com", + "master_password": "password123", + "encrypted_vault": "initial_encrypted_data" + })), + None, + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["email"], "bob@test.com"); +} + +// ─── Login / Session ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn login_with_correct_password_returns_token() { + let (app, pool) = setup().await; + + // Register + register(app, "login@test.com", "mypassword").await; + + // Login + let state = Arc::new(AppState { db: pool.clone() }); + let app2 = Router::new() + .merge(api::router()) + .layer(CorsLayer::permissive()) + .layer(Extension(state)); + + let (status, body) = login(app2, "login@test.com", "mypassword").await; + assert_eq!(status, StatusCode::OK); + assert!( + body["session_token"].is_string(), + "should return a session_token" + ); + assert!( + body["expires_at"].is_string(), + "should return an expires_at" + ); +} + +#[tokio::test] +async fn login_with_wrong_password_returns_unauthorized() { + let (app, pool) = setup().await; + + register(app, "wrongpw@test.com", "correctpass").await; + + let state = Arc::new(AppState { db: pool.clone() }); + let app2 = Router::new() + .merge(api::router()) + .layer(CorsLayer::permissive()) + .layer(Extension(state)); + + let (status, _) = login(app2, "wrongpw@test.com", "wrongpass").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn login_nonexistent_user_returns_not_found() { + let (app, _pool) = setup().await; + let (status, _) = login(app, "ghost@test.com", "anything").await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// ─── Vault CRUD ───────────────────────────────────────────────────────────── + +/// Helper: register + login, return (fresh router, token). +async fn register_and_login(pool: &PgPool, email: &str, password: &str) -> (Router, String) { + let make_app = || { + let state = Arc::new(AppState { db: pool.clone() }); + Router::new() + .merge(api::router()) + .route("/health", get(|| async { "zcloudpass: ok" })) + .layer(CorsLayer::permissive()) + .layer(Extension(state)) + }; + + // Register + let (st, _) = register(make_app(), email, password).await; + assert_eq!(st, StatusCode::OK); + + // Login + let (st, body) = login(make_app(), email, password).await; + assert_eq!(st, StatusCode::OK); + let token = body["session_token"].as_str().unwrap().to_string(); + + (make_app(), token) +} + +#[tokio::test] +async fn get_vault_initially_returns_null() { + let (app, pool) = setup().await; + drop(app); + let (app, token) = register_and_login(&pool, "vault@test.com", "pw123").await; + + let (status, body) = json_request(app, "GET", "/api/v1/vault/", None, Some(&token)).await; + assert_eq!(status, StatusCode::OK); + assert!( + body["encrypted_vault"].is_null(), + "vault should be null initially" + ); +} + +#[tokio::test] +async fn put_then_get_vault_round_trip() { + let (app, pool) = setup().await; + drop(app); + let (app, token) = register_and_login(&pool, "roundtrip@test.com", "pw").await; + + // PUT vault + let make_app = || { + let state = Arc::new(AppState { db: pool.clone() }); + Router::new() + .merge(api::router()) + .layer(CorsLayer::permissive()) + .layer(Extension(state)) + }; + + let (status, _) = json_request( + app, + "PUT", + "/api/v1/vault/", + Some(json!({ "encrypted_vault": "ENCRYPTED_BLOB_ABC" })), + Some(&token), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // GET vault + let (status, body) = + json_request(make_app(), "GET", "/api/v1/vault/", None, Some(&token)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["encrypted_vault"], "ENCRYPTED_BLOB_ABC"); + + // UPDATE vault + let (status, _) = json_request( + make_app(), + "PUT", + "/api/v1/vault/", + Some(json!({ "encrypted_vault": "UPDATED_BLOB_XYZ" })), + Some(&token), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Verify update + let (status, body) = + json_request(make_app(), "GET", "/api/v1/vault/", None, Some(&token)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["encrypted_vault"], "UPDATED_BLOB_XYZ"); +} + +#[tokio::test] +async fn vault_without_auth_returns_unauthorized() { + let (app, _pool) = setup().await; + + let (status, _) = json_request(app, "GET", "/api/v1/vault/", None, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn vault_with_invalid_token_returns_unauthorized() { + let (app, _pool) = setup().await; + + let (status, _) = json_request( + app, + "GET", + "/api/v1/vault/", + None, + Some("not-a-real-token"), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +// ─── Change password ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn change_password_then_login_with_new_password() { + let (app, pool) = setup().await; + drop(app); + let (_app, token) = register_and_login(&pool, "chpw@test.com", "oldpass").await; + + let make_app = || { + let state = Arc::new(AppState { db: pool.clone() }); + Router::new() + .merge(api::router()) + .layer(CorsLayer::permissive()) + .layer(Extension(state)) + }; + + // Change password + let (status, _) = json_request( + make_app(), + "POST", + "/api/v1/auth/change-password", + Some(json!({ + "current_password": "oldpass", + "new_password": "newpass" + })), + Some(&token), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Old password should fail + let (status, _) = login(make_app(), "chpw@test.com", "oldpass").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // New password should succeed + let (status, body) = login(make_app(), "chpw@test.com", "newpass").await; + assert_eq!(status, StatusCode::OK); + assert!(body["session_token"].is_string()); +} + +#[tokio::test] +async fn change_password_with_wrong_current_returns_unauthorized() { + let (app, pool) = setup().await; + drop(app); + let (_app, token) = register_and_login(&pool, "badchpw@test.com", "realpass").await; + + let make_app = || { + let state = Arc::new(AppState { db: pool.clone() }); + Router::new() + .merge(api::router()) + .layer(CorsLayer::permissive()) + .layer(Extension(state)) + }; + + let (status, _) = json_request( + make_app(), + "POST", + "/api/v1/auth/change-password", + Some(json!({ + "current_password": "wrongpass", + "new_password": "newpass" + })), + Some(&token), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn change_password_without_auth_returns_unauthorized() { + let (app, _pool) = setup().await; + + let (status, _) = json_request( + app, + "POST", + "/api/v1/auth/change-password", + Some(json!({ + "current_password": "x", + "new_password": "y" + })), + None, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +// ─── Session endpoint (alias for /login) ──────────────────────────────────── + +#[tokio::test] +async fn session_endpoint_also_works() { + let (app, pool) = setup().await; + register(app, "session@test.com", "pw").await; + + let state = Arc::new(AppState { db: pool.clone() }); + let app2 = Router::new() + .merge(api::router()) + .layer(CorsLayer::permissive()) + .layer(Extension(state)); + + let (status, body) = json_request( + app2, + "POST", + "/api/v1/auth/session", + Some(json!({ + "email": "session@test.com", + "master_password": "pw" + })), + None, + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert!(body["session_token"].is_string()); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs new file mode 100644 index 0000000..b206a99 --- /dev/null +++ b/tests/integration/main.rs @@ -0,0 +1 @@ +mod api_tests;