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}