merge feature/ticket120-plugin-asset-cors-headers dans develop (#120: headers CORS idea-plugin:// — QA PASS avec reserve e2e AppImage)
Cause racine de la 4e rechute #120 corrigee : plugin_asset_response posait aucun header CORS, bloquant l'import() dynamique du bundle plugin sous WebKitGTK. Valide via cargo test -p app-tauri (test CORS cible + plugin_install_load) et tests frontend runtime/plugins, tous verts. Reserve QA non levee : pas de preuve visuelle e2e dans un AppImage reel (fuse absent dans cet environnement) que le bandeau disparait apres redemarrage. Cloture definitive du ticket #120 a conditionner a cette validation AppImage reelle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -15,6 +15,9 @@ use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const PLUGIN_ASSET_CORS_ORIGIN: &str = "*";
|
||||
const PLUGIN_ASSET_CORS_METHODS: &str = "GET";
|
||||
|
||||
/// Lists installed plugins for the admin surface.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_list_plugins(
|
||||
@ -192,8 +195,7 @@ pub fn plugin_asset_protocol(
|
||||
application::diag!(
|
||||
"[plugins] asset failed uri={uri} status={status} message={message}"
|
||||
);
|
||||
Response::builder()
|
||||
.status(status)
|
||||
plugin_asset_response_builder(status)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(message.into_bytes())
|
||||
.unwrap_or_else(|e| {
|
||||
@ -207,6 +209,27 @@ pub fn plugin_asset_protocol(
|
||||
fn plugin_asset_response(
|
||||
app: &AppHandle,
|
||||
request: http::Request<Vec<u8>>,
|
||||
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||
let state = app.state::<AppState>();
|
||||
let app_data = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
plugin_asset_response_with_stores(
|
||||
&app_data,
|
||||
request,
|
||||
state.plugin_registry_store.as_ref(),
|
||||
state.plugin_package_store.as_ref(),
|
||||
state.plugin_manifest_validator.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn plugin_asset_response_with_stores(
|
||||
app_data: &Path,
|
||||
request: http::Request<Vec<u8>>,
|
||||
registry_store: &dyn PluginRegistryStore,
|
||||
package_store: &dyn PluginPackageStore,
|
||||
validator: &dyn PluginManifestValidator,
|
||||
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||
let uri = request.uri();
|
||||
let plugin_id = uri
|
||||
@ -227,14 +250,13 @@ fn plugin_asset_response(
|
||||
}
|
||||
let rel =
|
||||
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
let state = app.state::<AppState>();
|
||||
let allowed = block_on_protocol_future(asset_allowed(
|
||||
&plugin_id,
|
||||
hash,
|
||||
&rel,
|
||||
state.plugin_registry_store.as_ref(),
|
||||
state.plugin_package_store.as_ref(),
|
||||
state.plugin_manifest_validator.as_ref(),
|
||||
registry_store,
|
||||
package_store,
|
||||
validator,
|
||||
))?;
|
||||
if !allowed {
|
||||
return Err((
|
||||
@ -242,10 +264,6 @@ fn plugin_asset_response(
|
||||
"plugin asset is not active".to_owned(),
|
||||
));
|
||||
}
|
||||
let app_data = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let root = app_data
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
@ -264,13 +282,25 @@ fn plugin_asset_response(
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
plugin_asset_response_builder(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type(&target))
|
||||
.body(bytes)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||
}
|
||||
|
||||
fn plugin_asset_response_builder(status: StatusCode) -> http::response::Builder {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
PLUGIN_ASSET_CORS_ORIGIN,
|
||||
)
|
||||
.header(
|
||||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
PLUGIN_ASSET_CORS_METHODS,
|
||||
)
|
||||
}
|
||||
|
||||
async fn asset_allowed(
|
||||
plugin_id: &PluginId,
|
||||
hash: &str,
|
||||
@ -351,8 +381,12 @@ fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{asset_allowed, block_on_protocol_future};
|
||||
use super::{
|
||||
asset_allowed, block_on_protocol_future, plugin_asset_response_with_stores,
|
||||
PLUGIN_ASSET_CORS_METHODS, PLUGIN_ASSET_CORS_ORIGIN,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -360,10 +394,11 @@ mod tests {
|
||||
PluginRegistryStore, PluginStoreError,
|
||||
};
|
||||
use domain::{
|
||||
ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState,
|
||||
PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||
ContentHash, LocalPath, PluginContributionSet, PluginId, PluginInstallSource,
|
||||
PluginLifecycleState, PluginManifest, PluginRegistry, PluginRegistryEntry,
|
||||
PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||
};
|
||||
use http::StatusCode;
|
||||
use http::{header, StatusCode};
|
||||
|
||||
struct FakeRegistry {
|
||||
registry: Mutex<PluginRegistry>,
|
||||
@ -459,6 +494,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct AcceptingValidator {
|
||||
plugin_id: PluginId,
|
||||
main: RelativePath,
|
||||
}
|
||||
|
||||
impl domain::ports::PluginManifestValidator for AcceptingValidator {
|
||||
fn validate(
|
||||
&self,
|
||||
_bytes: &[u8],
|
||||
_package: &domain::PluginPackageRef,
|
||||
) -> Result<PluginManifest, PluginManifestError> {
|
||||
Ok(PluginManifest {
|
||||
idea_plugin_manifest_version: 1,
|
||||
id: self.plugin_id.clone(),
|
||||
display_name: "Git Graph".to_owned(),
|
||||
publisher: None,
|
||||
version: PluginVersion::new("1.0.0").unwrap(),
|
||||
description: None,
|
||||
engine_idea: None,
|
||||
main: self.main.clone(),
|
||||
icon: None,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: Vec::new(),
|
||||
contributes: PluginContributionSet::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn protocol_future_can_be_waited_inside_tauri_runtime() {
|
||||
let value = block_on_protocol_future(async { 42 });
|
||||
@ -504,4 +567,80 @@ mod tests {
|
||||
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
||||
assert!(err.1.contains("broken manifest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_asset_response_includes_cors_headers_for_dynamic_import() {
|
||||
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||
let hash = ContentHash::new("abc123").unwrap();
|
||||
let rel = RelativePath::new("dist/index.js").unwrap();
|
||||
let registry = FakeRegistry {
|
||||
registry: Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: plugin_id.clone(),
|
||||
lifecycle_state: PluginLifecycleState::Enabled,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/source/plugin".to_owned(),
|
||||
},
|
||||
content_hash: hash.clone(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
}),
|
||||
};
|
||||
let packages = FakePackages {
|
||||
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
|
||||
};
|
||||
let validator = AcceptingValidator {
|
||||
plugin_id: plugin_id.clone(),
|
||||
main: rel.clone(),
|
||||
};
|
||||
let app_data = test_app_data_dir("plugin-asset-cors");
|
||||
let target = app_data
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
.join(plugin_id.as_str())
|
||||
.join(rel.as_str());
|
||||
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
std::fs::write(&target, "export default 42;").unwrap();
|
||||
let request = http::Request::builder()
|
||||
.uri(format!(
|
||||
"idea-plugin://{}/current/{}/{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
rel.as_str()
|
||||
))
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
|
||||
let response =
|
||||
plugin_asset_response_with_stores(&app_data, request, ®istry, &packages, &validator)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
|
||||
Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_ORIGIN))
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get(header::ACCESS_CONTROL_ALLOW_METHODS),
|
||||
Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_METHODS))
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get(header::CONTENT_TYPE),
|
||||
Some(&http::HeaderValue::from_static(
|
||||
"text/javascript; charset=utf-8"
|
||||
))
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
fn test_app_data_dir(label: &str) -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("idea-{label}-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user