notedthat-api-http 0.7.2

HTTP API surface for NotedThat
Documentation
use std::collections::BTreeMap;

use axum::body::Body;
use axum::http::{Request, StatusCode};
use notedthat_core::{AccessPolicy, Verb, Who};
use tower::ServiceExt;

use super::fixture::{
    TOKEN, app, grant, grant_under, json, listed_keys, policy, signed_in_everything,
};

fn notes(
    rules: impl IntoIterator<Item = notedthat_core::AccessRule>,
) -> BTreeMap<String, AccessPolicy> {
    BTreeMap::from([("notes".to_string(), policy(rules))])
}

async fn keys_for(app: axum::Router, uri: &str, token: Option<&str>) -> Vec<String> {
    let mut builder = Request::builder().uri(uri);
    if let Some(token) = token {
        builder = builder.header("authorization", format!("Bearer {token}"));
    }
    let response = app
        .oneshot(builder.body(Body::empty()).expect("request"))
        .await
        .expect("response");
    assert_eq!(response.status(), StatusCode::OK);
    listed_keys(response).await
}

#[tokio::test]
async fn a_listing_shows_every_key_the_credential_holder_may_see() {
    // Given
    let app = app(notes([signed_in_everything()])).await;

    // When
    let keys = keys_for(app, "/api/v1/knowledgebases/notes", Some(TOKEN)).await;

    // Then — including the internal namespace, which the credential holder
    // always reaches.
    assert!(keys.contains(&".notedthat/manifest.json".to_string()));
    assert!(keys.contains(&"internal/secret.md".to_string()));
}

#[tokio::test]
async fn a_prefix_scoped_list_grant_shows_only_keys_under_that_prefix() {
    // Given
    let app = app(notes([grant_under(
        Who::Anyone,
        [Verb::List],
        &["public/**"],
    )]))
    .await;

    // When
    let keys = keys_for(app, "/api/v1/knowledgebases/notes", None).await;

    // Then
    assert_eq!(
        keys,
        vec![
            "public/deep/note.md".to_string(),
            "public/index.md".to_string()
        ],
        "only the granted subtree, and `public.md` is not under `public/`"
    );
}

#[tokio::test]
async fn a_listing_drops_the_internal_namespace_for_an_anonymous_caller() {
    // Given
    let app = app(notes([grant(Who::Anyone, [Verb::List])])).await;

    // When
    let keys = keys_for(app, "/api/v1/knowledgebases/notes", None).await;

    // Then
    assert!(!keys.is_empty());
    assert!(
        !keys.iter().any(|key| key.starts_with(".notedthat")),
        "{keys:?}"
    );
}

#[tokio::test]
async fn a_caller_prefix_outside_the_granted_scope_lists_nothing() {
    // Given
    let app = app(notes([grant_under(
        Who::Anyone,
        [Verb::List],
        &["public/**"],
    )]))
    .await;

    // When
    let response = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/knowledgebases/notes?prefix=internal/")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    // Then — an empty page, not an error: the caller asked a legitimate question
    // whose answer happens to be nothing.
    assert_eq!(response.status(), StatusCode::OK);
    let body = json(response).await;
    assert_eq!(body["objects"], serde_json::json!([]));
    assert_eq!(body["truncated"], serde_json::json!(false));
    assert_eq!(body["next_cursor"], serde_json::Value::Null);
}

#[tokio::test]
async fn a_caller_prefix_inside_the_granted_scope_narrows_further() {
    // Given
    let app = app(notes([grant_under(
        Who::Anyone,
        [Verb::List],
        &["public/**"],
    )]))
    .await;

    // When
    let keys = keys_for(
        app,
        "/api/v1/knowledgebases/notes?prefix=public/deep/",
        None,
    )
    .await;

    // Then
    assert_eq!(keys, vec!["public/deep/note.md".to_string()]);
}

#[tokio::test]
async fn listing_is_refused_without_a_list_grant() {
    // Given — `read` alone does not let a caller enumerate.
    let app = app(notes([grant(Who::Anyone, [Verb::Read])])).await;

    // When
    let response = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/knowledgebases/notes")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    // Then — `404`, not `401`: an anonymous denial must be indistinguishable
    // from an undeclared slug, or the status enumerates declared knowledge bases.
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn an_anonymous_denial_is_indistinguishable_from_an_undeclared_slug() {
    // Given — `notes` is declared but grants an anonymous caller nothing, and
    // `nope` is not declared at all. `visible_in_listing` keeps `notes` out of
    // the anonymous index; this is the other half of that concealment.
    let app = app(notes([signed_in_everything()])).await;

    // When
    let hidden = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/api/v1/knowledgebases/notes")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
    let undeclared = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/knowledgebases/nope")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    // Then — the same status, and the same message once the slug the caller
    // asked for is factored out. A body that differed by anything else would be
    // the very oracle the status codes were made to agree about.
    assert_eq!(hidden.status(), StatusCode::NOT_FOUND);
    assert_eq!(undeclared.status(), StatusCode::NOT_FOUND);
    let hidden = super::fixture::json(hidden).await;
    let undeclared = super::fixture::json(undeclared).await;
    assert_eq!(hidden["error"], undeclared["error"]);
    assert_eq!(
        hidden["message"]
            .as_str()
            .expect("message")
            .replace("notes", "<slug>"),
        undeclared["message"]
            .as_str()
            .expect("message")
            .replace("nope", "<slug>"),
    );
}

#[tokio::test]
async fn listing_an_undeclared_knowledge_base_is_not_found_rather_than_unauthorized() {
    // Given — an undeclared base does not exist for anyone, whatever they hold.
    let app = app(notes([signed_in_everything()])).await;

    // When
    let response = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/knowledgebases/nope")
                .header("authorization", format!("Bearer {TOKEN}"))
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    // Then
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn a_deny_under_a_prefix_hides_those_keys_from_a_whole_kb_listing() {
    // Given — the credential holder may see everything except `internal/`.
    let app = app(notes([
        signed_in_everything(),
        notedthat_core::AccessRule::deny(Who::SignedIn, [Verb::List]).under(
            notedthat_core::KeyPattern::parse("internal/**")
                .map(|p| vec![p])
                .expect("pattern"),
        ),
    ]))
    .await;

    // When — the allow-all shortcut is closed by the denial, so every key is
    // filtered; the internal namespace still comes through for the service token.
    let keys = keys_for(app, "/api/v1/knowledgebases/notes", Some(TOKEN)).await;

    // Then
    assert!(!keys.contains(&"internal/secret.md".to_string()));
    assert!(keys.contains(&"public/index.md".to_string()));
    assert!(keys.contains(&".notedthat/manifest.json".to_string()));
}

#[tokio::test]
async fn a_user_identity_never_sees_the_internal_namespace_in_a_listing() {
    // Given — the widest grant, which for the service token is the allow-all
    // shortcut; for a user it must not be.
    let app = app(notes([signed_in_everything()])).await;

    // When
    let keys = keys_for(
        app,
        "/api/v1/knowledgebases/notes",
        Some(super::fixture::ALICE_TOKEN),
    )
    .await;

    // Then
    assert!(
        !keys.iter().any(|key| key.starts_with(".notedthat")),
        "{keys:?}"
    );
    assert!(keys.contains(&"internal/secret.md".to_string()));
}