upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/grasp-audit/src/client.rs
blob: 4831d3f65527683df64cd22733071c19e0108072 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
//! Audit client for testing GRASP implementations

use crate::audit::{AuditConfig, AuditEventBuilder, AuditMode};
use anyhow::{anyhow, Result};
use nostr_sdk::prelude::*;
use std::time::Duration;

/// Client for auditing GRASP implementations
pub struct AuditClient {
    client: Client,
    pub config: AuditConfig,
    keys: Keys,
}

impl AuditClient {
    /// Create a new audit client
    pub async fn new(relay_url: &str, config: AuditConfig) -> Result<Self> {
        let keys = Keys::generate();
        let client = Client::new(keys.clone());
        
        // Add relay and connect
        client.add_relay(relay_url).await?;
        client.connect().await;
        
        // Wait for connection to establish (with retries)
        let mut attempts = 0;
        while attempts < 20 {
            tokio::time::sleep(Duration::from_millis(100)).await;
            
            let relays = client.relays().await;
            let connected = relays.values().any(|r| r.is_connected());
            
            if connected {
                break;
            }
            
            attempts += 1;
        }
        
        // Give it a bit more time to stabilize
        tokio::time::sleep(Duration::from_millis(200)).await;
        
        Ok(Self {
            client,
            config,
            keys,
        })
    }
    
    /// Get the public key for this audit client
    pub fn public_key(&self) -> PublicKey {
        self.keys.public_key()
    }
    
    /// Check if connected to relay
    pub async fn is_connected(&self) -> bool {
        // Check if we have any connected relays
        let relays = self.client.relays().await;
        for relay in relays.values() {
            if relay.is_connected() {
                return true;
            }
        }
        false
    }
    
    /// Send an event (with audit tags automatically added)
    pub async fn send_event(&self, event: Event) -> Result<EventId> {
        if self.config.read_only {
            return Err(anyhow!("Client is in read-only mode"));
        }
        
        let output = self.client.send_event(&event).await?;
        let event_id = *output.id();
        
        // Check if any relay rejected the event
        if output.success.is_empty() && !output.failed.is_empty() {
            return Err(anyhow!("All relays rejected the event"));
        }
        
        // Wait a bit for event to propagate
        tokio::time::sleep(Duration::from_millis(100)).await;
        
        Ok(event_id)
    }
    
    /// Create an event builder with audit tags
    pub fn event_builder(&self, kind: Kind, content: impl Into<String>) -> AuditEventBuilder {
        AuditEventBuilder::new(kind, content, self.config.clone())
    }
    
    /// Query events, optionally filtered to this audit run
    pub async fn query(&self, mut filter: Filter) -> Result<Vec<Event>> {
        use nostr_sdk::prelude::{Alphabet, SingleLetterTag};
        
        if self.config.mode == AuditMode::CI {
            // In CI mode, only see our own audit events
            // Filter by "t" tags (hashtags)
            let t_tag = SingleLetterTag::lowercase(Alphabet::T);
            filter = filter
                .custom_tag(t_tag, "grasp-audit-test-event")
                .custom_tag(t_tag, format!("audit-{}", self.config.run_id));
        }
        // In Production mode, see all events (no filter modification)
        
        let events = self.client
            .fetch_events(filter, Duration::from_secs(5))
            .await?;
        
        Ok(events.into_iter().collect())
    }
    
    /// Subscribe to events with a callback
    pub async fn subscribe(
        &self,
        filters: Vec<Filter>,
        timeout: Option<Duration>,
    ) -> Result<Vec<Event>> {
        let timeout = timeout.unwrap_or(Duration::from_secs(5));
        let mut all_events = Vec::new();
        
        for filter in filters {
            let events = self.client
                .fetch_events(filter, timeout)
                .await?;
            all_events.extend(events.into_iter());
        }
        
        Ok(all_events)
    }
    
    /// Get the underlying nostr client (for advanced usage)
    pub fn client(&self) -> &Client {
        &self.client
    }
    
    /// Get the keys (for signing custom events)
    pub fn keys(&self) -> &Keys {
        &self.keys
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[tokio::test]
    async fn test_client_creation() {
        let config = AuditConfig::ci();
        
        // This will fail if no relay is running, which is expected in tests
        // In real usage, there should be a relay at the URL
        let result = AuditClient::new("ws://localhost:7000", config).await;
        
        // We can't test connection without a running relay
        // But we can test that the client is created
        if let Ok(client) = result {
            assert_eq!(client.config.mode, AuditMode::CI);
        }
    }
    
    #[test]
    fn test_event_builder() {
        let config = AuditConfig::ci();
        let keys = Keys::generate();
        let client = AuditClient {
            client: Client::new(keys.clone()),
            config: config.clone(),
            keys: keys.clone(),
        };
        
        let _builder = client.event_builder(Kind::TextNote, "test content");
        
        // Builder should be created successfully
        // (We can't test the internal config field as it's private, which is correct)
    }
}