mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
1085 lines
37 KiB
Rust
1085 lines
37 KiB
Rust
//! 插件管理 HTTP 测试入口,以及制品、实例与权限合同测试
|
|
|
|
mod usage_data;
|
|
|
|
use async_trait::async_trait;
|
|
use gateway_admin::model::plugins::distribution::{
|
|
DownloadedPlugin, GithubReleaseQuery, PluginRelease, RemotePluginLocation, SourceCredential,
|
|
SourceCredentialInfo,
|
|
};
|
|
use gateway_admin::model::plugins::distribution::{PluginSourceBinding, PluginUpdateSource};
|
|
use gateway_admin::{
|
|
model::{
|
|
AdminError, MutationContext, Revision,
|
|
plugins::{
|
|
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactIcon,
|
|
PluginArtifactIconResource, PluginArtifactMetadata, PluginArtifactMutation,
|
|
PluginIconTheme, PluginSource,
|
|
state::{
|
|
ApplyPluginStateMigration, DeletePluginState, PluginStateConfiguration,
|
|
PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest,
|
|
PluginStateRecord, PluginStateTransition, PluginStateWrite, PutPluginState,
|
|
},
|
|
},
|
|
},
|
|
ports::{
|
|
plugins::{
|
|
PluginPackageInspector, PluginStateStore, PluginStateStoreError,
|
|
PluginStateStoreErrorKind, PluginStateStoreResult, PluginStore,
|
|
},
|
|
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
|
|
},
|
|
};
|
|
use std::{collections::BTreeMap, sync::Arc};
|
|
|
|
mod management;
|
|
|
|
#[test]
|
|
fn artifact_metadata_wire_preserves_identity_icons_and_contributions() {
|
|
use gateway_admin::model::plugins::{
|
|
PluginArtifactIcon, PluginArtifactIconVariants, PluginArtifactMetadata, PluginContribution,
|
|
};
|
|
|
|
let metadata = PluginArtifactMetadata {
|
|
plugin_id: "test.example".into(),
|
|
version: "1.0.0".into(),
|
|
name: "example".into(),
|
|
display_name: "Example".into(),
|
|
publisher: "test".into(),
|
|
author: None,
|
|
description: "fixture".into(),
|
|
license: "MIT".into(),
|
|
sha256: "a".repeat(64),
|
|
platforms: vec!["linux-x86_64".into()],
|
|
icon: Some(PluginArtifactIcon::Themed(PluginArtifactIconVariants {
|
|
light: "assets/icon-light.png".into(),
|
|
dark: "assets/icon-dark.webp".into(),
|
|
})),
|
|
contributes: BTreeMap::from([(
|
|
"middleware".into(),
|
|
PluginContribution {
|
|
id: "test.example.middleware".into(),
|
|
version: 1,
|
|
stages: vec!["attempt".into()],
|
|
input_formats: vec!["openai".into()],
|
|
output_formats: vec!["openai".into()],
|
|
},
|
|
)]),
|
|
|
|
configuration_schema: serde_json::json!({}),
|
|
secret_fields: vec![],
|
|
state_namespaces: vec![],
|
|
};
|
|
let value = serde_json::to_value(metadata).expect("artifact metadata JSON");
|
|
assert_eq!(value["displayName"], "Example");
|
|
assert_eq!(value["publisher"], "test");
|
|
assert!(value["author"].is_null());
|
|
assert_eq!(value["icon"]["light"], "assets/icon-light.png");
|
|
assert_eq!(value["icon"]["dark"], "assets/icon-dark.webp");
|
|
assert_eq!(
|
|
value["contributes"]["middleware"]["id"],
|
|
"test.example.middleware"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_binding_requires_a_contribution_and_rejects_unknown_fields() {
|
|
use gateway_admin::model::plugins::instances::PluginCapabilityBinding;
|
|
|
|
let input = serde_json::json!({
|
|
"contribution": "test.example.usage",
|
|
"stage": "observation",
|
|
"order": 0,
|
|
"failurePolicy": "observe"
|
|
});
|
|
let binding = serde_json::from_value::<PluginCapabilityBinding>(input.clone())
|
|
.expect("contribution binding");
|
|
assert_eq!(binding.contribution, "test.example.usage");
|
|
|
|
let mut missing = input.clone();
|
|
missing.as_object_mut().unwrap().remove("contribution");
|
|
assert!(serde_json::from_value::<PluginCapabilityBinding>(missing).is_err());
|
|
|
|
let mut unknown = input;
|
|
unknown["unknownField"] = serde_json::json!(true);
|
|
assert!(serde_json::from_value::<PluginCapabilityBinding>(unknown).is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn artifact_icon_is_admin_only_digest_bound_and_hardened() {
|
|
use axum::{
|
|
body::{Body, to_bytes},
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
let digest = "f".repeat(64);
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!(
|
|
"/api/admin/plugins/artifacts/{digest}/icon?theme=light"
|
|
))
|
|
.header("cookie", "cpr_session=valid-session")
|
|
.header("x-request-id", "req_plugin_icon")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let headers = response.headers().clone();
|
|
let body = to_bytes(response.into_body(), 1024).await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
|
assert_eq!(headers["content-type"], "image/png");
|
|
assert_eq!(headers["x-content-type-options"], "nosniff");
|
|
assert_eq!(
|
|
headers["content-security-policy"],
|
|
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"
|
|
);
|
|
assert_eq!(
|
|
headers["cache-control"],
|
|
"private, max-age=31536000, immutable"
|
|
);
|
|
assert_eq!(body.as_ref(), b"icon fixture");
|
|
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!(
|
|
"/api/admin/plugins/artifacts/{digest}/icon?theme=dark"
|
|
))
|
|
.header("cookie", "cpr_session=valid-session")
|
|
.header("x-request-id", "req_plugin_svg_icon")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(response.headers()["content-type"], "image/svg+xml");
|
|
assert_eq!(
|
|
response.headers()["content-security-policy"],
|
|
headers["content-security-policy"]
|
|
);
|
|
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
|
|
assert_eq!(
|
|
response.headers()["cache-control"],
|
|
headers["cache-control"]
|
|
);
|
|
let body = to_bytes(response.into_body(), 1024).await.unwrap();
|
|
assert_eq!(
|
|
body.as_ref(),
|
|
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"/>"#
|
|
);
|
|
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!(
|
|
"/api/admin/plugins/artifacts/{digest}/icon?theme=light"
|
|
))
|
|
.header("x-request-id", "req_plugin_icon_unauthorized")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!(
|
|
"/api/admin/plugins/artifacts/{}/icon?theme=light",
|
|
"e".repeat(64)
|
|
))
|
|
.header("cookie", "cpr_session=valid-session")
|
|
.header("x-request-id", "req_plugin_icon_missing")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn distribution_rate_limit_keeps_its_safe_owner_message() {
|
|
use axum::{
|
|
body::{Body, to_bytes},
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
let direct = fixture
|
|
.services
|
|
.plugins()
|
|
.query_release(
|
|
GithubReleaseQuery {
|
|
repository: "rate-limit/fixture".to_owned(),
|
|
tag: None,
|
|
allow_prerelease: false,
|
|
},
|
|
&[],
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("fixture release query should be rate limited");
|
|
assert_eq!(
|
|
direct.kind(),
|
|
gateway_admin::model::AdminErrorKind::RateLimited
|
|
);
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/admin/plugins/releases/query")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", "cpr_session=valid-session")
|
|
.header("x-request-id", "req_plugin_distribution_rate_limit")
|
|
.body(Body::from(
|
|
serde_json::json!({
|
|
"query": {
|
|
"repository": "rate-limit/fixture",
|
|
"tag": null,
|
|
"allowPrerelease": false
|
|
},
|
|
"credentialIds": []
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
assert!(response.headers().get("retry-after").is_none());
|
|
let body = to_bytes(response.into_body(), 64 * 1024)
|
|
.await
|
|
.expect("rate limited response body");
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"{}",
|
|
String::from_utf8_lossy(&body)
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_slice::<serde_json::Value>(&body).expect("rate limited response JSON"),
|
|
serde_json::json!({
|
|
"code": 42902,
|
|
"message": "插件来源限流,请稍后重试",
|
|
"data": null
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn instance_list_serializes_safe_runtime_diagnostics() {
|
|
use axum::{
|
|
body::{Body, to_bytes},
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
let direct = fixture
|
|
.services
|
|
.plugins()
|
|
.instances()
|
|
.await
|
|
.expect("direct plugin instance projection");
|
|
assert_eq!(direct.len(), 1);
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/admin/plugins/instances")
|
|
.header("cookie", "cpr_session=valid-session")
|
|
.header("x-request-id", "req_plugin_instance_diagnostics")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), 64 * 1024)
|
|
.await
|
|
.expect("response body");
|
|
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
|
let value: serde_json::Value = serde_json::from_slice(&body).expect("response json");
|
|
let instance = &value["data"][0];
|
|
assert_eq!(instance["running"], false);
|
|
assert_eq!(instance["runtime"]["status"], "preparation_failed");
|
|
assert_eq!(instance["runtime"]["actualRevision"], 6);
|
|
assert_eq!(
|
|
instance["runtime"]["load"],
|
|
serde_json::json!({
|
|
"activeCalls": 3,
|
|
"executingCalls": 1,
|
|
"queuedCalls": 1,
|
|
"resumingCalls": 1,
|
|
"pendingCallbacks": 2,
|
|
"activeStreams": 0,
|
|
})
|
|
);
|
|
assert_eq!(
|
|
instance["runtime"]["drainingRevisions"],
|
|
serde_json::json!([5])
|
|
);
|
|
assert_eq!(instance["runtime"]["failure"]["code"], "unavailable");
|
|
assert_eq!(
|
|
instance["runtime"]["failure"]["message"],
|
|
"插件候选准备失败"
|
|
);
|
|
assert!(instance["runtime"].get("configuration").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_upload_endpoints_require_admin_and_bound_raw_bodies() {
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
for path in ["artifacts/upload", "artifacts/upload/verify"] {
|
|
for authenticated in [false, true] {
|
|
let mut request = Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/api/admin/plugins/{path}"))
|
|
.header("x-request-id", "req_plugin_upload_limit")
|
|
.header("content-type", "application/octet-stream");
|
|
if authenticated {
|
|
request = request.header("cookie", "cpr_session=valid-session");
|
|
}
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
request
|
|
.body(Body::from(vec![0_u8; 32 * 1024 * 1024 + 1]))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
if authenticated {
|
|
StatusCode::PAYLOAD_TOO_LARGE
|
|
} else {
|
|
StatusCode::UNAUTHORIZED
|
|
},
|
|
"{path}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn artifact_acceptance_returns_installed_artifact_and_configuration_status() {
|
|
use axum::{
|
|
body::{Body, to_bytes},
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/admin/plugins/artifacts/accept")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", "cpr_session=valid-session")
|
|
.header("x-request-id", "req_plugin_accept")
|
|
.body(Body::from(format!(r#"{{"sha256":"{}"}}"#, "a".repeat(64))))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::CREATED,
|
|
"{}",
|
|
String::from_utf8_lossy(&body)
|
|
);
|
|
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
let result = &value["data"];
|
|
assert_eq!(result["configRevision"], 7);
|
|
assert_eq!(result["defaultInstanceId"], serde_json::Value::Null);
|
|
assert_eq!(result["configurationRequired"], false);
|
|
assert_eq!(result["artifact"]["metadata"]["sha256"], "a".repeat(64));
|
|
assert!(result["artifact"]["acceptedAt"].is_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_mutations_reject_unknown_fields() {
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
let instance = serde_json::json!({
|
|
"name":"Example",
|
|
"artifactSha256":"a".repeat(64),
|
|
"enabled":false,
|
|
"configuration":{},
|
|
"bindings":[]
|
|
});
|
|
serde_json::from_value::<gateway_admin::model::plugins::instances::ConfigurePluginInstance>(
|
|
instance.clone(),
|
|
)
|
|
.expect("valid instance request");
|
|
for (path, mut body) in [
|
|
("/api/admin/plugins/instances", instance),
|
|
(
|
|
"/api/admin/plugins/artifacts/accept",
|
|
serde_json::json!({"sha256":"a".repeat(64)}),
|
|
),
|
|
] {
|
|
body["unknownField"] = serde_json::json!(true);
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(path)
|
|
.header("content-type", "application/json")
|
|
.header("cookie", "cpr_session=valid-session")
|
|
.header("x-request-id", "req_plugin_strict_input")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"{path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_mutations_require_admin_and_validate_json_on_static_post_routes() {
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
for path in [
|
|
"artifacts/accept",
|
|
"artifacts/delete",
|
|
"artifacts/install",
|
|
"releases/query",
|
|
"updates/check",
|
|
"artifacts/verify",
|
|
"source-credentials",
|
|
"source-credentials/delete",
|
|
"update-sources",
|
|
"instances",
|
|
"instances/update",
|
|
"instances/rollback",
|
|
"instances/switch-version",
|
|
"instances/disable",
|
|
"instances/delete",
|
|
] {
|
|
for authenticated in [false, true] {
|
|
let mut request = Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/api/admin/plugins/{path}"))
|
|
.header("content-type", "application/json")
|
|
.header("x-request-id", "req_plugin_routes");
|
|
if authenticated {
|
|
request = request.header("cookie", "cpr_session=valid-session");
|
|
}
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(request.body(Body::from("{}")).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
if authenticated {
|
|
// 字段缺失沿用 AdminJson 的结构校验合同
|
|
StatusCode::UNPROCESSABLE_ENTITY
|
|
} else {
|
|
StatusCode::UNAUTHORIZED
|
|
},
|
|
"{path}"
|
|
);
|
|
assert_eq!(response.headers()["cache-control"], "no-store");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(super) struct TestPluginPorts {
|
|
instances:
|
|
std::sync::Mutex<Option<Vec<gateway_admin::model::plugins::instances::PluginInstance>>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts {
|
|
async fn validate_target(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
|
) -> Result<(), AdminError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_callback(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
|
_: gateway_admin::model::plugins::management::StartPluginManagementCallback,
|
|
_: &gateway_admin::model::auth::AdminRequestContext,
|
|
) -> Result<gateway_admin::model::plugins::management::PluginManagementCallbackTicket, AdminError>
|
|
{
|
|
Err(AdminError::not_found("unused plugin fixture"))
|
|
}
|
|
async fn callback(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
|
_: &str,
|
|
request: gateway_admin::model::plugins::management::PluginManagementRequest,
|
|
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
|
|
{
|
|
let mut response = management::resource_fixture("ui/index.html");
|
|
response.headers = request.headers;
|
|
Ok(response)
|
|
}
|
|
async fn views(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
) -> Result<Vec<gateway_admin::model::plugins::management::PluginManagementView>, AdminError>
|
|
{
|
|
Ok(Vec::new())
|
|
}
|
|
async fn resource(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
|
path: &str,
|
|
public: bool,
|
|
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
|
|
{
|
|
if public && path != "public/login.css" {
|
|
return Err(AdminError::not_found("resource is not public"));
|
|
}
|
|
Ok(management::resource_fixture(path))
|
|
}
|
|
async fn handle(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
|
request: gateway_admin::model::plugins::management::PluginManagementRequest,
|
|
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
|
|
{
|
|
let mut response = management::resource_fixture("api/echo");
|
|
response.headers = request.headers;
|
|
Ok(response)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts {
|
|
async fn activate_state(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
_: &gateway_admin::model::plugins::instances::PluginInstance,
|
|
) -> Result<(), AdminError> {
|
|
Ok(())
|
|
}
|
|
async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) {
|
|
panic!("unexpected instance drain")
|
|
}
|
|
async fn migrate_state(
|
|
&self,
|
|
_: &gateway_core::routing::extensions::ExtensionSetReference,
|
|
_: gateway_admin::model::plugins::state::PluginStateTransition,
|
|
) -> Result<(), AdminError> {
|
|
panic!("unexpected state migration")
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts {
|
|
async fn configuration_ready(
|
|
&self,
|
|
_: gateway_admin::model::plugins::instances::PluginInstance,
|
|
_: &gateway_admin::model::plugins::PluginArtifactMetadata,
|
|
) -> Result<bool, AdminError> {
|
|
Ok(true)
|
|
}
|
|
|
|
async fn validate(
|
|
&self,
|
|
_: gateway_admin::model::plugins::instances::PluginInstance,
|
|
) -> Result<gateway_admin::model::plugins::state::PluginStateConfiguration, AdminError> {
|
|
Err(AdminError::invalid("unused plugin fixture"))
|
|
}
|
|
async fn prepare(
|
|
&self,
|
|
_: gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
|
|
) -> Result<gateway_core::routing::extensions::ExtensionSetReference, AdminError> {
|
|
Err(AdminError::invalid("unused plugin fixture"))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts {
|
|
async fn runtime_diagnostics(
|
|
&self,
|
|
snapshot: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
|
|
_: Option<u64>,
|
|
_: Option<&gateway_core::routing::extensions::ExtensionSetReference>,
|
|
) -> Option<BTreeMap<String, gateway_admin::model::plugins::instances::PluginInstanceRuntime>>
|
|
{
|
|
Some(
|
|
snapshot
|
|
.instances
|
|
.iter()
|
|
.map(|instance| {
|
|
(
|
|
instance.id.clone(),
|
|
gateway_admin::model::plugins::instances::PluginInstanceRuntime {
|
|
status: gateway_admin::model::plugins::instances::PluginInstanceRuntimeStatus::PreparationFailed,
|
|
actual_revision: Some(6),
|
|
actual_artifact_sha256: Some("b".repeat(64)),
|
|
failure: Some(gateway_admin::model::plugins::instances::PluginInstanceRuntimeFailure {
|
|
code: "unavailable".into(),
|
|
message: "插件候选准备失败".into(),
|
|
}),
|
|
draining_revisions: vec![5],
|
|
load: Some(gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad {
|
|
active_calls: 3,
|
|
executing_calls: 1,
|
|
queued_calls: 1,
|
|
resuming_calls: 1,
|
|
pending_callbacks: 2,
|
|
active_streams: 0,
|
|
}),
|
|
},
|
|
)
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn unavailable() -> AdminStoreError {
|
|
AdminStoreError::new(
|
|
AdminStoreErrorKind::Unavailable,
|
|
"plugin",
|
|
"unused plugin fixture",
|
|
)
|
|
}
|
|
|
|
fn icon_artifact(digest: &str) -> InspectedPluginArtifact {
|
|
InspectedPluginArtifact {
|
|
metadata: PluginArtifactMetadata {
|
|
plugin_id: "test.icon".into(),
|
|
version: "1.0.0".into(),
|
|
name: "icon".into(),
|
|
display_name: "Icon fixture".into(),
|
|
publisher: "test".into(),
|
|
author: None,
|
|
description: "fixture".into(),
|
|
license: "MIT".into(),
|
|
sha256: digest.into(),
|
|
platforms: vec!["linux-x86_64".into()],
|
|
icon: (digest.as_bytes()[0] == b'f')
|
|
.then(|| PluginArtifactIcon::Path("assets/icon.png".into())),
|
|
contributes: BTreeMap::new(),
|
|
|
|
configuration_schema: serde_json::json!({}),
|
|
secret_fields: Vec::new(),
|
|
state_namespaces: Vec::new(),
|
|
},
|
|
archive: b"icon archive fixture".as_slice().into(),
|
|
}
|
|
}
|
|
|
|
fn accepted_artifact() -> InstalledPluginArtifact {
|
|
let metadata = icon_artifact(&"a".repeat(64)).metadata;
|
|
InstalledPluginArtifact {
|
|
metadata,
|
|
source: PluginSource::Upload,
|
|
installed_at: chrono::Utc::now(),
|
|
accepted_at: Some(chrono::Utc::now()),
|
|
}
|
|
}
|
|
|
|
fn state_unavailable<T>() -> PluginStateStoreResult<T> {
|
|
Err(PluginStateStoreError::new(
|
|
PluginStateStoreErrorKind::Unavailable,
|
|
))
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PluginStateStore for TestPluginPorts {
|
|
async fn load_owner(
|
|
&self,
|
|
_: PluginStateOwnerRequest,
|
|
) -> PluginStateStoreResult<Option<PluginStateOwner>> {
|
|
state_unavailable()
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_: &PluginStateOwner,
|
|
_: &str,
|
|
_: &str,
|
|
) -> PluginStateStoreResult<Option<PluginStateRecord>> {
|
|
state_unavailable()
|
|
}
|
|
async fn put(
|
|
&self,
|
|
_: &PluginStateOwner,
|
|
_: PutPluginState,
|
|
) -> PluginStateStoreResult<PluginStateWrite> {
|
|
state_unavailable()
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
_: &PluginStateOwner,
|
|
_: DeletePluginState,
|
|
) -> PluginStateStoreResult<bool> {
|
|
state_unavailable()
|
|
}
|
|
async fn transition_required(
|
|
&self,
|
|
_: &str,
|
|
_: &PluginStateConfiguration,
|
|
) -> PluginStateStoreResult<bool> {
|
|
state_unavailable()
|
|
}
|
|
async fn begin_transition(
|
|
&self,
|
|
_: &str,
|
|
_: Revision,
|
|
_: &str,
|
|
_: PluginStateConfiguration,
|
|
) -> PluginStateStoreResult<PluginStateTransition> {
|
|
state_unavailable()
|
|
}
|
|
async fn migration_batch(
|
|
&self,
|
|
_: &str,
|
|
_: &str,
|
|
_: u32,
|
|
) -> PluginStateStoreResult<PluginStateMigrationBatch> {
|
|
state_unavailable()
|
|
}
|
|
async fn apply_migration_batch(
|
|
&self,
|
|
_: ApplyPluginStateMigration,
|
|
) -> PluginStateStoreResult<()> {
|
|
state_unavailable()
|
|
}
|
|
async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> {
|
|
state_unavailable()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PluginStore for TestPluginPorts {
|
|
async fn management_target_is_current(
|
|
&self,
|
|
target: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
|
) -> AdminStoreResult<bool> {
|
|
Ok(self
|
|
.load_instances()
|
|
.await?
|
|
.instances
|
|
.iter()
|
|
.any(|instance| {
|
|
instance.enabled
|
|
&& instance.trusted_process
|
|
&& instance.id == target.instance_id
|
|
&& instance.artifact_sha256 == target.artifact_sha256
|
|
&& instance.revision.get() == target.revision
|
|
}))
|
|
}
|
|
async fn load_instances(
|
|
&self,
|
|
) -> AdminStoreResult<gateway_admin::model::plugins::instances::PluginInstanceSnapshot> {
|
|
Ok(
|
|
gateway_admin::model::plugins::instances::PluginInstanceSnapshot {
|
|
config_revision: Revision::new(7).unwrap(),
|
|
instances: self.instances.lock().unwrap().clone().unwrap_or_else(|| {
|
|
vec![gateway_admin::model::plugins::instances::PluginInstance {
|
|
id: "plugin-instance-diagnostic".into(),
|
|
name: "Diagnostic fixture".into(),
|
|
artifact_sha256: "a".repeat(64),
|
|
enabled: true,
|
|
trusted_process: true,
|
|
configuration: serde_json::json!({}),
|
|
secrets: BTreeMap::new(),
|
|
|
|
bindings: Vec::new(),
|
|
revision: Revision::new(7).unwrap(),
|
|
}]
|
|
}),
|
|
},
|
|
)
|
|
}
|
|
async fn save_instance(
|
|
&self,
|
|
_: gateway_admin::model::plugins::instances::PluginInstance,
|
|
_: Revision,
|
|
_: &MutationContext,
|
|
) -> AdminStoreResult<gateway_admin::model::plugins::instances::PluginInstanceMutation> {
|
|
Err(unavailable())
|
|
}
|
|
async fn delete_instance(
|
|
&self,
|
|
_: &str,
|
|
_: Revision,
|
|
_: &MutationContext,
|
|
) -> AdminStoreResult<Revision> {
|
|
Err(unavailable())
|
|
}
|
|
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn change_update_source(
|
|
&self,
|
|
_: PluginSourceBinding,
|
|
_: &MutationContext,
|
|
) -> AdminStoreResult<Revision> {
|
|
Err(unavailable())
|
|
}
|
|
|
|
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
|
|
Err(unavailable())
|
|
}
|
|
async fn save_source_credential(
|
|
&self,
|
|
_: SourceCredential,
|
|
_: &MutationContext,
|
|
) -> AdminStoreResult<Revision> {
|
|
Err(unavailable())
|
|
}
|
|
async fn delete_source_credential(
|
|
&self,
|
|
_: &str,
|
|
_: &MutationContext,
|
|
) -> AdminStoreResult<Revision> {
|
|
Err(unavailable())
|
|
}
|
|
|
|
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
|
|
Ok(vec![accepted_artifact()])
|
|
}
|
|
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
|
if digest == "a".repeat(64) {
|
|
return Ok(InspectedPluginArtifact {
|
|
metadata: accepted_artifact().metadata,
|
|
archive: Arc::from([1_u8]),
|
|
});
|
|
}
|
|
if matches!(digest.as_bytes().first(), Some(b'e' | b'f')) && digest.len() == 64 {
|
|
return Ok(icon_artifact(digest));
|
|
}
|
|
Err(unavailable())
|
|
}
|
|
async fn install_artifact(
|
|
&self,
|
|
_: InspectedPluginArtifact,
|
|
_: PluginSource,
|
|
_: &MutationContext,
|
|
) -> AdminStoreResult<PluginArtifactMutation> {
|
|
Err(unavailable())
|
|
}
|
|
async fn accept_artifact(
|
|
&self,
|
|
digest: &str,
|
|
_: &MutationContext,
|
|
) -> AdminStoreResult<PluginArtifactMutation> {
|
|
if digest != "a".repeat(64) {
|
|
return Err(unavailable());
|
|
}
|
|
Ok(PluginArtifactMutation {
|
|
config_revision: Revision::new(7).unwrap(),
|
|
artifact: accepted_artifact(),
|
|
})
|
|
}
|
|
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
|
|
Err(unavailable())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PluginPackageInspector for TestPluginPorts {
|
|
async fn inspect(
|
|
&self,
|
|
_: Arc<[u8]>,
|
|
_: Option<String>,
|
|
) -> Result<InspectedPluginArtifact, AdminError> {
|
|
Ok(InspectedPluginArtifact {
|
|
metadata: accepted_artifact().metadata,
|
|
archive: Arc::from([1_u8]),
|
|
})
|
|
}
|
|
|
|
async fn icon(
|
|
&self,
|
|
_: Arc<[u8]>,
|
|
expected_sha256: String,
|
|
theme: PluginIconTheme,
|
|
) -> Result<Option<PluginArtifactIconResource>, AdminError> {
|
|
Ok(
|
|
(expected_sha256 == "f".repeat(64)).then(|| PluginArtifactIconResource {
|
|
content_type: match theme {
|
|
PluginIconTheme::Light => "image/png",
|
|
PluginIconTheme::Dark => "image/svg+xml",
|
|
}
|
|
.into(),
|
|
body: match theme {
|
|
PluginIconTheme::Light => b"icon fixture".to_vec(),
|
|
PluginIconTheme::Dark => {
|
|
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"/>"#.to_vec()
|
|
}
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl gateway_admin::ports::plugins::PluginDistribution for TestPluginPorts {
|
|
fn validate_source(&self, _: &PluginUpdateSource) -> Result<(), AdminError> {
|
|
Err(AdminError::invalid("unused plugin fixture"))
|
|
}
|
|
|
|
fn validate_credential(&self, _: &SourceCredential) -> Result<(), AdminError> {
|
|
Err(AdminError::invalid("unused plugin fixture"))
|
|
}
|
|
async fn query_release(
|
|
&self,
|
|
query: GithubReleaseQuery,
|
|
_: Vec<SourceCredential>,
|
|
_: Option<gateway_admin::model::plugins::distribution::PluginDistributionEgress>,
|
|
) -> Result<PluginRelease, AdminError> {
|
|
if query.repository == "rate-limit/fixture" {
|
|
return Err(AdminError::new(
|
|
gateway_admin::model::AdminErrorKind::RateLimited,
|
|
"插件来源限流,请稍后重试",
|
|
));
|
|
}
|
|
Err(AdminError::invalid("unused plugin fixture"))
|
|
}
|
|
async fn download(
|
|
&self,
|
|
_: RemotePluginLocation,
|
|
_: Vec<SourceCredential>,
|
|
_: Option<gateway_admin::model::plugins::distribution::PluginDistributionEgress>,
|
|
) -> Result<DownloadedPlugin, AdminError> {
|
|
Err(AdminError::invalid("unused plugin fixture"))
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn version_plan_requires_admin_and_an_explicit_target() {
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use tower::ServiceExt as _;
|
|
let fixture = super::AdminTestFixture::new().await;
|
|
fixture.auth.insert_session("valid-session");
|
|
for authenticated in [false, true] {
|
|
let mut request = Request::builder()
|
|
.uri("/api/admin/plugins/instances/version-plan")
|
|
.header("x-request-id", "version-plan");
|
|
if authenticated {
|
|
request = request.header("cookie", "cpr_session=valid-session");
|
|
}
|
|
let response = gateway_api::admin::router::<super::AdminTestState>()
|
|
.with_state(fixture.state())
|
|
.oneshot(request.body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
if authenticated {
|
|
StatusCode::BAD_REQUEST
|
|
} else {
|
|
StatusCode::UNAUTHORIZED
|
|
}
|
|
);
|
|
assert_eq!(response.headers()["cache-control"], "no-store");
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl gateway_admin::ports::plugin_resources::PluginResourceStore for TestPluginPorts {
|
|
async fn ensure_group(
|
|
&self,
|
|
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
|
_: String,
|
|
_: gateway_admin::model::account_groups::NewAccountGroup,
|
|
_: &gateway_admin::model::MutationContext,
|
|
) -> gateway_admin::ports::store::AdminStoreResult<
|
|
gateway_admin::model::plugin_resources::ResourceMutation<
|
|
gateway_admin::model::plugin_resources::ManagedResource,
|
|
>,
|
|
> {
|
|
unreachable!("resource port is not used by this fixture")
|
|
}
|
|
async fn ensure_key(
|
|
&self,
|
|
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
|
_: String,
|
|
_: Vec<String>,
|
|
_: gateway_admin::model::client_keys::NewClientKey,
|
|
_: &gateway_admin::model::MutationContext,
|
|
) -> gateway_admin::ports::store::AdminStoreResult<
|
|
gateway_admin::model::plugin_resources::ResourceMutation<
|
|
gateway_admin::model::plugin_resources::ManagedResource,
|
|
>,
|
|
> {
|
|
unreachable!("resource port is not used by this fixture")
|
|
}
|
|
async fn change_members(
|
|
&self,
|
|
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
|
_: gateway_admin::model::plugin_resources::GroupMembersChange,
|
|
_: &gateway_admin::model::MutationContext,
|
|
) -> gateway_admin::ports::store::AdminStoreResult<
|
|
gateway_admin::model::plugin_resources::ResourceMutation<
|
|
gateway_admin::model::plugin_resources::GroupMembersChanged,
|
|
>,
|
|
> {
|
|
unreachable!("resource port is not used by this fixture")
|
|
}
|
|
}
|