newton-core 0.4.18

newton protocol core sdk
//! Integration tests for `ApiKeyRepository::rotate` semantics.
//!
//! The rotation contract has security implications: the new bearer must
//! inherit the source's identity tuple AND its `expires_at`. A previous
//! version dropped expiration on rotation, which silently extended access
//! beyond the operator's original time bound. This file pins that property
//! against regressions.
//!
//! Tests follow the lazy-DB pattern from `crates/gateway/tests/common/mod.rs`:
//! they skip cleanly when no postgres is reachable (CI without a DB sidecar
//! or local dev without `just local-dev` running). When a DB IS reachable,
//! they exercise the real repository against the real schema, not a mock.

#![cfg(feature = "database")]

use alloy::primitives::Address;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use newton_core::database::{api_keys::ApiKeyRepository, ApiPermission, DatabaseConfig, DatabaseManager};
use sqlx::postgres::PgSslMode;
use std::{collections::HashSet, time::Duration};
use uuid::Uuid;

/// Connect to a test postgres or skip the test. Returns `None` when no DB
/// is reachable so CI runners without a postgres sidecar pass cleanly.
async fn lazy_repo() -> Option<ApiKeyRepository> {
    let url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "postgres://newton:newton@localhost:5432/newton_test".to_string());
    let config = DatabaseConfig {
        url,
        max_connections: 4,
        min_connections: 1,
        connect_timeout: Duration::from_secs(5),
        ssl_mode: PgSslMode::Prefer,
    };
    let db = DatabaseManager::new(config).await.ok()?;
    Some(ApiKeyRepository::new(db))
}

fn perms_rpc_read() -> HashSet<ApiPermission> {
    [ApiPermission::RpcRead].into_iter().collect()
}

#[tokio::test]
async fn rotate_preserves_source_expires_at() {
    let Some(repo) = lazy_repo().await else { return };

    let user_id = Uuid::new_v4();
    let address = Address::repeat_byte(0xAB);
    // Pin a specific expiration timestamp on the source row. The rotated
    // row MUST carry this exact value forward; silently dropping it to
    // None would extend the key's validity window — a subtle access-
    // control regression that would only surface on long-lived keys.
    // Truncate to seconds to dodge sub-second comparison drift inherent to
    // postgres `timestamptz` storage. The semantic property we care about
    // is "same instant," not "byte-identical timestamp." Reconstruct from
    // the integer-seconds timestamp so nanos are zeroed.
    let raw = Utc::now() + ChronoDuration::days(7);
    let expected_expiry: DateTime<Utc> = DateTime::from_timestamp(raw.timestamp(), 0).expect("valid timestamp");

    let old_bearer = format!("nidx_test_rotate_pres_{}", Uuid::new_v4().simple());
    let _old = repo
        .create(
            user_id,
            address,
            old_bearer.clone(),
            format!("rot-pres-{}", Uuid::new_v4().simple()),
            perms_rpc_read(),
            None,
            None,
            Some(expected_expiry),
        )
        .await
        .expect("create with expires_at");

    let new_bearer = format!("nidx_test_rotate_pres_new_{}", Uuid::new_v4().simple());
    let new = repo
        .rotate(
            &old_bearer,
            new_bearer,
            format!("rot-pres-new-{}", Uuid::new_v4().simple()),
        )
        .await
        .expect("rotate succeeds");

    // The security invariant: rotation does NOT silently extend validity.
    assert_eq!(
        new.expires_at,
        Some(expected_expiry),
        "rotation must preserve source expires_at; got {:?}",
        new.expires_at
    );
}

#[tokio::test]
async fn rotate_preserves_none_expires_at() {
    // Inverse case: a never-expiring source key rotates into a never-
    // expiring new key. The previous behavior happened to satisfy this by
    // accident (it always set None); the new behavior must satisfy it
    // intentionally by copying None forward.
    let Some(repo) = lazy_repo().await else { return };

    let user_id = Uuid::new_v4();
    let address = Address::repeat_byte(0xCD);
    let old_bearer = format!("nidx_test_rotate_none_{}", Uuid::new_v4().simple());
    let _old = repo
        .create(
            user_id,
            address,
            old_bearer.clone(),
            format!("rot-none-{}", Uuid::new_v4().simple()),
            perms_rpc_read(),
            None,
            None,
            None, // no expiration on source
        )
        .await
        .expect("create without expires_at");

    let new_bearer = format!("nidx_test_rotate_none_new_{}", Uuid::new_v4().simple());
    let new = repo
        .rotate(
            &old_bearer,
            new_bearer,
            format!("rot-none-new-{}", Uuid::new_v4().simple()),
        )
        .await
        .expect("rotate succeeds");

    assert_eq!(
        new.expires_at, None,
        "rotation of never-expiring key must produce never-expiring key"
    );
}

#[tokio::test]
async fn rotate_atomic_double_rotation_rejects_second_caller() {
    // Property: two concurrent rotations of the SAME source bearer can
    // succeed at most once. The second caller's deactivate finds
    // `is_active = false` (set by the first) and the SQL transaction
    // rolls back — neither row gets a duplicate replacement, the source
    // is not silently re-activated, no orphan row is left in the table.
    //
    // We exercise this serially (not concurrently) because the security
    // property we care about is "second rotate of an already-rotated
    // bearer must fail", not "exact concurrency contract." A serial
    // re-rotate of the source bearer reaches the same `is_active = true`
    // gate and produces the same RowNotFound the concurrent path would.
    let Some(repo) = lazy_repo().await else { return };

    let user_id = Uuid::new_v4();
    let address = Address::repeat_byte(0xEF);
    let old_bearer = format!("nidx_test_rotate_atomic_{}", Uuid::new_v4().simple());
    let _old = repo
        .create(
            user_id,
            address,
            old_bearer.clone(),
            format!("rot-atomic-{}", Uuid::new_v4().simple()),
            perms_rpc_read(),
            None,
            None,
            None,
        )
        .await
        .expect("create succeeds");

    // First rotate: succeeds.
    let new_bearer_1 = format!("nidx_test_rotate_atomic_n1_{}", Uuid::new_v4().simple());
    let _r1 = repo
        .rotate(
            &old_bearer,
            new_bearer_1,
            format!("rot-atomic-n1-{}", Uuid::new_v4().simple()),
        )
        .await
        .expect("first rotate succeeds");

    // Second rotate of the SAME source bearer: must fail with RowNotFound.
    let new_bearer_2 = format!("nidx_test_rotate_atomic_n2_{}", Uuid::new_v4().simple());
    let r2 = repo
        .rotate(
            &old_bearer,
            new_bearer_2.clone(),
            format!("rot-atomic-n2-{}", Uuid::new_v4().simple()),
        )
        .await;

    // The rotate path returns sqlx::Result<ApiKeyRecord>. Match by Display
    // string to avoid pulling sqlx into this test crate's direct deps —
    // sqlx is a transitive dep of newton-core's `database` feature and we
    // only need to assert the failure shape, not the typed variant.
    match r2 {
        Err(e) => {
            let msg = e.to_string();
            assert!(
                msg.contains("no rows returned") || msg.contains("RowNotFound"),
                "expected row-not-found-shaped error from second rotate; got {msg:?}"
            );
        }
        Ok(record) => panic!("rotate of already-rotated source must fail; got id={}", record.id),
    }

    // The second new bearer MUST NOT exist in the table — the rolled-back
    // transaction must not have leaked a partial INSERT.
    let probe = repo.get_by_key(&new_bearer_2).await.expect("get_by_key succeeds");
    assert!(
        probe.is_none(),
        "second rotate's new bearer must NOT exist in DB; rollback leaked"
    );
}