#![allow(clippy::unwrap_used, clippy::print_stderr)]
use std::collections::BTreeSet;
use fraiseql_cli::schema::database_validator::{
create_introspector, find_unbacked_sources as cli_find_unbacked,
};
use fraiseql_core::{
db::postgres::PostgresAdapter,
schema::{CompiledSchema, MutationDefinition, QueryDefinition, SourceProbe},
};
use fraiseql_server::sql_source_check::find_unbacked_sources as server_find_unbacked;
use fraiseql_test_utils::try_database_url;
use tokio_postgres::NoTls;
const SETUP: &str = "\
DROP SCHEMA IF EXISTS fql_487_test CASCADE;
CREATE SCHEMA fql_487_test;
CREATE VIEW fql_487_test.v_orders AS SELECT '{}'::jsonb AS data;
CREATE FUNCTION fql_487_test.fn_create_order(p_input jsonb)
RETURNS jsonb LANGUAGE sql AS $$ SELECT p_input $$;
";
const TEARDOWN: &str = "DROP SCHEMA IF EXISTS fql_487_test CASCADE;";
async fn run_sql(url: &str, sql: &str) {
let (client, connection) = tokio_postgres::connect(url, NoTls).await.unwrap();
tokio::spawn(async move {
let _ = connection.await;
});
client.batch_execute(sql).await.unwrap();
}
fn query(name: &str, sql_source: &str) -> QueryDefinition {
QueryDefinition::new(name, "T").with_sql_source(sql_source).returning_list()
}
fn mutation(name: &str, sql_source: &str) -> MutationDefinition {
let mut m = MutationDefinition::new(name, "T");
m.sql_source = Some(sql_source.to_string());
m
}
fn names(probes: &[SourceProbe]) -> BTreeSet<String> {
probes.iter().map(SourceProbe::display_name).collect()
}
fn fully_backed_schema() -> CompiledSchema {
CompiledSchema {
queries: vec![query("orders", "fql_487_test.v_orders")],
mutations: vec![mutation("createOrder", "fql_487_test.fn_create_order")],
..Default::default()
}
}
fn schema_with_two_missing() -> CompiledSchema {
CompiledSchema {
queries: vec![
query("orders", "fql_487_test.v_orders"),
query("missing", "fql_487_test.v_missing"),
],
mutations: vec![
mutation("createOrder", "fql_487_test.fn_create_order"),
mutation("absent", "fql_487_test.fn_absent"),
],
..Default::default()
}
}
#[tokio::test]
async fn boot_check_passes_when_all_sources_backed() {
let Some(url) = try_database_url() else {
eprintln!("skipping #487 boot-check test: no DATABASE_URL");
return;
};
run_sql(&url, SETUP).await;
let adapter = PostgresAdapter::new(&url).await.unwrap();
let unbacked = server_find_unbacked(&fully_backed_schema(), &adapter).await.unwrap();
assert!(unbacked.is_empty(), "fully-backed schema must boot clean, got {unbacked:?}");
run_sql(&url, TEARDOWN).await;
}
#[tokio::test]
async fn boot_check_lists_each_unbacked_source() {
let Some(url) = try_database_url() else {
eprintln!("skipping #487 boot-check test: no DATABASE_URL");
return;
};
run_sql(&url, SETUP).await;
let adapter = PostgresAdapter::new(&url).await.unwrap();
let unbacked = server_find_unbacked(&schema_with_two_missing(), &adapter).await.unwrap();
assert_eq!(
names(&unbacked),
BTreeSet::from([
"fql_487_test.v_missing".to_string(),
"fql_487_test.fn_absent".to_string(),
]),
"exactly the missing view + function must be reported",
);
run_sql(&url, TEARDOWN).await;
}
#[tokio::test]
async fn server_and_cli_agree_on_unbacked_set() {
let Some(url) = try_database_url() else {
eprintln!("skipping #487 symmetry test: no DATABASE_URL");
return;
};
run_sql(&url, SETUP).await;
let schema = schema_with_two_missing();
let adapter = PostgresAdapter::new(&url).await.unwrap();
let server_set = names(&server_find_unbacked(&schema, &adapter).await.unwrap());
let introspector =
create_introspector(&url, &fraiseql_core::db::postgres::PostgresTlsConfig::default())
.await
.unwrap();
let cli_set = names(&cli_find_unbacked(&schema, &introspector).await.unwrap());
assert_eq!(server_set, cli_set, "server boot check and CLI gate must agree on 'backed'");
run_sql(&url, TEARDOWN).await;
}
const ROLE: &str = "fql_1426_api";
const PRIVILEGE_SETUP: &str = "\
DROP SCHEMA IF EXISTS fql_1426_test CASCADE;
DROP SCHEMA IF EXISTS fql_1426_hidden CASCADE;
DROP ROLE IF EXISTS fql_1426_api;
CREATE ROLE fql_1426_api LOGIN PASSWORD 'fql_1426_api';
CREATE SCHEMA fql_1426_test;
GRANT USAGE ON SCHEMA fql_1426_test TO fql_1426_api;
CREATE VIEW fql_1426_test.v_granted AS SELECT '{}'::jsonb AS data;
GRANT SELECT ON fql_1426_test.v_granted TO fql_1426_api;
CREATE VIEW fql_1426_test.v_ungranted AS SELECT '{}'::jsonb AS data;
CREATE FUNCTION fql_1426_test.fn_granted(p_input jsonb)
RETURNS jsonb LANGUAGE sql AS $$ SELECT p_input $$;
CREATE FUNCTION fql_1426_test.fn_revoked(p_input jsonb)
RETURNS jsonb LANGUAGE sql AS $$ SELECT p_input $$;
REVOKE EXECUTE ON FUNCTION fql_1426_test.fn_revoked(jsonb) FROM PUBLIC;
CREATE SCHEMA fql_1426_hidden;
CREATE VIEW fql_1426_hidden.v_orders AS SELECT '{}'::jsonb AS data;
";
const PRIVILEGE_TEARDOWN: &str = "\
DROP SCHEMA IF EXISTS fql_1426_test CASCADE;
DROP SCHEMA IF EXISTS fql_1426_hidden CASCADE;
DROP ROLE IF EXISTS fql_1426_api;
";
fn as_role(url: &str) -> String {
let (scheme, rest) = url.split_once("://").unwrap();
let host = rest.rsplit_once('@').map_or(rest, |(_, host)| host);
format!("{scheme}://{ROLE}:{ROLE}@{host}")
}
fn schema_with_privilege_gaps() -> CompiledSchema {
CompiledSchema {
queries: vec![
query("granted", "fql_1426_test.v_granted"),
query("ungranted", "fql_1426_test.v_ungranted"),
query("hidden", "fql_1426_hidden.v_orders"),
],
mutations: vec![
mutation("granted", "fql_1426_test.fn_granted"),
mutation("revoked", "fql_1426_test.fn_revoked"),
],
..Default::default()
}
}
#[tokio::test]
async fn boot_check_reports_each_source_the_role_may_not_use() {
let Some(url) = try_database_url() else {
eprintln!("skipping #1426 boot-check test: no DATABASE_URL");
return;
};
run_sql(&url, PRIVILEGE_SETUP).await;
let adapter = PostgresAdapter::new(&as_role(&url)).await.unwrap();
let schema = schema_with_privilege_gaps();
let unbacked = server_find_unbacked(&schema, &adapter).await;
let unusable =
fraiseql_server::sql_source_check::find_unusable_sources(&schema, &adapter).await;
run_sql(&url, PRIVILEGE_TEARDOWN).await;
assert!(unbacked.unwrap().is_empty(), "every source exists");
let unusable: BTreeSet<(String, String)> = unusable
.unwrap()
.into_iter()
.map(|(probe, missing)| (probe.display_name(), missing))
.collect();
assert_eq!(
unusable,
BTreeSet::from([
("fql_1426_test.v_ungranted".to_string(), "SELECT".to_string()),
("fql_1426_test.fn_revoked".to_string(), "EXECUTE".to_string()),
(
"fql_1426_hidden.v_orders".to_string(),
"USAGE on schema fql_1426_hidden".to_string()
),
])
);
}
#[test]
fn the_boot_message_names_the_role_and_each_missing_privilege() {
use fraiseql_core::schema::SourceKind;
let probe = |kind, name: &str| SourceProbe {
kind,
schema: Some("app".to_string()),
name: name.to_string(),
};
let message = fraiseql_server::sql_source_check::format_source_problems(
&[probe(SourceKind::Relation, "v_missing")],
&[(probe(SourceKind::Function, "create_order"), "EXECUTE".to_string())],
"api_role",
);
assert!(message.contains("app.v_missing (relation) does not exist"), "{message}");
assert!(
message.contains("app.create_order (function): EXECUTE not granted to api_role"),
"{message}"
);
}