faucet-source-snowflake 1.1.2

Snowflake query source connector for the faucet-stream ecosystem
Documentation
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! Integration tests for the Snowflake source's less-travelled HTTP paths:
//!
//! - the asynchronous (HTTP 202) submit → poll-until-ready flow,
//! - the 202-without-handle error and the poll-timeout error,
//! - partition-fetch failures (HTTP non-success and a missing handle),
//! - the key-pair JWT auth header shape,
//! - `faucet doctor` probe failures (auth-resolution, request, and HTTP).
//!
//! All scenarios run against a wiremock server; no live Snowflake is touched.

use std::collections::HashMap;
use std::time::Duration;

use faucet_core::check::{CheckContext, ProbeStatus};
use faucet_core::{AuthSpec, FaucetError, Source};
use faucet_source_snowflake::{SnowflakeAuth, SnowflakeSource, SnowflakeSourceConfig};
use serde_json::{Value, json};
use wiremock::matchers::{body_partial_json, header, header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn cfg() -> SnowflakeSourceConfig {
    SnowflakeSourceConfig::new(
        "xy12345",
        "WH",
        "DB",
        "PUBLIC",
        SnowflakeAuth::OAuth { token: "t".into() },
        "SELECT id FROM events",
    )
    .with_batch_size(10)
}

fn build_source(cfg: SnowflakeSourceConfig, server: &MockServer) -> SnowflakeSource {
    SnowflakeSource::new(cfg)
        .unwrap()
        .with_endpoint_base(server.uri())
}

fn metadata(num_partitions: usize) -> Value {
    let partition_info: Vec<Value> = (0..num_partitions)
        .map(|_| json!({"rowCount": 1}))
        .collect();
    json!({
        "rowType": [{"name": "ID", "type": "fixed"}],
        "partitionInfo": partition_info,
    })
}

// --- Async submit (HTTP 202) → poll-until-ready -----------------------------

#[tokio::test]
async fn async_202_submit_is_polled_until_ready_then_rows_decode() {
    let server = MockServer::start().await;

    // The initial POST is accepted asynchronously: 202 + a handle to poll.
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(202).set_body_json(json!({
            "statementHandle": "async-h",
            "message": "Asynchronous execution in progress"
        })))
        .mount(&server)
        .await;

    // Polling the handle (no `partition` query param) returns the finished
    // result set with schema + a single row.
    Mock::given(method("GET"))
        .and(path("/api/v2/statements/async-h"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "code": "090001",
            "statementHandle": "async-h",
            "resultSetMetaData": metadata(1),
            "data": [["7"]],
        })))
        .mount(&server)
        .await;

    let src = build_source(cfg(), &server);
    let rows = src.fetch_all().await.unwrap();
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0]["ID"], 7);

    // One POST (submit) followed by at least one GET (poll).
    let reqs = server.received_requests().await.unwrap();
    assert_eq!(reqs[0].method.as_str(), "POST");
    assert!(reqs.iter().skip(1).all(|r| r.method.as_str() == "GET"));
}

#[tokio::test]
async fn async_202_submit_without_handle_is_an_error() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(202).set_body_json(json!({
            "message": "accepted but no handle"
        })))
        .mount(&server)
        .await;

    let src = build_source(cfg(), &server);
    let err = src.fetch_all().await.unwrap_err();
    match err {
        FaucetError::Source(msg) => {
            assert!(
                msg.contains("without a statementHandle"),
                "unexpected: {msg}"
            );
        }
        other => panic!("expected Source error, got {other:?}"),
    }
}

#[tokio::test]
async fn poll_gives_up_after_poll_timeout() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(202).set_body_json(json!({
            "statementHandle": "stuck-h"
        })))
        .mount(&server)
        .await;
    // The statement never finishes — every poll stays 202.
    Mock::given(method("GET"))
        .and(path("/api/v2/statements/stuck-h"))
        .respond_with(ResponseTemplate::new(202).set_body_json(json!({
            "statementHandle": "stuck-h"
        })))
        .mount(&server)
        .await;

    let src = build_source(cfg().with_poll_timeout(Duration::from_millis(1)), &server);
    let err = src.fetch_all().await.unwrap_err();
    match err {
        FaucetError::Source(msg) => {
            assert!(msg.contains("poll_timeout"), "unexpected: {msg}");
            assert!(msg.contains("stuck-h"), "should name the handle: {msg}");
        }
        other => panic!("expected Source error, got {other:?}"),
    }
}

#[tokio::test]
async fn poll_http_error_surfaces_as_source_error() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(202).set_body_json(json!({
            "statementHandle": "err-h"
        })))
        .mount(&server)
        .await;
    // The poll itself returns a hard 500.
    Mock::given(method("GET"))
        .and(path("/api/v2/statements/err-h"))
        .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
        .mount(&server)
        .await;

    let src = build_source(cfg(), &server);
    let err = src.fetch_all().await.unwrap_err();
    match err {
        FaucetError::Source(msg) => {
            assert!(msg.contains("poll returned HTTP 500"), "unexpected: {msg}");
            assert!(msg.contains("boom"), "should carry the body: {msg}");
        }
        other => panic!("expected Source error, got {other:?}"),
    }
}

// --- Partition-fetch failures ----------------------------------------------

#[tokio::test]
async fn partition_fetch_http_error_surfaces_with_status_and_index() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "code": "090001",
            "statementHandle": "ph",
            "resultSetMetaData": metadata(2),
            "data": [["0"]],
        })))
        .mount(&server)
        .await;
    // The second partition fetch fails with 503.
    Mock::given(method("GET"))
        .and(path("/api/v2/statements/ph"))
        .and(query_param("partition", "1"))
        .respond_with(ResponseTemplate::new(503).set_body_string("unavailable"))
        .mount(&server)
        .await;

    let src = build_source(cfg(), &server);
    let err = src.fetch_all().await.unwrap_err();
    match err {
        FaucetError::Source(msg) => {
            assert!(msg.contains("partition fetch"), "unexpected: {msg}");
            assert!(msg.contains("503"), "should carry status: {msg}");
            assert!(msg.contains("partition 1"), "should name index: {msg}");
        }
        other => panic!("expected Source error, got {other:?}"),
    }
}

#[tokio::test]
async fn partition_fetch_parse_error_surfaces_as_source_error() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "code": "090001",
            "statementHandle": "ph2",
            "resultSetMetaData": metadata(2),
            "data": [["0"]],
        })))
        .mount(&server)
        .await;
    // Second partition returns 200 but a non-JSON body → parse failure.
    Mock::given(method("GET"))
        .and(path("/api/v2/statements/ph2"))
        .and(query_param("partition", "1"))
        .respond_with(ResponseTemplate::new(200).set_body_string("not-json-at-all"))
        .mount(&server)
        .await;

    let src = build_source(cfg(), &server);
    let err = src.fetch_all().await.unwrap_err();
    match err {
        FaucetError::Source(msg) => {
            assert!(
                msg.contains("failed to parse Snowflake partition response"),
                "unexpected: {msg}"
            );
            assert!(msg.contains("partition 1"), "should name index: {msg}");
        }
        other => panic!("expected Source error, got {other:?}"),
    }
}

#[tokio::test]
async fn fetch_all_missing_handle_with_multiple_partitions_errors() {
    let server = MockServer::start().await;
    // metadata claims 2 partitions but the response omits `statementHandle`,
    // so the source has no way to fetch partition 1.
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "code": "090001",
            "resultSetMetaData": metadata(2),
            "data": [["0"]],
        })))
        .mount(&server)
        .await;

    let src = build_source(cfg(), &server);
    let err = src.fetch_all().await.unwrap_err();
    match err {
        FaucetError::Source(msg) => assert!(
            msg.contains("without a statementHandle"),
            "unexpected: {msg}"
        ),
        other => panic!("expected Source error, got {other:?}"),
    }
}

#[tokio::test]
async fn stream_pages_missing_handle_with_multiple_partitions_errors() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "code": "090001",
            "resultSetMetaData": metadata(2),
            "data": [["0"]],
        })))
        .mount(&server)
        .await;

    use futures::StreamExt;
    let src = build_source(cfg(), &server);
    let ctx = HashMap::new();
    let pages: Vec<_> = src.stream_pages(&ctx, 0).collect().await;
    // The first partition's row yields a page; the missing handle then errors.
    let err = pages
        .into_iter()
        .find_map(|p| p.err())
        .expect("expected a partition-fetch error in the stream");
    match err {
        FaucetError::Source(msg) => assert!(
            msg.contains("without a statementHandle"),
            "unexpected: {msg}"
        ),
        other => panic!("expected Source error, got {other:?}"),
    }
}

// --- Key-pair JWT auth header ----------------------------------------------

// Throwaway 2048-bit RSA test key (PKCS#8). Not used anywhere real.
const TEST_RSA_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDmeSF5jD5LMGw
INB1hExU2Ux9qEQ9DXNUeWxrDv7K3QHA+UkCbdUpHDZdFSbIr/bvwlNn16Hqhqi9
b8WywAzjagZNg0cReXuQ7nKIr5c9zYl2EJe+RZTo2z2LE21HrSKRhTAmlOk3XJ1N
xc7ahYcKyw8lchuTcZaYWaNTYvronOpHUAGS0XpT0y8Oggzp1DvZNYOeZbJCPZwf
mpGGCSilnODNYnwT02Pc4aXXBzJP7TP57+ve/ZzqvsKCBiNJUMLsjUZcGWnqQHnR
A+8B87ug7CyhhEiYnskp0d1ZlWT/kU7rIZv58KMbMJidAdizA47jRjelsWeoedRf
JmiA99ZhAgMBAAECggEAAOrybwxm82xZ1k05HSwLPaStXrOQ6mZrQZy2PQRbfrEt
xm2FAa1pQCGhQauNPIjS1EopoQWafWK3XPguyclr5g9Dy05P4Y2b3lC4GdsVDxWt
TPAD/kEOU09gCQyEyT7PODaTRMMTGw7ksA47C7xvp0XPouHXrkfsqHdXNFd1DO1Z
dBCzkX4dg4Y4ffh5tt/ILeSsNlmqqpUQmHQZ/X3JHkP9/+NpAe6i4k9QKsqmLDGD
7+br/snVYbECBgmN1QIofTSvnlmmRiKgoG9wbZLmGvCiW9xVjbY+ryJs/lsLoM7w
W1TUuOlk3apoIzQ7OIGznyZzE5RumdQq11rNKB7aaQKBgQDowsceEQz2kLb93f8J
QaBDcebqbbGTJE6+hq2k8D/GzvZAdBHGuEt7NiDAFKy/GItwzJSGGdjK24iRtZ7G
2gIloZShu+7mmxX6Ojuxun8EMRZKZzTedMJWQJMwA1Hk1fwzsEM0+9+yZdTcylP9
wYDMFKbvw+av7sDcySENNEhshQKBgQDXIVX+Zvlf2PoLkRx11mk1CBtPfjqPTMcs
QVjISwvkgGSi8ihq+mwsIWLXhOZX38+L4iGfdIgqSSnwqB/fgTbjwQsa0Dqkygt6
IBfb3QmWr7196c+xss5h8eUTFiCMWw/EAa9R+jkWH0cVpJVbyTK7cBJlaXxPcXx3
xprI10qnLQKBgBl/NKajgYME6Ta3+bb+3FpnAL+PUpNmt8WBJUZbFvFlPG5lCIl3
KLWPgVjpKt8oBiZOErr529ik4bnsZj8sJG4Q3CI3Xv0d4fNuK5nVbxJ7ehCea5ku
uxcNrdHlmzPxCNZ0qXgFW0TEiOPCuh6i8sPoQz0ifYOqKLBGy/sRThmtAoGAGTd9
Hv7vCD8kwCpYTa++UUsL+HtxXc7AIf3e7Etvr28lXLxJ5JBKEbowHdckMPS5HUp6
anh8ZYiB9AWhBs/coUHFjXUPCrXsNnqAkXMNZq5e5d18TPYKnwx9r4kOc6VQ6cbQ
yCkue9tat7y9DS8+VR5D6cM9oQpKbrfG+PfTdlkCgYBf/pUWO94VgZvpV5Ui7MHb
6ZoH11q0gIhmT72FQ+2Erw977qghzs1+C7HO4Q7kNfC8sA9uVS4WiA1EzE6QeJWt
+FklEinW+AR2azgC/+gEUBvZSWU1v4meYdAQcNEek8L4VtBuGc4ZwbVbho3hiLmx
68Y3qeoKxOyBKo6j2NiZzg==
-----END PRIVATE KEY-----
";

#[tokio::test]
async fn key_pair_auth_sends_bearer_jwt_and_keypair_token_type() {
    let server = MockServer::start().await;
    // Only respond when the Authorization header is a Bearer JWT and the token
    // type is KEYPAIR_JWT. A mismatch ⇒ no match ⇒ 404 ⇒ test fails.
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .and(header(
            "X-Snowflake-Authorization-Token-Type",
            "KEYPAIR_JWT",
        ))
        .and(header_exists("Authorization"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "code": "090001",
            "statementHandle": "h",
            "resultSetMetaData": metadata(1),
            "data": [["1"]],
        })))
        .mount(&server)
        .await;

    let cfg = SnowflakeSourceConfig::new(
        "acct",
        "WH",
        "DB",
        "PUBLIC",
        SnowflakeAuth::KeyPair {
            user: "u".into(),
            private_key_pem: TEST_RSA_PKCS8_PEM.into(),
        },
        "SELECT 1",
    );
    let src = build_source(cfg, &server);
    let rows = src.fetch_all().await.unwrap();
    assert_eq!(rows.len(), 1);

    // Confirm the actual Authorization value is a `Bearer <jwt>`.
    let reqs = server.received_requests().await.unwrap();
    let auth = reqs[0]
        .headers
        .get("Authorization")
        .unwrap()
        .to_str()
        .unwrap();
    assert!(auth.starts_with("Bearer "), "auth header: {auth}");
}

// --- `faucet doctor` probe failures ----------------------------------------

#[tokio::test]
async fn check_fails_on_unresolved_auth_reference() {
    // No mock server interaction: auth resolution fails before any request.
    let server = MockServer::start().await;
    let mut config = cfg();
    config.auth = AuthSpec::Reference(faucet_core::AuthReference {
        name: "missing".into(),
    });
    let src = build_source(config, &server);

    let report = src.check(&CheckContext::default()).await.unwrap();
    let probe = &report.probes[0];
    assert_eq!(probe.name, "auth");
    match &probe.status {
        ProbeStatus::Fail { reason } => assert!(reason.contains("missing"), "reason: {reason}"),
        other => panic!("expected a Fail probe, got {other:?}"),
    }
}

#[tokio::test]
async fn check_fails_on_http_error_response() {
    let server = MockServer::start().await;
    // doctor submits `SELECT 1`; respond to it with a 403.
    Mock::given(method("POST"))
        .and(path("/api/v2/statements"))
        .and(body_partial_json(json!({"statement": "SELECT 1"})))
        .respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
        .mount(&server)
        .await;

    let src = build_source(cfg(), &server);
    let report = src.check(&CheckContext::default()).await.unwrap();
    let probe = &report.probes[0];
    assert_eq!(probe.name, "query");
    match &probe.status {
        ProbeStatus::Fail { reason } => {
            assert!(reason.contains("403"), "reason: {reason}");
            assert!(reason.contains("forbidden"), "reason: {reason}");
        }
        other => panic!("expected a Fail probe, got {other:?}"),
    }
}

#[tokio::test]
async fn check_fails_when_endpoint_unreachable() {
    // Point at a closed port so the request layer (`reqwest::send`) fails.
    let src = SnowflakeSource::new(cfg())
        .unwrap()
        .with_endpoint_base("http://127.0.0.1:1");
    let report = src.check(&CheckContext::default()).await.unwrap();
    let probe = &report.probes[0];
    assert_eq!(probe.name, "query");
    match &probe.status {
        ProbeStatus::Fail { reason } => {
            assert!(
                reason.contains("Snowflake request failed"),
                "reason: {reason}"
            )
        }
        other => panic!("expected a Fail probe, got {other:?}"),
    }
}