offline-cli 0.3.3

The Offline Protocol developer CLI
//! The setup operations against a fake developer API: what is read, what is
//! sent, and that a second run sends nothing.

use serde_json::{json, Value};
use wiremock::matchers::{body_partial_json, header, method, path};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};

use super::*;

const APP: &str = "app_e2e123";
const SHA: &str = "AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB:AB";

fn app_json(native: Value, origins: Value) -> Value {
    json!({ "id": APP, "name": "E2E", "origins": origins, "native_attestation": native, "android_package_names": [] })
}

async fn server_with(app: Value) -> MockServer {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path(format!("/api/v1/developers/applications/{APP}")))
        .and(header("authorization", "Bearer dev-token"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(json!({ "data": { "application": app } })),
        )
        .mount(&server)
        .await;
    for product in ["mesh_sdk", "proof_of_location"] {
        Mock::given(method("GET"))
            .and(path(format!(
                "/api/v1/developers/applications/{APP}/product-settings/{product}"
            )))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(json!({ "data": { "product": product, "settings": {} } })),
            )
            .mount(&server)
            .await;
    }
    server
}

fn ctx(server: &MockServer) -> AppContext {
    AppContext {
        client: DeveloperClient::new(format!("{}/api/v1", server.uri())).unwrap(),
        token: "dev-token".into(),
        app_id: APP.into(),
    }
}

fn patches(requests: &[Request]) -> Vec<Value> {
    requests
        .iter()
        .filter(|r| r.method.as_str() == "PATCH")
        .map(|r| serde_json::from_slice(&r.body).unwrap())
        .collect()
}

#[tokio::test]
async fn apply_registers_native_apps_origins_and_features_in_one_pass() {
    let server = server_with(app_json(json!(null), json!([]))).await;
    Mock::given(method("PATCH"))
        .and(path(format!("/api/v1/developers/applications/{APP}")))
        .respond_with(|req: &Request| {
            let body: Value = serde_json::from_slice(&req.body).unwrap();
            ResponseTemplate::new(200).set_body_json(json!({ "data": { "application": {
                "id": APP, "origins": body.get("origins").cloned().unwrap_or(json!([])),
                "native_attestation": body.get("native_attestation").cloned().unwrap_or(Value::Null),
            } } }))
        })
        .mount(&server)
        .await;
    Mock::given(method("PATCH"))
        .and(path(format!(
            "/api/v1/developers/applications/{APP}/product-settings/mesh_sdk"
        )))
        .and(body_partial_json(json!({ "mesh_telemetry": true })))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(json!({ "data": { "settings": { "mesh_telemetry": true } } })),
        )
        .mount(&server)
        .await;
    let desired = desired_from_config(&json!({ "app": {
        "native": {
            "ios": [{ "bundleId": "com.example.app", "teamId": "ABCDE12345" }],
            "android": [{ "package": "com.example.app", "sha256": [SHA] }],
            "allowDevelopmentBuilds": true
        },
        "origins": ["https://example.com"],
        "features": { "telemetry": true }
    }}));
    let changes = apply(&ctx(&server), &desired, false).await.unwrap();
    assert_eq!(changes.len(), 5, "{changes:?}");
    let sent = patches(&server.received_requests().await.unwrap());
    let native = sent
        .iter()
        .find(|b| b.get("native_attestation").is_some())
        .unwrap();
    assert_eq!(
        native["native_attestation"]["ios_apps"][0]["app_id_prefix"],
        "ABCDE12345"
    );
    assert_eq!(
        native["native_attestation"]["android_apps"][0]["signing_cert_sha256"][0],
        SHA
    );
    assert_eq!(native["native_attestation"]["allow_development"], true);
    assert_eq!(native["android_package_names"], json!(["com.example.app"]));
    assert!(sent
        .iter()
        .any(|b| b["origins"] == json!(["https://example.com"])));
    assert!(sent.iter().any(|b| b["mesh_telemetry"] == true));
}

#[tokio::test]
async fn a_second_run_changes_nothing_and_sends_nothing() {
    let native = json!({ "ios_apps": [{ "bundle_id": "com.example.app", "app_id_prefix": "ABCDE12345" }],
        "android_apps": [], "allow_development": false });
    let server = server_with(app_json(native, json!(["https://example.com"]))).await;
    let desired = desired_from_config(&json!({ "app": {
        "native": { "ios": [{ "bundleId": "com.example.app", "teamId": "abcde12345" }] },
        "origins": ["example.com/"],
        "features": { "telemetry": false }
    }}));
    let c = ctx(&server);
    assert!(apply(&c, &desired, false).await.unwrap().is_empty());
    let (changed, _) = c
        .edit_native(|n| Ok(n.add_ios("com.example.app", "ABCDE12345")))
        .await
        .unwrap();
    assert!(!changed);
    assert!(patches(&server.received_requests().await.unwrap()).is_empty());
}

#[tokio::test]
async fn api_errors_keep_the_servers_message_and_next_steps() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path(format!("/api/v1/developers/applications/{APP}")))
        .respond_with(ResponseTemplate::new(401).set_body_json(json!({
            "message": "App app_e2e123 has no registered native app.",
            "error": "app_credentials_required",
            "data": { "next_steps": ["offline apps native add ios --bundle-id <bundle id> --team-id <Apple Team ID>"] }
        })))
        .mount(&server)
        .await;
    let err = ctx(&server).application().await.unwrap_err().to_string();
    assert!(err.contains("(app_credentials_required)"), "{err}");
    assert!(err.contains("has no registered native app"), "{err}");
    assert!(
        err.contains("next:\n  offline apps native add ios"),
        "{err}"
    );
}