Skip to main content

minco_plugin_feedback/
plugin.rs

1use crate::{
2    FeedbackConfig, FeedbackReleaseBinding, FeedbackService, FeedbackStore, FeedbackStoreService,
3    FeedbackValidationError, MemoryFeedbackStore, TranscriptionService,
4    feedback_request_body_budget, feedback_router,
5};
6use async_trait::async_trait;
7#[cfg(any(feature = "postgres", feature = "sqlite"))]
8use minco_core::MigrationSet;
9use minco_core::{
10    CapabilityProvision, CapabilityRequirement, ConfigurationField, ConfigurationValueKind,
11    DataClass, HealthCheckDescriptor, IdleCostClass, OperationDescriptor, Plugin, PluginContext,
12    PluginDescriptor, PluginError, PluginId, PluginStability, ResourceIntent, ResourceKind,
13};
14use minco_http::{HttpHeaderPolicy, HttpModule};
15use minco_plugin_audit::AuditService;
16use minco_plugin_events::EventServices;
17use minco_plugin_health::{HealthCheck, HealthResult};
18use minco_plugin_notifications::NotificationService;
19use minco_plugin_object_storage::ObjectStoreService;
20use semver::{Version, VersionReq};
21use std::sync::Arc;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum FeedbackStorageProfile {
25    Memory,
26    Custom,
27    #[cfg(feature = "postgres")]
28    Postgres,
29    #[cfg(feature = "sqlite")]
30    Sqlite,
31}
32
33#[derive(Clone)]
34pub struct FeedbackPlugin {
35    store: FeedbackStoreService,
36    storage_profile: FeedbackStorageProfile,
37    transcription: Option<TranscriptionService>,
38    release_binding: Option<FeedbackReleaseBinding>,
39}
40
41impl std::fmt::Debug for FeedbackPlugin {
42    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        formatter
44            .debug_struct("FeedbackPlugin")
45            .field("storage_profile", &self.storage_profile)
46            .field("transcription_configured", &self.transcription.is_some())
47            .field("release_bound", &self.release_binding.is_some())
48            .finish_non_exhaustive()
49    }
50}
51
52impl FeedbackPlugin {
53    #[must_use]
54    pub fn new(store: Arc<dyn FeedbackStore>) -> Self {
55        Self {
56            store: FeedbackStoreService::new(store),
57            storage_profile: FeedbackStorageProfile::Custom,
58            transcription: None,
59            release_binding: None,
60        }
61    }
62
63    #[must_use]
64    pub fn memory() -> Self {
65        Self {
66            store: FeedbackStoreService::new(Arc::new(MemoryFeedbackStore::default())),
67            storage_profile: FeedbackStorageProfile::Memory,
68            transcription: None,
69            release_binding: None,
70        }
71    }
72
73    #[must_use]
74    pub fn with_transcription(mut self, transcription: TranscriptionService) -> Self {
75        self.transcription = Some(transcription);
76        self
77    }
78
79    pub fn with_release_binding(
80        mut self,
81        binding: FeedbackReleaseBinding,
82    ) -> Result<Self, FeedbackValidationError> {
83        binding.validate()?;
84        self.release_binding = Some(binding);
85        Ok(self)
86    }
87
88    #[cfg(feature = "postgres")]
89    #[must_use]
90    pub fn postgres(pool: sqlx::PgPool) -> Self {
91        Self {
92            store: FeedbackStoreService::new(Arc::new(crate::PostgresFeedbackStore::new(pool))),
93            storage_profile: FeedbackStorageProfile::Postgres,
94            transcription: None,
95            release_binding: None,
96        }
97    }
98
99    #[cfg(feature = "sqlite")]
100    #[must_use]
101    pub fn sqlite(pool: sqlx::SqlitePool) -> Self {
102        Self {
103            store: FeedbackStoreService::new(Arc::new(crate::SqliteFeedbackStore::new(pool))),
104            storage_profile: FeedbackStorageProfile::Sqlite,
105            transcription: None,
106            release_binding: None,
107        }
108    }
109}
110
111impl Default for FeedbackPlugin {
112    fn default() -> Self {
113        Self::memory()
114    }
115}
116
117impl Plugin for FeedbackPlugin {
118    fn descriptor(&self) -> PluginDescriptor {
119        let mut descriptor = PluginDescriptor::new(
120            PluginId::new("feedback").expect("static plugin ID"),
121            Version::new(0, 1, 0),
122            "Fast client feedback loops with screenshots, voice, discussion, and AI-ready context",
123        );
124        descriptor.core_compatibility =
125            VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
126        descriptor.stability = PluginStability::Stable;
127        descriptor.default_enabled = false;
128        descriptor.documentation = Some("https://docs.rs/minco-plugin-feedback".into());
129        descriptor.data_classes.extend([
130            DataClass::CustomerProvided,
131            DataClass::Personal,
132            DataClass::Confidential,
133        ]);
134        descriptor.plugin_dependencies.extend(
135            [
136                "health",
137                "identity",
138                "object-storage",
139                "notifications",
140                "audit",
141                "events",
142            ]
143            .into_iter()
144            .map(|id| PluginId::new(id).expect("static plugin ID")),
145        );
146        descriptor.requires.extend([
147            requirement("health.registry"),
148            requirement("identity.resolve"),
149            requirement("authorization.permissions"),
150            requirement("storage.object"),
151            requirement("notifications.send"),
152            requirement("audit.append"),
153            requirement("events.publish"),
154            requirement("events.outbox"),
155        ]);
156        descriptor.provides.extend([
157            provision("feedback.submit"),
158            provision("feedback.conversation"),
159            provision("feedback.manage"),
160            provision("feedback.ai-context"),
161            provision("feedback.widget"),
162        ]);
163        if self.transcription.is_some() {
164            descriptor
165                .provides
166                .push(provision("feedback.transcription"));
167        }
168        descriptor.operations.extend(feedback_operations());
169        match self.storage_profile {
170            FeedbackStorageProfile::Memory => {}
171            FeedbackStorageProfile::Custom => descriptor.resources.push(ResourceIntent {
172                id: "feedback-custom-store".into(),
173                kind: ResourceKind::Custom("feedback-store".into()),
174                idle_cost: IdleCostClass::ProviderManaged,
175                wake_sources: Vec::new(),
176                dependencies: Vec::new(),
177            }),
178            #[cfg(feature = "postgres")]
179            FeedbackStorageProfile::Postgres => descriptor.migrations.push(MigrationSet {
180                id: "feedback-postgres-v1".into(),
181                database: "postgres".into(),
182                path: "migrations/postgres".into(),
183            }),
184            #[cfg(feature = "sqlite")]
185            FeedbackStorageProfile::Sqlite => descriptor.migrations.push(MigrationSet {
186                id: "feedback-sqlite-v1".into(),
187                database: "sqlite".into(),
188                path: "migrations/sqlite".into(),
189            }),
190        }
191        descriptor.health_checks.push(HealthCheckDescriptor {
192            id: "feedback-store".into(),
193            critical: true,
194        });
195        descriptor.configuration.extend(configuration_fields());
196        descriptor
197    }
198
199    fn configure_descriptor(
200        &self,
201        descriptor: &mut PluginDescriptor,
202        configuration: Option<&serde_json::Value>,
203    ) -> Result<(), PluginError> {
204        let configuration = configuration
205            .cloned()
206            .unwrap_or_else(|| serde_json::json!({}));
207        let configuration =
208            serde_json::from_value::<FeedbackConfig>(configuration).map_err(|source| {
209                PluginError::InvalidConfiguration {
210                    plugin: descriptor.id.clone(),
211                    source,
212                }
213            })?;
214        if !configuration.transcription_enabled {
215            descriptor
216                .provides
217                .retain(|capability| capability.name != "feedback.transcription");
218        }
219        Ok(())
220    }
221
222    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
223        let config = context.configuration::<FeedbackConfig>()?;
224        let (objects, notifications, audit, events) = {
225            let services = context.services();
226            (
227                (*services.get::<ObjectStoreService>()?).clone(),
228                (*services.get::<NotificationService>()?).clone(),
229                (*services.get::<AuditService>()?).clone(),
230                (*services.get::<EventServices>()?).clone(),
231            )
232        };
233        let mut service = FeedbackService::new(
234            self.store.clone(),
235            objects,
236            notifications,
237            audit,
238            events,
239            self.transcription.clone(),
240            config,
241        )
242        .map_err(|error| PluginError::Installation(error.to_string()))?;
243        if let Some(binding) = self.release_binding.clone() {
244            service = service
245                .with_release_binding(binding)
246                .map_err(|error| PluginError::Installation(error.to_string()))?;
247        }
248
249        context.services().insert(Arc::new(self.store.clone()))?;
250        context.services().insert(Arc::new(service.clone()))?;
251        context
252            .contributions()
253            .push_shared::<dyn HealthCheck>(Arc::new(FeedbackHealthCheck(service.clone())));
254        let request_body_budget = feedback_request_body_budget(service.config());
255        let mut header_policy = HttpHeaderPolicy::empty();
256        for name in ["x-minco-feedback-token", "x-minco-feedback-project-key"] {
257            header_policy
258                .allow_request_header_name(name)
259                .and_then(|()| header_policy.mark_request_header_name_sensitive(name))
260                .map_err(|error| PluginError::Installation(error.to_string()))?;
261        }
262        HttpModule::new(context.plugin_id().clone(), feedback_router(service))
263            .with_operations(
264                feedback_operations()
265                    .into_iter()
266                    .map(|operation| operation.operation_id),
267            )
268            .with_max_request_body_bytes(request_body_budget)
269            .with_header_policy(header_policy)
270            .contribute(context);
271        Ok(())
272    }
273}
274
275#[derive(Debug, Clone)]
276struct FeedbackHealthCheck(FeedbackService);
277
278#[async_trait]
279impl HealthCheck for FeedbackHealthCheck {
280    fn id(&self) -> &'static str {
281        "feedback-store"
282    }
283
284    async fn check(&self) -> HealthResult {
285        match self.0.ready().await {
286            Ok(()) => HealthResult {
287                id: self.id().into(),
288                ready: true,
289                critical: true,
290                detail: None,
291            },
292            Err(error) => HealthResult {
293                id: self.id().into(),
294                ready: false,
295                critical: true,
296                detail: Some(error.to_string()),
297            },
298        }
299    }
300}
301
302fn provision(name: &str) -> CapabilityProvision {
303    CapabilityProvision {
304        name: name.into(),
305        version: Version::new(1, 0, 0),
306    }
307}
308
309fn requirement(name: &str) -> CapabilityRequirement {
310    CapabilityRequirement {
311        name: name.into(),
312        version: VersionReq::parse("^1").expect("static requirement"),
313    }
314}
315
316fn feedback_operations() -> Vec<OperationDescriptor> {
317    [
318        ("feedbackWidget", "GET", "/_minco/feedback/widget.js", true),
319        (
320            "getFeedbackWidgetConfig",
321            "GET",
322            "/_minco/feedback/widget-config",
323            true,
324        ),
325        ("createFeedback", "POST", "/_minco/feedback/threads", true),
326        (
327            "getClientFeedback",
328            "GET",
329            "/_minco/feedback/threads/{id}",
330            true,
331        ),
332        (
333            "replyToFeedback",
334            "POST",
335            "/_minco/feedback/threads/{id}/messages",
336            true,
337        ),
338        (
339            "getClientFeedbackAttachment",
340            "GET",
341            "/_minco/feedback/threads/{id}/attachments/{attachmentId}",
342            true,
343        ),
344        (
345            "transcribeFeedbackAudio",
346            "POST",
347            "/_minco/feedback/transcriptions",
348            true,
349        ),
350        (
351            "listDeveloperFeedback",
352            "GET",
353            "/_minco/feedback/developer/threads",
354            false,
355        ),
356        (
357            "getDeveloperFeedback",
358            "GET",
359            "/_minco/feedback/developer/threads/{id}",
360            false,
361        ),
362        (
363            "developerReplyToFeedback",
364            "POST",
365            "/_minco/feedback/developer/threads/{id}/messages",
366            false,
367        ),
368        (
369            "transitionFeedback",
370            "PATCH",
371            "/_minco/feedback/developer/threads/{id}/status",
372            false,
373        ),
374        (
375            "getFeedbackAiContext",
376            "GET",
377            "/_minco/feedback/developer/threads/{id}/ai-context",
378            false,
379        ),
380        (
381            "getDeveloperFeedbackAttachment",
382            "GET",
383            "/_minco/feedback/developer/threads/{id}/attachments/{attachmentId}",
384            false,
385        ),
386    ]
387    .into_iter()
388    .map(|(operation_id, method, path, public)| OperationDescriptor {
389        operation_id: operation_id.into(),
390        method: method.into(),
391        path: path.into(),
392        public,
393        idempotent: false,
394    })
395    .collect()
396}
397
398fn configuration_fields() -> Vec<ConfigurationField> {
399    vec![
400        field(
401            "project_id",
402            ConfigurationValueKind::String,
403            true,
404            false,
405            None,
406            "Stable product or application identifier",
407        ),
408        field(
409            "widget_label",
410            ConfigurationValueKind::String,
411            false,
412            false,
413            Some(serde_json::json!("Share feedback")),
414            "Accessible label shown on the feedback action",
415        ),
416        field(
417            "widget_position",
418            ConfigurationValueKind::String,
419            false,
420            false,
421            Some(serde_json::json!("bottom_right")),
422            "FAB position: top_left, top_right, bottom_left, or bottom_right",
423        ),
424        field(
425            "offset_x_px",
426            ConfigurationValueKind::Integer,
427            false,
428            false,
429            Some(serde_json::json!(24)),
430            "Horizontal viewport offset in CSS pixels",
431        ),
432        field(
433            "offset_y_px",
434            ConfigurationValueKind::Integer,
435            false,
436            false,
437            Some(serde_json::json!(24)),
438            "Vertical viewport offset in CSS pixels",
439        ),
440        field(
441            "theme",
442            ConfigurationValueKind::String,
443            false,
444            false,
445            Some(serde_json::json!("auto")),
446            "Widget theme: light, dark, or auto",
447        ),
448        field(
449            "token_storage",
450            ConfigurationValueKind::String,
451            false,
452            false,
453            Some(serde_json::json!("session")),
454            "Opaque client-token storage: session (default) or local",
455        ),
456        field(
457            "max_http_body_bytes",
458            ConfigurationValueKind::Integer,
459            false,
460            false,
461            Some(serde_json::json!(7 * 1024 * 1024)),
462            "Maximum complete multipart request size for the default serverless HTTP path",
463        ),
464        field(
465            "max_screenshot_bytes",
466            ConfigurationValueKind::Integer,
467            false,
468            false,
469            Some(serde_json::json!(4 * 1024 * 1024)),
470            "Maximum screenshot upload size",
471        ),
472        field(
473            "max_audio_bytes",
474            ConfigurationValueKind::Integer,
475            false,
476            false,
477            Some(serde_json::json!(5 * 1024 * 1024)),
478            "Maximum voice recording upload size",
479        ),
480        field(
481            "max_file_bytes",
482            ConfigurationValueKind::Integer,
483            false,
484            false,
485            Some(serde_json::json!(5 * 1024 * 1024)),
486            "Maximum general attachment upload size",
487        ),
488        field(
489            "max_attachments",
490            ConfigurationValueKind::Integer,
491            false,
492            false,
493            Some(serde_json::json!(3)),
494            "Maximum screenshot, audio, and file attachments per submission; zero disables all attachments",
495        ),
496        field(
497            "allow_anonymous",
498            ConfigurationValueKind::Boolean,
499            false,
500            false,
501            Some(serde_json::json!(false)),
502            "Explicitly allow unauthenticated feedback when neither identity nor a project key is available",
503        ),
504        field(
505            "project_key",
506            ConfigurationValueKind::String,
507            false,
508            false,
509            None,
510            "Optional browser-visible submission key used for basic abuse controls",
511        ),
512        field(
513            "developer_token",
514            ConfigurationValueKind::String,
515            false,
516            true,
517            None,
518            "Fallback bearer token for local/operator access; prefer an identity principal with feedback.manage",
519        ),
520        field(
521            "developer_recipient",
522            ConfigurationValueKind::String,
523            false,
524            false,
525            Some(serde_json::json!("developers")),
526            "Recipient understood by the configured notification sink",
527        ),
528        field(
529            "developer_link_base",
530            ConfigurationValueKind::String,
531            false,
532            false,
533            None,
534            "Optional base URL included in developer notifications",
535        ),
536        field(
537            "notify_client_updates",
538            ConfigurationValueKind::Boolean,
539            false,
540            false,
541            Some(serde_json::json!(true)),
542            "Send in-app notifications for developer replies and status changes",
543        ),
544        field(
545            "publish_events_inline",
546            ConfigurationValueKind::Boolean,
547            false,
548            false,
549            Some(serde_json::json!(false)),
550            "Publish outbox events on the request path instead of leaving them for a worker",
551        ),
552        field(
553            "screenshot_enabled",
554            ConfigurationValueKind::Boolean,
555            false,
556            false,
557            Some(serde_json::json!(true)),
558            "Allow browser screen capture and image attachments",
559        ),
560        field(
561            "voice_enabled",
562            ConfigurationValueKind::Boolean,
563            false,
564            false,
565            Some(serde_json::json!(false)),
566            "Allow microphone recording when the browser supports MediaRecorder",
567        ),
568        field(
569            "max_recording_seconds",
570            ConfigurationValueKind::Integer,
571            false,
572            false,
573            Some(serde_json::json!(90)),
574            "Maximum browser voice-note recording duration",
575        ),
576        field(
577            "include_url_query",
578            ConfigurationValueKind::Boolean,
579            false,
580            false,
581            Some(serde_json::json!(false)),
582            "Include URL query parameters in captured context after redaction",
583        ),
584        field(
585            "redact_query_parameters",
586            ConfigurationValueKind::StringList,
587            false,
588            false,
589            Some(serde_json::json!([
590                "access_token",
591                "api_key",
592                "code",
593                "key",
594                "password",
595                "secret",
596                "signature",
597                "token"
598            ])),
599            "Case-insensitive query parameter names replaced with [REDACTED]",
600        ),
601        field(
602            "transcription_enabled",
603            ConfigurationValueKind::Boolean,
604            false,
605            false,
606            Some(serde_json::json!(false)),
607            "Expose voice transcription for authenticated feedback.create principals when a TranscriptionService is configured",
608        ),
609        field(
610            "auto_transcribe_audio",
611            ConfigurationValueKind::Boolean,
612            false,
613            false,
614            Some(serde_json::json!(false)),
615            "Transcribe uploaded voice recordings automatically",
616        ),
617        field(
618            "poll_interval_ms",
619            ConfigurationValueKind::Integer,
620            false,
621            false,
622            Some(serde_json::json!(15_000)),
623            "Client discussion refresh interval in milliseconds",
624        ),
625        field(
626            "privacy_notice",
627            ConfigurationValueKind::String,
628            false,
629            false,
630            None,
631            "Optional client-visible privacy and retention notice",
632        ),
633    ]
634}
635
636fn field(
637    key: &str,
638    kind: ConfigurationValueKind,
639    required: bool,
640    secret: bool,
641    default: Option<serde_json::Value>,
642    description: &str,
643) -> ConfigurationField {
644    ConfigurationField {
645        key: key.into(),
646        kind,
647        required,
648        secret,
649        description: description.into(),
650        default,
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::{DisabledTranscriber, TranscriptionService};
658    use minco_core::{PluginManager, PluginSelection};
659    use minco_plugin_audit::AuditPlugin;
660    use minco_plugin_events::EventsPlugin;
661    use minco_plugin_health::HealthPlugin;
662    use minco_plugin_identity::IdentityPlugin;
663    use minco_plugin_notifications::NotificationsPlugin;
664    use minco_plugin_object_storage::ObjectStoragePlugin;
665
666    #[test]
667    fn feedback_declares_every_foundational_dependency() {
668        let descriptor = FeedbackPlugin::default().descriptor();
669        assert_eq!(descriptor.stability, PluginStability::Stable);
670        let dependencies = descriptor
671            .plugin_dependencies
672            .iter()
673            .map(PluginId::as_str)
674            .collect::<Vec<_>>();
675        assert_eq!(
676            dependencies,
677            [
678                "health",
679                "identity",
680                "object-storage",
681                "notifications",
682                "audit",
683                "events"
684            ]
685        );
686        assert!(
687            descriptor
688                .operations
689                .iter()
690                .any(|operation| operation.operation_id == "createFeedback")
691        );
692        assert!(
693            descriptor
694                .data_classes
695                .contains(&DataClass::CustomerProvided)
696        );
697    }
698
699    #[test]
700    fn feedback_plugin_composes_with_explicit_foundational_dependencies() {
701        let mut manager = PluginManager::default();
702        manager.register(HealthPlugin).unwrap();
703        manager.register(IdentityPlugin::default()).unwrap();
704        manager.register(ObjectStoragePlugin::memory()).unwrap();
705        manager.register(NotificationsPlugin::memory().0).unwrap();
706        manager.register(AuditPlugin::memory().0).unwrap();
707        manager.register(EventsPlugin::memory().0).unwrap();
708        manager.register(FeedbackPlugin::memory()).unwrap();
709
710        let mut selection = PluginSelection::default();
711        let feedback_id = PluginId::new("feedback").unwrap();
712        selection.enabled.insert(feedback_id.clone());
713        selection
714            .set_configuration(
715                feedback_id,
716                &FeedbackConfig {
717                    project_id: "example".into(),
718                    developer_token: Some("developer-token-with-enough-entropy".into()),
719                    ..FeedbackConfig::default()
720                },
721            )
722            .unwrap();
723        let application = manager.compose(&selection).unwrap();
724        assert!(application.services.get::<FeedbackService>().is_ok());
725        assert_eq!(application.contributions.get::<HttpModule>().len(), 1);
726        let module = application
727            .contributions
728            .get::<HttpModule>()
729            .into_iter()
730            .next()
731            .unwrap();
732        assert_eq!(
733            module
734                .header_policy
735                .allowed_request_headers()
736                .iter()
737                .map(http::HeaderName::as_str)
738                .collect::<Vec<_>>(),
739            ["x-minco-feedback-project-key", "x-minco-feedback-token"]
740        );
741        assert_eq!(
742            application
743                .contributions
744                .get_shared::<dyn HealthCheck>()
745                .len(),
746            1
747        );
748    }
749    #[test]
750    fn feedback_plugin_installs_an_exact_server_release_binding() {
751        let release_digest = "a".repeat(64);
752        let binding = FeedbackReleaseBinding {
753            release_id: format!("minco.{}", &release_digest[..24]),
754            release_digest,
755            environment: "review".into(),
756            deployment_attempt_id: "attempt-1".into(),
757            deployment_receipt_digest: "b".repeat(64),
758            ui_build_id: None,
759            ui_build_digest: None,
760        };
761        let plugin = FeedbackPlugin::memory()
762            .with_release_binding(binding.clone())
763            .expect("valid release binding");
764        let mut manager = PluginManager::default();
765        manager.register(HealthPlugin).unwrap();
766        manager.register(IdentityPlugin::default()).unwrap();
767        manager.register(ObjectStoragePlugin::memory()).unwrap();
768        manager.register(NotificationsPlugin::memory().0).unwrap();
769        manager.register(AuditPlugin::memory().0).unwrap();
770        manager.register(EventsPlugin::memory().0).unwrap();
771        manager.register(plugin).unwrap();
772
773        let feedback_id = PluginId::new("feedback").unwrap();
774        let mut selection = PluginSelection::default();
775        selection.enabled.insert(feedback_id.clone());
776        selection
777            .set_configuration(
778                feedback_id,
779                &FeedbackConfig {
780                    project_id: "example".into(),
781                    ..FeedbackConfig::default()
782                },
783            )
784            .unwrap();
785        let application = manager.compose(&selection).unwrap();
786        assert_eq!(
787            application
788                .services
789                .get::<FeedbackService>()
790                .unwrap()
791                .release_binding(),
792            Some(&binding)
793        );
794    }
795
796    #[test]
797    fn memory_feedback_does_not_claim_database_migrations_or_transcription() {
798        let descriptor = FeedbackPlugin::memory().descriptor();
799        assert!(descriptor.migrations.is_empty());
800        assert!(descriptor.resources.is_empty());
801        assert!(
802            descriptor
803                .provides
804                .iter()
805                .all(|capability| capability.name != "feedback.transcription")
806        );
807    }
808
809    #[test]
810    fn transcription_capability_requires_both_provider_and_enabled_configuration() {
811        fn manager_with_feedback(plugin: FeedbackPlugin) -> PluginManager {
812            let mut manager = PluginManager::default();
813            manager.register(HealthPlugin).unwrap();
814            manager.register(IdentityPlugin::default()).unwrap();
815            manager.register(ObjectStoragePlugin::memory()).unwrap();
816            manager.register(NotificationsPlugin::memory().0).unwrap();
817            manager.register(AuditPlugin::memory().0).unwrap();
818            manager.register(EventsPlugin::memory().0).unwrap();
819            manager.register(plugin).unwrap();
820            manager
821        }
822
823        let plugin = FeedbackPlugin::memory()
824            .with_transcription(TranscriptionService::new(Arc::new(DisabledTranscriber)));
825        let manager = manager_with_feedback(plugin);
826        let feedback_id = PluginId::new("feedback").unwrap();
827
828        let mut disabled = PluginSelection::default();
829        disabled.enabled.insert(feedback_id.clone());
830        disabled
831            .set_configuration(
832                feedback_id.clone(),
833                &FeedbackConfig {
834                    project_id: "example".into(),
835                    transcription_enabled: false,
836                    ..FeedbackConfig::default()
837                },
838            )
839            .unwrap();
840        let disabled_graph = manager.compose(&disabled).unwrap().graph;
841        assert!(
842            !disabled_graph
843                .capabilities
844                .contains_key("feedback.transcription")
845        );
846
847        let mut enabled = PluginSelection::default();
848        enabled.enabled.insert(feedback_id.clone());
849        enabled
850            .set_configuration(
851                feedback_id,
852                &FeedbackConfig {
853                    project_id: "example".into(),
854                    transcription_enabled: true,
855                    ..FeedbackConfig::default()
856                },
857            )
858            .unwrap();
859        let enabled_graph = manager.compose(&enabled).unwrap().graph;
860        assert!(
861            enabled_graph
862                .capabilities
863                .contains_key("feedback.transcription")
864        );
865    }
866
867    #[test]
868    fn openapi_contract_obeys_minco_policy_and_matches_the_plugin_operation_inventory() {
869        let path =
870            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi/feedback.openapi.yaml");
871        let report = minco_contract::load_contract(path).unwrap();
872        assert!(report.is_valid(), "{:?}", report.findings);
873
874        let mut contract = report
875            .document
876            .operations
877            .into_iter()
878            .map(|operation| {
879                (
880                    operation.operation_id,
881                    operation.method.as_str().to_owned(),
882                    operation.path,
883                    !operation.authenticated,
884                )
885            })
886            .collect::<Vec<_>>();
887        let mut descriptor = FeedbackPlugin::memory()
888            .descriptor()
889            .operations
890            .into_iter()
891            .map(|operation| {
892                (
893                    operation.operation_id,
894                    operation.method,
895                    operation.path,
896                    operation.public,
897                )
898            })
899            .collect::<Vec<_>>();
900        contract.sort();
901        descriptor.sort();
902        assert_eq!(contract, descriptor);
903    }
904}