Skip to main content

minco_plugin_feedback/
plugin.rs

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