fraiseql-server 2.17.1

HTTP server for FraiseQL v2 GraphQL engine
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
//! H16 — server constructor drift regression guards.
//!
//! Every public `Server` constructor that boots an executor must route through
//! the single `RuntimeConfig::from_compiled_schema` seam, so it cannot skip the
//! schema-format-version validation (or the audit / page-size / change-log
//! config) the way `with_relay_pagination` and `with_flight_service` previously
//! did by building the executor with `RuntimeConfig::default()`.
//!
//! These tests assert the headline regression: an incompatible compiled-schema
//! format version is now refused by *every* constructor, not just `Server::new`.
//!
//! **Execution engine:** in-memory (no database required)
//! **Infrastructure:** none
//! **Parallelism:** safe
#![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
#![allow(clippy::missing_panics_doc)] // Reason: test functions, panics are expected
#![allow(clippy::unimplemented)] // Reason: the relay query stub is never invoked by these tests
#![allow(missing_docs)] // Reason: test code does not require documentation
#![allow(clippy::panic)] // Reason: test code, panics are the failure mechanism

use std::{collections::HashMap, sync::Arc};

use async_trait::async_trait;
use fraiseql_core::{
    db::{
        DatabaseAdapter, DatabaseType, WhereClause,
        traits::{RelayCursor, RelayDatabaseAdapter, RelayPageResult},
        types::{JsonbValue, OrderByClause, PoolMetrics},
    },
    error::Result as FraiseQLResult,
    schema::{CompiledSchema, SecurityConfig, SqlProjectionHint, TenancyConfig, TenancyMode},
};
use fraiseql_server::{Server, server_config::ServerConfig};

/// A relay-capable no-op adapter. Its query methods are never invoked by these
/// tests — construction either fails fast on the format-version check or builds
/// the server without running a query — so the bodies are trivial/unimplemented.
#[derive(Debug, Clone)]
struct NoopRelayAdapter;

#[async_trait]
impl DatabaseAdapter for NoopRelayAdapter {
    // A test double: the session variables a read carries are accepted (#1115).
    fn applies_session_variables(&self) -> bool {
        true
    }

    async fn execute_where_query(
        &self,
        _view: &str,
        _where_clause: Option<&WhereClause>,
        _limit: Option<u32>,
        _offset: Option<u32>,
        _order_by: Option<&[OrderByClause]>,
    ) -> FraiseQLResult<Vec<JsonbValue>> {
        Ok(vec![])
    }

    async fn execute_with_projection(
        &self,
        _view: &str,
        _projection: Option<&SqlProjectionHint>,
        _where_clause: Option<&WhereClause>,
        _limit: Option<u32>,
        _offset: Option<u32>,
        _order_by: Option<&[OrderByClause]>,
    ) -> FraiseQLResult<Vec<JsonbValue>> {
        Ok(vec![])
    }

    fn database_type(&self) -> DatabaseType {
        DatabaseType::PostgreSQL
    }

    async fn health_check(&self) -> FraiseQLResult<()> {
        Ok(())
    }

    fn pool_metrics(&self) -> PoolMetrics {
        PoolMetrics::default()
    }

    async fn execute_raw_query(
        &self,
        _sql: &str,
    ) -> FraiseQLResult<Vec<HashMap<String, serde_json::Value>>> {
        Ok(vec![])
    }

    async fn execute_parameterized_aggregate(
        &self,
        _sql: &str,
        _params: &[serde_json::Value],
    ) -> FraiseQLResult<Vec<HashMap<String, serde_json::Value>>> {
        Ok(vec![])
    }
}

// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
#[async_trait::async_trait]
impl fraiseql_core::db::traits::Writer for NoopRelayAdapter {
    async fn execute_write(
        &self,
        request: &fraiseql_core::db::traits::WriteRequest<'_>,
        gate: fraiseql_core::db::traits::MutationRowGate<'_>,
    ) -> std::result::Result<
        Vec<std::collections::HashMap<String, serde_json::Value>>,
        fraiseql_core::error::FraiseQLError,
    > {
        let _ = (request, gate);
        Err(fraiseql_core::error::FraiseQLError::Unsupported {
            message: "this test double does not write".to_string(),
        })
    }
}

// `RelayDatabaseAdapter` is a native RPIT async trait (no `#[async_trait]`).
impl RelayDatabaseAdapter for NoopRelayAdapter {
    #[allow(clippy::too_many_arguments)] // Reason: mirrors the trait's full cursor/filter/sort signature
    async fn execute_relay_page(
        &self,
        _view: &str,
        _cursor_column: &str,
        _after: Option<RelayCursor>,
        _before: Option<RelayCursor>,
        _limit: u32,
        _forward: bool,
        _where_clause: Option<&WhereClause>,
        _order_by: Option<&[OrderByClause]>,
        _include_total_count: bool,
    ) -> FraiseQLResult<RelayPageResult> {
        unimplemented!("relay queries are never executed in constructor-drift tests")
    }
}

/// An artifact produced by another fraiseql build (#1304). Every constructor
/// must refuse it, because this runtime reads fields that build never wrote and
/// reads their absence as a setting.
fn incompatible_schema() -> CompiledSchema {
    let mut schema = CompiledSchema::new();
    schema.fraiseql_version = serde_json::from_value(serde_json::json!("2.14.0")).unwrap();
    schema
}

/// This build's own — what `CompiledSchema::new()` is by construction.
fn current_schema() -> CompiledSchema {
    CompiledSchema::new()
}

/// A schema that declares row-level tenancy and nothing else. With caching on,
/// every constructor that builds a result cache must refuse it: cache keys carry no
/// tenant, so without RLS two tenants issuing the same query share one entry.
fn multi_tenant_schema_without_rls() -> CompiledSchema {
    let mut schema = current_schema();
    schema.security = Some(SecurityConfig {
        tenancy: TenancyConfig {
            mode: TenancyMode::Row,
            ..TenancyConfig::default()
        },
        ..SecurityConfig::default()
    });
    schema
}

fn caching_config() -> ServerConfig {
    ServerConfig {
        cache_enabled: true,
        // #874: production validate() refuses cors_enabled=true + empty origins
        cors_enabled: false,
        ..ServerConfig::default()
    }
}

#[tokio::test]
async fn server_new_refuses_incompatible_schema_format() {
    let config = ServerConfig {
        cache_enabled: false,
        // #874: production validate() refuses cors_enabled=true + empty origins
        cors_enabled: false,
        ..ServerConfig::default()
    };
    let result = Server::new(config, incompatible_schema(), Arc::new(NoopRelayAdapter), None).await;
    assert!(
        result.is_err(),
        "Server::new must refuse an incompatible compiled-schema version"
    );
}

#[tokio::test]
async fn with_relay_pagination_refuses_incompatible_schema_format() {
    // H16 regression: the relay constructor previously built the executor with
    // RuntimeConfig::default() and so never validated the schema format version.
    let config = ServerConfig {
        cache_enabled: false,
        // #874: production validate() refuses cors_enabled=true + empty origins
        cors_enabled: false,
        ..ServerConfig::default()
    };
    let result = Server::with_relay_pagination(
        config,
        incompatible_schema(),
        Arc::new(NoopRelayAdapter),
        None,
    )
    .await;
    assert!(
        result.is_err(),
        "with_relay_pagination must refuse an incompatible compiled-schema version (H16)"
    );
}

#[tokio::test]
async fn with_relay_pagination_accepts_current_schema_format() {
    // Guard against over-rejection: a current-version schema still boots.
    let config = ServerConfig {
        cache_enabled: false,
        // #874: production validate() refuses cors_enabled=true + empty origins
        cors_enabled: false,
        ..ServerConfig::default()
    };
    let result =
        Server::with_relay_pagination(config, current_schema(), Arc::new(NoopRelayAdapter), None)
            .await;
    assert!(result.is_ok(), "with_relay_pagination must accept a current-version schema");
}

#[cfg(feature = "arrow")]
#[tokio::test]
async fn with_flight_service_refuses_incompatible_schema_format() {
    // H16 regression: the Arrow Flight constructor likewise used
    // RuntimeConfig::default() and skipped format-version validation.
    let config = ServerConfig {
        cache_enabled: false,
        // #874: production validate() refuses cors_enabled=true + empty origins
        cors_enabled: false,
        ..ServerConfig::default()
    };
    let result = Server::with_flight_service(
        config,
        incompatible_schema(),
        Arc::new(NoopRelayAdapter),
        None,
        None,
    )
    .await;
    assert!(
        result.is_err(),
        "with_flight_service must refuse an incompatible compiled-schema version (H16)"
    );
}

// ── #758: the multi-tenant cache gate must fire from every constructor ────────
//
// This gate existed inline in `Server::new` and `with_relay_pagination` and was
// absent from `with_flight_service`. The drift was invisible because nothing could
// set `security.multi_tenant`, so `is_multi_tenant()` was false everywhere and all
// three constructors agreed by accident. Giving it a producer makes the difference
// real, so the check now lives in one place and each constructor is pinned here.

#[tokio::test]
async fn server_new_refuses_multi_tenant_caching_without_rls() {
    let result = Server::new(
        caching_config(),
        multi_tenant_schema_without_rls(),
        Arc::new(NoopRelayAdapter),
        None,
    )
    .await;
    let Err(e) = result else {
        panic!("#758: Server::new must refuse multi-tenant + cache + no RLS");
    };
    let msg = e.to_string();
    assert!(msg.contains("multi-tenant"), "the refusal must say why: {msg}");
    assert!(
        msg.contains("[security.rls]"),
        "the refusal must name a knob that exists: {msg}"
    );
}

#[tokio::test]
async fn with_relay_pagination_refuses_multi_tenant_caching_without_rls() {
    let result = Server::with_relay_pagination(
        caching_config(),
        multi_tenant_schema_without_rls(),
        Arc::new(NoopRelayAdapter),
        None,
    )
    .await;
    assert!(
        result.is_err(),
        "#758: with_relay_pagination must refuse multi-tenant + cache + no RLS"
    );
}

/// Counterweight: the gate must not refuse a single-tenant schema, or it would
/// break every non-tenanted deployment while proving nothing about isolation.
#[tokio::test]
async fn a_single_tenant_schema_still_boots_with_caching() {
    let result =
        Server::new(caching_config(), current_schema(), Arc::new(NoopRelayAdapter), None).await;
    assert!(result.is_ok(), "single-tenant + cache must still boot: {:?}", result.err());
}

/// Counterweight: declaring RLS lets a multi-tenant schema boot. The live catalog
/// verification that follows the declaration is exercised against a real database
/// in `cache_rls_isolation_test`; this adapter answers no rows, which the check
/// treats as "relation does not exist" — so the declaration path is asserted here
/// only up to the point where it hands off.
#[tokio::test]
async fn declaring_rls_changes_the_outcome() {
    let mut schema = multi_tenant_schema_without_rls();
    if let Some(security) = schema.security.as_mut() {
        security.rls.enabled = true;
    }
    let refused_without = Server::new(
        caching_config(),
        multi_tenant_schema_without_rls(),
        Arc::new(NoopRelayAdapter),
        None,
    )
    .await
    .is_err();
    assert!(refused_without, "precondition: the undeclared schema is refused");

    // With RLS declared the *declaration* gate passes; the schema below has no
    // queries, so the live check has no relation to inspect and also passes.
    let result = Server::new(caching_config(), schema, Arc::new(NoopRelayAdapter), None).await;
    assert!(
        result.is_ok(),
        "declaring [security.rls] must change the outcome: {:?}",
        result.err()
    );
}

// ── #910: an unattributable mutation must not boot beside a cacheable view ────
//
// `fraiseql compile` refuses this shape, so this is the backstop for a
// hand-authored `schema.compiled.json` or one from an older CLI. The consequence
// it guards is silent and permanent: a successful mutation whose views resolve to
// nothing leaves every entry for a `cache_ttl_seconds = 0` view warm forever.

/// A schema with one cacheable view and one mutation that resolves to no view —
/// an unbacked payload, no `entity` field, no `invalidates_views`.
fn unattributable_mutation_schema() -> CompiledSchema {
    use fraiseql_core::schema::{MutationDefinition, QueryDefinition, TypeDefinition};

    let mut schema = current_schema();
    schema.types.push(TypeDefinition::new("Price", "v_price"));
    schema.types.push(TypeDefinition::new("RebuildResult", ""));

    let mut query = QueryDefinition::new("prices", "Price");
    query.sql_source = Some("v_price".to_string());
    query.cache_ttl_seconds = Some(0);
    schema.queries.push(query);

    schema
        .mutations
        .push(MutationDefinition::new("rebuildPricing", "RebuildResult"));
    schema
}

#[tokio::test]
async fn an_unattributable_mutation_refuses_to_boot_with_the_cache_on() {
    let err = Server::new(
        caching_config(),
        unattributable_mutation_schema(),
        Arc::new(NoopRelayAdapter),
        None,
    )
    .await
    .err()
    .expect("a mutation that resolves to no view must refuse to boot with caching on (#910)");
    let msg = err.to_string();
    assert!(
        msg.contains("rebuildPricing") && msg.contains("invalidates_views"),
        "the refusal must name the mutation and the fix; got: {msg}"
    );
}

#[tokio::test]
async fn the_same_schema_boots_with_the_cache_off() {
    // Guard against over-refusal: with no result cache there is no entry to strand.
    let config = ServerConfig {
        cache_enabled: false,
        cors_enabled: false,
        ..ServerConfig::default()
    };
    let result =
        Server::new(config, unattributable_mutation_schema(), Arc::new(NoopRelayAdapter), None)
            .await;
    assert!(result.is_ok(), "cache_enabled = false must still boot: {:?}", result.err());
}

#[tokio::test]
async fn declaring_invalidates_views_lets_it_boot() {
    let mut schema = unattributable_mutation_schema();
    schema.mutations[0].invalidates_views = vec!["v_price".to_string()];
    let result = Server::new(caching_config(), schema, Arc::new(NoopRelayAdapter), None).await;
    assert!(
        result.is_ok(),
        "declaring what the mutation writes must change the outcome: {:?}",
        result.err()
    );
}