Codex Handbook
core-plugins/src/remote/share/tests.rs 721 lines
use super::*;use codex_app_server_protocol::PluginAuthPolicy;use codex_app_server_protocol::PluginInstallPolicy;use codex_app_server_protocol::PluginInterface;use codex_login::CodexAuth;use codex_utils_absolute_path::AbsolutePathBuf;use pretty_assertions::assert_eq;use serde_json::json;use std::collections::BTreeMap;use std::fs;use std::io::Read;use std::path::Path;use std::path::PathBuf;use tempfile::TempDir;use wiremock::Mock;use wiremock::MockServer;use wiremock::ResponseTemplate;use wiremock::matchers::body_json;use wiremock::matchers::header;use wiremock::matchers::method;use wiremock::matchers::path;use wiremock::matchers::query_param;use wiremock::matchers::query_param_is_missing;fn test_config(server: &MockServer) -> RemotePluginServiceConfig {    RemotePluginServiceConfig {        chatgpt_base_url: format!("{}/backend-api", server.uri()),    }}fn test_auth() -> CodexAuth {    CodexAuth::create_dummy_chatgpt_auth_for_testing()}fn write_file(path: &Path, contents: &str) {    fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();    fs::write(path, contents).unwrap();}fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf {    let plugin_path = root.join(plugin_name);    write_file(        &plugin_path.join(".codex-plugin/plugin.json"),        &format!(r#"{{"name":"{plugin_name}"}}"#),    );    write_file(        &plugin_path.join("skills/example/SKILL.md"),        "# Example\n\nA test skill.\n",    );    plugin_path}fn write_plugin_share_local_path_mapping(    codex_home: &Path,    remote_plugin_id: &str,    plugin_path: &AbsolutePathBuf,) {    write_file(        &codex_home.join(".tmp/plugin-share-local-paths-v1.json"),        &format!(            "{}\n",            serde_json::to_string_pretty(&json!({                "localPluginPathsByRemotePluginId": {                    remote_plugin_id: plugin_path,                },            }))            .unwrap()        ),    );}fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {    let decoder = flate2::read::GzDecoder::new(archive_bytes);    let mut archive = tar::Archive::new(decoder);    archive        .entries()        .unwrap()        .filter_map(|entry| {            let mut entry = entry.unwrap();            if !entry.header().entry_type().is_file() {                return None;            }            let path = entry.path().unwrap().to_string_lossy().into_owned();            let mut contents = Vec::new();            entry.read_to_end(&mut contents).unwrap();            Some((path, contents))        })        .collect()}fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {    json!({        "id": plugin_id,        "name": "demo-plugin",        "scope": "WORKSPACE",        "discoverability": "PRIVATE",        "installation_policy": "AVAILABLE",        "authentication_policy": "ON_USE",        "release": {            "version": "0.1.0",            "display_name": "Demo Plugin",            "description": "Demo plugin description",            "interface": {                "short_description": "A demo plugin",                "capabilities": ["Read", "Write"]            },            "skills": []        }    })}fn remote_plugin_json_with_share_url_and_principals(    plugin_id: &str,    share_url: Option<&str>,    share_principals: serde_json::Value,) -> serde_json::Value {    let mut plugin = remote_plugin_json(plugin_id);    let serde_json::Value::Object(fields) = &mut plugin else {        unreachable!("plugin json should be an object");    };    fields.insert("discoverability".to_string(), json!("PRIVATE"));    fields.insert("share_url".to_string(), json!(share_url));    fields.insert("share_principals".to_string(), share_principals);    plugin}fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {    let mut plugin = remote_plugin_json(plugin_id);    let serde_json::Value::Object(fields) = &mut plugin else {        unreachable!("plugin json should be an object");    };    fields.insert("enabled".to_string(), json!(true));    fields.insert("disabled_skill_names".to_string(), json!([]));    plugin}fn empty_pagination_json() -> serde_json::Value {    json!({        "next_page_token": null    })}fn expected_plugin_interface() -> PluginInterface {    PluginInterface {        display_name: Some("Demo Plugin".to_string()),        short_description: Some("A demo plugin".to_string()),        long_description: None,        developer_name: None,        category: None,        capabilities: vec!["Read".to_string(), "Write".to_string()],        website_url: None,        privacy_policy_url: None,        terms_of_service_url: None,        default_prompt: None,        brand_color: None,        composer_icon: None,        composer_icon_url: None,        logo: None,        logo_url: None,        screenshots: Vec::new(),        screenshot_urls: Vec::new(),    }}#[tokio::test]async fn save_remote_plugin_share_creates_workspace_plugin() {    let codex_home = TempDir::new().unwrap();    let temp_dir = TempDir::new().unwrap();    let plugin_path =        AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();    let archive_size = archive_plugin_for_upload(plugin_path.as_path())        .unwrap()        .len();    let server = MockServer::start().await;    let config = test_config(&server);    let auth = test_auth();    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace/upload-url"))        .and(header("authorization", "Bearer Access Token"))        .and(header("chatgpt-account-id", "account_id"))        .and(body_json(json!({            "filename": "demo-plugin.tar.gz",            "mime_type": "application/gzip",            "size_bytes": archive_size,        })))        .respond_with(ResponseTemplate::new(201).set_body_json(json!({            "file_id": "file_123",            "upload_url": format!("{}/upload/file_123", server.uri()),            "etag": "\"upload_etag_123\"",        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("PUT"))        .and(path("/upload/file_123"))        .and(header("x-ms-blob-type", "BlockBlob"))        .and(header("content-type", "application/gzip"))        .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\""))        .expect(1)        .mount(&server)        .await;    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace"))        .and(header("authorization", "Bearer Access Token"))        .and(header("chatgpt-account-id", "account_id"))        .and(body_json(json!({            "file_id": "file_123",            "etag": "\"upload_etag_123\"",            "discoverability": "UNLISTED",            "share_targets": [                {                    "principal_type": "user",                    "principal_id": "user-1",                    "role": "reader",                },                {                    "principal_type": "workspace",                    "principal_id": "account_id",                    "role": "reader",                },            ],        })))        .respond_with(ResponseTemplate::new(201).set_body_json(json!({            "plugin_id": "plugins_123",            "share_url": "https://chatgpt.example/plugins/share/share-key-1",        })))        .expect(1)        .mount(&server)        .await;    let result = save_remote_plugin_share(        &config,        Some(&auth),        codex_home.path(),        &plugin_path,        /*remote_plugin_id*/ None,        RemotePluginShareAccessPolicy {            discoverability: Some(RemotePluginShareDiscoverability::Unlisted),            share_targets: Some(vec![RemotePluginShareTarget {                principal_type: RemotePluginSharePrincipalType::User,                principal_id: "user-1".to_string(),                role: RemotePluginShareTargetRole::Reader,            }]),        },    )    .await    .unwrap();    assert_eq!(        result,        RemotePluginShareSaveResult {            remote_plugin_id: "plugins_123".to_string(),            share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),        }    );    assert_eq!(        local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),        BTreeMap::from([("plugins_123".to_string(), plugin_path)])    );    let requests = server.received_requests().await.unwrap_or_default();    let upload_request = requests        .iter()        .find(|request| request.method == "PUT" && request.url.path() == "/upload/file_123")        .unwrap();    let archive_files = archive_file_entries(&upload_request.body);    assert_eq!(        archive_files            .get(".codex-plugin/plugin.json")            .map(Vec::as_slice),        Some(br#"{"name":"demo-plugin"}"#.as_slice())    );    assert_eq!(        archive_files            .get("skills/example/SKILL.md")            .map(Vec::as_slice),        Some(b"# Example\n\nA test skill.\n".as_slice())    );}#[test]fn archive_plugin_for_upload_rejects_archives_over_limit() {    let temp_dir = TempDir::new().unwrap();    let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");    write_file(        &plugin_path.join("large.txt"),        &"0123456789abcdef".repeat(1024),    );    let err = archive_plugin_for_upload_with_limit(&plugin_path, /*max_bytes*/ 16)        .expect_err("oversized plugin archive should fail");    assert!(matches!(        err,        RemotePluginCatalogError::ArchiveTooLarge { .. }    ));}#[test]fn archive_plugin_for_upload_places_manifest_at_archive_root() {    let temp_dir = TempDir::new().unwrap();    let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");    let archive_bytes = archive_plugin_for_upload(&plugin_path).unwrap();    let archive_files = archive_file_entries(&archive_bytes);    assert_eq!(        archive_files.keys().cloned().collect::<Vec<_>>(),        vec![            ".codex-plugin/plugin.json".to_string(),            "skills/example/SKILL.md".to_string()        ]    );    assert_eq!(        archive_files            .get(".codex-plugin/plugin.json")            .map(Vec::as_slice),        Some(br#"{"name":"demo-plugin"}"#.as_slice())    );    assert_eq!(        archive_files            .get("skills/example/SKILL.md")            .map(Vec::as_slice),        Some(b"# Example\n\nA test skill.\n".as_slice())    );}#[test]fn archive_plugin_for_upload_round_trips_through_plugin_bundle_archive_with_long_paths() {    let temp_dir = TempDir::new().unwrap();    let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");    let long_skill_path = Path::new("skills")        .join(["segment"; 40].join("/"))        .join("SKILL.md");    write_file(&plugin_path.join(&long_skill_path), "# Long path skill\n");    let archive_bytes = archive_plugin_for_upload(&plugin_path).unwrap();    let destination = TempDir::new().unwrap();    crate::plugin_bundle_archive::unpack_plugin_bundle_tar_gz(        &archive_bytes,        destination.path(),        /*max_total_bytes*/ 1024 * 1024,    )    .expect("extract shared plugin archive");    assert_eq!(        fs::read_to_string(destination.path().join(".codex-plugin/plugin.json")).unwrap(),        r#"{"name":"demo-plugin"}"#    );    assert_eq!(        fs::read_to_string(destination.path().join(long_skill_path)).unwrap(),        "# Long path skill\n"    );}#[tokio::test]async fn save_remote_plugin_share_updates_existing_workspace_plugin() {    let codex_home = TempDir::new().unwrap();    let temp_dir = TempDir::new().unwrap();    let plugin_path =        AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();    let archive_size = archive_plugin_for_upload(plugin_path.as_path())        .unwrap()        .len();    let server = MockServer::start().await;    let config = test_config(&server);    let auth = test_auth();    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace/upload-url"))        .and(body_json(json!({            "filename": "demo-plugin.tar.gz",            "mime_type": "application/gzip",            "size_bytes": archive_size,            "plugin_id": "plugins_123",        })))        .respond_with(ResponseTemplate::new(201).set_body_json(json!({            "file_id": "file_456",            "upload_url": format!("{}/upload/file_456", server.uri()),            "etag": "\"upload_etag_456\"",        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("PUT"))        .and(path("/upload/file_456"))        .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_456\""))        .expect(1)        .mount(&server)        .await;    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace/plugins_123"))        .and(body_json(json!({            "file_id": "file_456",            "etag": "\"upload_etag_456\"",        })))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugin_id": "plugins_123",        })))        .expect(1)        .mount(&server)        .await;    let result = save_remote_plugin_share(        &config,        Some(&auth),        codex_home.path(),        &plugin_path,        Some("plugins_123"),        RemotePluginShareAccessPolicy::default(),    )    .await    .unwrap();    assert_eq!(        result,        RemotePluginShareSaveResult {            remote_plugin_id: "plugins_123".to_string(),            share_url: None,        }    );}#[tokio::test]async fn update_remote_plugin_share_targets_updates_targets() {    let server = MockServer::start().await;    let config = test_config(&server);    let auth = test_auth();    Mock::given(method("PUT"))        .and(path("/backend-api/ps/plugins/plugins_123/shares"))        .and(header("authorization", "Bearer Access Token"))        .and(header("chatgpt-account-id", "account_id"))        .and(body_json(json!({            "discoverability": "UNLISTED",            "targets": [                {                    "principal_type": "user",                    "principal_id": "user-1",                    "role": "editor",                },                {                    "principal_type": "group",                    "principal_id": "group-1",                    "role": "reader",                },                {                    "principal_type": "workspace",                    "principal_id": "account_id",                    "role": "reader",                },            ],        })))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "principals": [                {                    "principal_type": "user",                    "principal_id": "user-1",                    "role": "editor",                    "name": "Gavin",                },                {                    "principal_type": "group",                    "principal_id": "group-1",                    "role": "reader",                    "name": "Engineering",                },            ],            "discoverability": "UNLISTED",        })))        .expect(1)        .mount(&server)        .await;    let result = update_remote_plugin_share_targets(        &config,        Some(&auth),        "plugins_123",        vec![            RemotePluginShareTarget {                principal_type: RemotePluginSharePrincipalType::User,                principal_id: "user-1".to_string(),                role: RemotePluginShareTargetRole::Editor,            },            RemotePluginShareTarget {                principal_type: RemotePluginSharePrincipalType::Group,                principal_id: "group-1".to_string(),                role: RemotePluginShareTargetRole::Reader,            },        ],        RemotePluginShareUpdateDiscoverability::Unlisted,    )    .await    .unwrap();    assert_eq!(        result,        RemotePluginShareUpdateTargetsResult {            principals: vec![                RemotePluginSharePrincipal {                    principal_type: RemotePluginSharePrincipalType::User,                    principal_id: "user-1".to_string(),                    role: RemotePluginSharePrincipalRole::Editor,                    name: "Gavin".to_string(),                },                RemotePluginSharePrincipal {                    principal_type: RemotePluginSharePrincipalType::Group,                    principal_id: "group-1".to_string(),                    role: RemotePluginSharePrincipalRole::Reader,                    name: "Engineering".to_string(),                },            ],            discoverability: RemotePluginShareDiscoverability::Unlisted,        }    );}#[tokio::test]async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {    let codex_home = TempDir::new().unwrap();    let local_plugin_path =        AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();    write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);    let server = MockServer::start().await;    let config = test_config(&server);    let auth = test_auth();    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/workspace/created"))        .and(header("authorization", "Bearer Access Token"))        .and(header("chatgpt-account-id", "account_id"))        .and(query_param(            "limit",            REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(),        ))        .and(query_param_is_missing("pageToken"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [remote_plugin_json_with_share_url_and_principals(                "plugins_123",                Some("https://chatgpt.example/plugins/share/share-key-1"),                json!([                    {                        "principal_type": "user",                        "principal_id": "user-owner",                        "role": "owner",                        "name": "Owner",                    },                    {                        "principal_type": "user",                        "principal_id": "user-reader",                        "role": "reader",                        "name": "Reader",                    },                ]),            )],            "pagination": {                "next_page_token": "page-2"            },        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/workspace/created"))        .and(header("authorization", "Bearer Access Token"))        .and(header("chatgpt-account-id", "account_id"))        .and(query_param(            "limit",            REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(),        ))        .and(query_param("pageToken", "page-2"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [remote_plugin_json_with_share_url_and_principals(                "plugins_456",                /*share_url*/ None,                json!([                    {                        "principal_type": "user",                        "principal_id": "user-owner",                        "role": "owner",                        "name": "Owner",                    },                    {                        "principal_type": "user",                        "principal_id": "user-editor",                        "role": "editor",                        "name": "Editor",                    },                ]),            )],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/installed"))        .and(query_param("scope", "WORKSPACE"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [installed_remote_plugin_json("plugins_456")],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    let result = list_remote_plugin_shares(&config, Some(&auth), codex_home.path())        .await        .unwrap();    assert_eq!(        result,        vec![            RemotePluginShareSummary {                summary: RemotePluginSummary {                    id: "demo-plugin@workspace-shared-with-me".to_string(),                    remote_plugin_id: "plugins_123".to_string(),                    name: "demo-plugin".to_string(),                    share_context: Some(RemotePluginShareContext {                        remote_plugin_id: "plugins_123".to_string(),                        remote_version: Some("0.1.0".to_string()),                        discoverability: RemotePluginShareDiscoverability::Private,                        share_url: Some(                            "https://chatgpt.example/plugins/share/share-key-1".to_string(),                        ),                        creator_account_user_id: None,                        creator_name: None,                        share_principals: Some(vec![                            RemotePluginSharePrincipal {                                principal_type: RemotePluginSharePrincipalType::User,                                principal_id: "user-owner".to_string(),                                role: RemotePluginSharePrincipalRole::Owner,                                name: "Owner".to_string(),                            },                            RemotePluginSharePrincipal {                                principal_type: RemotePluginSharePrincipalType::User,                                principal_id: "user-reader".to_string(),                                role: RemotePluginSharePrincipalRole::Reader,                                name: "Reader".to_string(),                            },                        ]),                    }),                    installed: false,                    enabled: false,                    install_policy: PluginInstallPolicy::Available,                    auth_policy: PluginAuthPolicy::OnUse,                    availability: PluginAvailability::Available,                    interface: Some(expected_plugin_interface()),                    keywords: Vec::new(),                },                local_plugin_path: Some(local_plugin_path),            },            RemotePluginShareSummary {                summary: RemotePluginSummary {                    id: "demo-plugin@workspace-shared-with-me".to_string(),                    remote_plugin_id: "plugins_456".to_string(),                    name: "demo-plugin".to_string(),                    share_context: Some(RemotePluginShareContext {                        remote_plugin_id: "plugins_456".to_string(),                        remote_version: Some("0.1.0".to_string()),                        discoverability: RemotePluginShareDiscoverability::Private,                        share_url: None,                        creator_account_user_id: None,                        creator_name: None,                        share_principals: Some(vec![                            RemotePluginSharePrincipal {                                principal_type: RemotePluginSharePrincipalType::User,                                principal_id: "user-owner".to_string(),                                role: RemotePluginSharePrincipalRole::Owner,                                name: "Owner".to_string(),                            },                            RemotePluginSharePrincipal {                                principal_type: RemotePluginSharePrincipalType::User,                                principal_id: "user-editor".to_string(),                                role: RemotePluginSharePrincipalRole::Editor,                                name: "Editor".to_string(),                            },                        ]),                    }),                    installed: true,                    enabled: true,                    install_policy: PluginInstallPolicy::Available,                    auth_policy: PluginAuthPolicy::OnUse,                    availability: PluginAvailability::Available,                    interface: Some(expected_plugin_interface()),                    keywords: Vec::new(),                },                local_plugin_path: None,            }        ]    );}#[tokio::test]async fn delete_remote_plugin_share_deletes_workspace_plugin() {    let codex_home = TempDir::new().unwrap();    let local_plugin_path =        AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();    write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);    let server = MockServer::start().await;    let config = test_config(&server);    let auth = test_auth();    Mock::given(method("DELETE"))        .and(path("/backend-api/public/plugins/workspace/plugins_123"))        .and(header("authorization", "Bearer Access Token"))        .and(header("chatgpt-account-id", "account_id"))        .respond_with(ResponseTemplate::new(204))        .expect(1)        .mount(&server)        .await;    delete_remote_plugin_share(&config, Some(&auth), codex_home.path(), "plugins_123")        .await        .unwrap();    assert_eq!(        local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),        BTreeMap::new()    );}