mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
* feat: cap user token expiration with an instance setting Adds `max_token_expiration_days`, an instance-wide ceiling on how far ahead a token created through `POST /users/tokens/create` may expire. With it set, that route refuses a token with no expiration and one that expires past the window; absent or non-positive, nothing changes. Only the user-facing handler enforces it. Server-side mints (native trigger webhook tokens, app embed tokens, sessions) pick a lifetime the caller never chooses and go straight to `create_token_internal`, so they stay uncapped, as does the superadmin `impersonate` route. Service accounts are exempt, in the workspace the token targets or in any workspace for a global token, so unattended automation can keep longer-lived credentials. The token form now surfaces the API error instead of only logging it, and offers "Expires In" in MCP mode as well: that mode always sent no expiration, which the cap refuses, leaving MCP URLs impossible to generate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: shorten over-long token expirations instead of refusing them Refusing a non-compliant request breaks the callers that cannot comply. The CLI authorization page, `wmill user create-token` and the editor's language-server token each pick a lifetime — usually none at all — with no way to read the setting, so a cap made browser login hang and the editor lose its LSP root rather than stopping the long-lived tokens the setting is aimed at. `cap_token_expiration` now returns the expiration to store, shortening a request that asks for too long or for none. The policy still holds absolutely, no caller can break, and there is no clock-skew boundary where an expiration exactly at the ceiling flips to an error. The token form needed no changes at all, so its MCP and error-toast edits are gone with it. Also drops the Enterprise badge on the setting, which nothing enforced, notes the mint paths in docs/auth-surface.md, and pins that `tokens/impersonate` and the second-workspace case stay outside the exemption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: drop the unintended token-form change and correct the exemption docs The token form needed no change once the ceiling shortens rather than refuses, but the earlier revert restored from the index, which already held the staged edit, so the MCP expiration field and the error toast stayed on the branch with a comment justifying them by a refusal that no longer happens. docs/auth-surface.md claimed a service-account row in any workspace exempts outright; that only holds for a workspace-less token, which has no workspace to match. A ceiling written as a string, which the YAML instance config and config sync can both produce, now has a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: offer only expirations within the ceiling in the token form The server shortens a token that asks for longer than `max_token_expiration_days` or for no expiration, which the token form could not tell anyone: a user picking "No expiration" got the ceiling silently. The form now reads the setting and, with one set, drops "No expiration" and every choice above it, adds the ceiling itself as "N days (maximum)" and selects it, and says the instance limits tokens to N days. MCP mode hides the expiration field and always sent none, so with a ceiling the field now shows there too and keeps its value across the toggle. Without a ceiling the form is unchanged. Reading it needs no superadmin: the setting joins the keys any logged-in user can read through `GET /settings/global/{key}`. It holds a policy, not a secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: make the token form and the server agree on what counts as a ceiling The form parsed `max_token_expiration_days` more loosely than `cap_token_expiration`, so the two could disagree on whether a ceiling exists at all. A `7.0` from the YAML instance config, or a string such as "7.0" or "1e1", made the form hide "No expiration" and announce a 7-day limit while the server capped nothing; a value between chrono's and JavaScript's date limits preselected an expiration the server could not parse. Both now read the same thing as a ceiling: a whole number of days from 1 to 1,000,000, stored as an integer, an integral float or a string of digits. `parseMaxTokenExpirationDays` holds the frontend's copy, and the instance settings validation uses it too, so the settings page no longer accepts a value the server would ignore. The bound replaces the date-range guard on both sides. Also corrects the rationale for shortening rather than refusing: the setting is now readable by any logged-in user, so those callers do not read it rather than cannot, and CLIs already installed never will. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: cap service-account tokens like everyone else's The ticket exempted service accounts from `max_token_expiration_days`, but their tokens are the long-lived ones a rotation policy is meant to bound, and the exemption let any workspace admin get an uncapped token by impersonating one. It also left the token form unable to agree with the server: an admin impersonating a service account was offered only capped choices while the server would have kept any. `cap_token_expiration` now takes just the requested expiration, with no per-caller lookup, and the service-account query and its cache entry are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: cap superadmin impersonation tokens and pin the frontend parser `POST /users/tokens/impersonate` wrote its own token row with whatever expiration the superadmin sent, so it was the one route left that could mint a token that never expires with `max_token_expiration_days` set. The ceiling only decides the stored expiration (the auth lookup never reads the setting), so leaving it uncapped meant exactly that. It now goes through `cap_token_expiration` like `create_token`; nothing in Windmill calls it, so no caller changes. Also adds `tokenExpiration.test.ts`, pinning which stored values `parseMaxTokenExpirationDays` reads as a ceiling against the server's reading, and documents that tokens existing when the setting is turned on or lowered keep their expiration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reject a max_token_expiration_days the token routes cannot read The settings API and config sync stored any value for the key, and the token routes can only read an unparseable one as no ceiling. A typo such as `7.5` or "7.0" was accepted and silently turned the policy off. `parse_max_token_expiration_days` in windmill-common is now the single server reading of the setting: null or empty clears it, a whole number of days within the bound is the ceiling, anything else is an error. The settings write hook and `sync_global_settings_declarative` reject that error, and `cap_token_expiration` reads through the same function, logging a value written around both. Tests: the parser's accept/clear/reject table (the same table as the frontend parser's), the settings API refusing 7.5, and config sync refusing "7.0". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reserve the CLI login token label so its expiry does not email the user With a token expiration ceiling, the token the CLI authorization page mints now expires, so every `wmill` login earned an "expiring soon" and an "expired and deleted" email and critical alert. The CLI already signs in again on its own when that token stops working, so those notifications ask the user to do nothing. The page now labels it `cli-login:<username>` (previously `cli-<username>`), reserved in `is_user_token` and its SQL and Svelte mirrors: no expiry notifications, and the label cannot be edited. A colon-terminated namespace like `embed_app:` and `impersonation:` keeps hand-made labels clear of it. Not in `is_server_minted_label`, since the page mints through `/users/tokens/create`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: skip the expiring-soon warning for tokens that were short-lived from the start A token whose whole lifetime fits in the 7-day warning window got its "expiring soon" email (and critical alert, when enabled) minutes after it was created, about a lifetime its creator had just picked. With an expiration ceiling of 7 days or less that is every token created from the form or `wmill token create`. `register_token_expiry_notification` no longer queues a warning for such a token. The window is now `TOKEN_EXPIRY_WARNING_DAYS`, shared with `check_expiring_tokens`, so shortening the warning window can never leave tokens of an intermediate lifetime with no warning at all. The "expired and deleted" notice still goes out for every user token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: exempt service-account tokens from the expiration ceiling again Service accounts are the identity automation that needs a long-lived credential runs as, so their tokens are exempt from `max_token_expiration_days` once more: a service account in the workspace the token names, or in any workspace for a workspace-less token. `tokens/impersonate` checks the impersonated account, so a superadmin minting a token for a service account gets the same exemption. The token form applies the same rule for the account it is running as, when that account is a service account in the current workspace, which is what an admin impersonating one sees; otherwise it would offer only capped choices while the server keeps any. Any workspace admin can create and impersonate a service account to hold an uncapped token, so the ceiling bounds personal tokens; the doc comment and docs/auth-surface.md say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: decide the token form's service-account exemption from the token's workspace The form treated the account as a service account only when it was one in the workspace the app was on, while the server checks the workspace the token is for, or any workspace for a workspace-less token. With an email that is a service account in one workspace and an ordinary member of another, picking the other workspace in MCP mode offered "No expiration" and the server silently stored the ceiling; the reverse hid the exemption. `GET /workspaces/users` now returns each membership's `is_service_account` (its query already joins the `usr` row), and the form applies the server's rule to the token's own workspace. The selection becomes a derived value held within the ceiling, so switching to a capped workspace never leaves an unoffered choice selected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1611 lines
54 KiB
Rust
1611 lines
54 KiB
Rust
/*!
|
|
* Integration tests for windmill-common instance_config module.
|
|
*
|
|
* Tests verify the DB-level operations:
|
|
* - `InstanceConfig::from_db()` reads global_settings + worker configs
|
|
* - `apply_settings_diff()` applies upserts and deletes to global_settings
|
|
* - `apply_configs_diff()` applies upserts and deletes to config table
|
|
* - Full roundtrip: write → read → modify → diff → apply → read → verify
|
|
*
|
|
* Note: the test DB is created from migrations which seed default settings
|
|
* and worker configs. Tests either clean up first or assert on specific keys
|
|
* rather than exact counts.
|
|
*/
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_common::instance_config::{
|
|
apply_configs_diff, apply_settings_diff, diff_global_settings, diff_worker_configs, ApplyMode,
|
|
ConfigsDiff, InstanceConfig, SettingsDiff,
|
|
};
|
|
|
|
// ========================================================================
|
|
// Helpers
|
|
// ========================================================================
|
|
|
|
async fn get_global_setting(db: &Pool<Postgres>, name: &str) -> Option<serde_json::Value> {
|
|
sqlx::query_as::<_, (serde_json::Value,)>("SELECT value FROM global_settings WHERE name = $1")
|
|
.bind(name)
|
|
.fetch_optional(db)
|
|
.await
|
|
.expect("query should succeed")
|
|
.map(|(v,)| v)
|
|
}
|
|
|
|
async fn get_config(db: &Pool<Postgres>, name: &str) -> Option<serde_json::Value> {
|
|
sqlx::query_as::<_, (serde_json::Value,)>("SELECT config FROM config WHERE name = $1")
|
|
.bind(name)
|
|
.fetch_optional(db)
|
|
.await
|
|
.expect("query should succeed")
|
|
.map(|(v,)| v)
|
|
}
|
|
|
|
async fn insert_global_setting(db: &Pool<Postgres>, name: &str, value: serde_json::Value) {
|
|
sqlx::query(
|
|
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
|
|
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
|
)
|
|
.bind(name)
|
|
.bind(&value)
|
|
.execute(db)
|
|
.await
|
|
.expect("insert should succeed");
|
|
}
|
|
|
|
async fn insert_config(db: &Pool<Postgres>, name: &str, config: serde_json::Value) {
|
|
sqlx::query(
|
|
"INSERT INTO config (name, config) VALUES ($1, $2) \
|
|
ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config",
|
|
)
|
|
.bind(name)
|
|
.bind(&config)
|
|
.execute(db)
|
|
.await
|
|
.expect("insert should succeed");
|
|
}
|
|
|
|
async fn count_global_settings(db: &Pool<Postgres>) -> i64 {
|
|
sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM global_settings")
|
|
.fetch_one(db)
|
|
.await
|
|
.expect("count query should succeed")
|
|
.0
|
|
}
|
|
|
|
/// Clear all migration-seeded data so tests start from a clean slate.
|
|
async fn clear_settings_and_configs(db: &Pool<Postgres>) {
|
|
sqlx::query("DELETE FROM global_settings")
|
|
.execute(db)
|
|
.await
|
|
.expect("clear global_settings should succeed");
|
|
sqlx::query("DELETE FROM config WHERE name LIKE 'worker__%'")
|
|
.execute(db)
|
|
.await
|
|
.expect("clear worker configs should succeed");
|
|
}
|
|
|
|
// ========================================================================
|
|
// InstanceConfig::from_db() tests
|
|
// ========================================================================
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_empty(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
let config = InstanceConfig::from_db(&db)
|
|
.await
|
|
.expect("from_db should succeed on empty DB");
|
|
assert!(config.global_settings.base_url.is_none());
|
|
assert!(config.global_settings.license_key.is_none());
|
|
assert!(config.worker_configs.is_empty());
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_with_typed_global_settings(db: Pool<Postgres>) {
|
|
insert_global_setting(&db, "base_url", serde_json::json!("https://windmill.test")).await;
|
|
insert_global_setting(&db, "retention_period_secs", serde_json::json!(86400)).await;
|
|
insert_global_setting(&db, "expose_metrics", serde_json::json!(true)).await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config.global_settings.base_url.as_deref(),
|
|
Some("https://windmill.test")
|
|
);
|
|
assert_eq!(config.global_settings.retention_period_secs, Some(86400));
|
|
assert_eq!(config.global_settings.expose_metrics, Some(true));
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_with_structured_settings(db: Pool<Postgres>) {
|
|
insert_global_setting(
|
|
&db,
|
|
"smtp_settings",
|
|
serde_json::json!({
|
|
"smtp_host": "mail.test.com",
|
|
"smtp_port": 587,
|
|
"smtp_tls_implicit": false
|
|
}),
|
|
)
|
|
.await;
|
|
insert_global_setting(
|
|
&db,
|
|
"otel",
|
|
serde_json::json!({
|
|
"metrics_enabled": true,
|
|
"logs_enabled": false,
|
|
"otel_exporter_otlp_endpoint": "http://otel:4317"
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
|
|
let smtp = config.global_settings.smtp_settings.as_ref().unwrap();
|
|
assert_eq!(smtp.smtp_host.as_deref(), Some("mail.test.com"));
|
|
assert_eq!(smtp.smtp_port, Some(587));
|
|
assert_eq!(smtp.smtp_tls_implicit, Some(false));
|
|
|
|
let otel = config.global_settings.otel.as_ref().unwrap();
|
|
assert_eq!(otel.metrics_enabled, Some(true));
|
|
assert_eq!(otel.logs_enabled, Some(false));
|
|
assert_eq!(
|
|
otel.otel_exporter_otlp_endpoint.as_deref(),
|
|
Some("http://otel:4317")
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_unknown_settings_go_to_extra(db: Pool<Postgres>) {
|
|
insert_global_setting(&db, "future_setting_xyz", serde_json::json!({"nested": 42})).await;
|
|
insert_global_setting(&db, "another_unknown", serde_json::json!("hello")).await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config.global_settings.extra["future_setting_xyz"],
|
|
serde_json::json!({"nested": 42})
|
|
);
|
|
assert_eq!(
|
|
config.global_settings.extra["another_unknown"],
|
|
serde_json::json!("hello")
|
|
);
|
|
}
|
|
|
|
/// Regression: a legacy `global_settings.worker_configs` row must not leak
|
|
/// into `GlobalSettings::extra`. Older Windmill versions stored worker configs
|
|
/// as a single blob in `global_settings`; the row could silently drift from
|
|
/// the real `config WHERE name LIKE 'worker__%'` rows and get resurrected on
|
|
/// every bulk InstanceSettings save via the flatten+extra round-trip.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_hides_legacy_worker_configs_ghost_row(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
insert_global_setting(
|
|
&db,
|
|
"worker_configs",
|
|
serde_json::json!({
|
|
"default": {"worker_tags": ["deno", "python3"]},
|
|
"sldc-standard": {"worker_tags": ["sldc-standard"]}
|
|
}),
|
|
)
|
|
.await;
|
|
insert_config(&db, "worker__sldc-standard", serde_json::json!({})).await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert!(
|
|
!config.global_settings.extra.contains_key("worker_configs"),
|
|
"legacy worker_configs row must be filtered out of GlobalSettings::extra"
|
|
);
|
|
assert!(
|
|
!config
|
|
.global_settings
|
|
.to_settings_map()
|
|
.contains_key("worker_configs"),
|
|
"legacy worker_configs row must not re-appear in to_settings_map()"
|
|
);
|
|
// The real config-table row is still read normally.
|
|
assert!(config.worker_configs.contains_key("sldc-standard"));
|
|
}
|
|
|
|
/// Regression: a bulk `diff_global_settings` desired map that contains an
|
|
/// empty-string value for a key must route that key to `deletes` rather than
|
|
/// upserting `""`. Mirrors the single-key endpoint's behavior and prevents
|
|
/// stale `""` rows (e.g. `npmrc`) from tripping the EE registry gate on CE.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_diff_global_settings_empty_string_routes_to_delete(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
insert_global_setting(&db, "npmrc", serde_json::json!("registry=https://x/")).await;
|
|
|
|
let current_map = InstanceConfig::from_db(&db)
|
|
.await
|
|
.unwrap()
|
|
.global_settings
|
|
.to_settings_map();
|
|
|
|
let mut desired: BTreeMap<String, serde_json::Value> = BTreeMap::new();
|
|
desired.insert("npmrc".to_string(), serde_json::json!(""));
|
|
|
|
let diff = diff_global_settings(¤t_map, &desired, ApplyMode::Merge);
|
|
assert!(
|
|
!diff.upserts.contains_key("npmrc"),
|
|
"empty-string value must not upsert"
|
|
);
|
|
assert!(
|
|
diff.deletes.iter().any(|k| k == "npmrc"),
|
|
"empty-string value must route to deletes"
|
|
);
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
assert!(
|
|
get_global_setting(&db, "npmrc").await.is_none(),
|
|
"npmrc row must be deleted after apply"
|
|
);
|
|
|
|
// Second case: key not currently present → no-op (no upsert, no delete).
|
|
clear_settings_and_configs(&db).await;
|
|
let current_map = InstanceConfig::from_db(&db)
|
|
.await
|
|
.unwrap()
|
|
.global_settings
|
|
.to_settings_map();
|
|
let diff = diff_global_settings(¤t_map, &desired, ApplyMode::Merge);
|
|
assert!(!diff.upserts.contains_key("npmrc"));
|
|
assert!(!diff.deletes.iter().any(|k| k == "npmrc"));
|
|
}
|
|
|
|
/// Regression: whitespace-only values for protected settings (e.g.
|
|
/// `jwt_secret`) must not leak into `deletes`. The empty-string-unset branch
|
|
/// in `diff_global_settings` relies on the `PROTECTED_SETTINGS` guard ahead
|
|
/// of it to catch whitespace, so `is_empty_or_null` must also trim.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_diff_global_settings_protected_whitespace_not_deleted(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
insert_global_setting(&db, "jwt_secret", serde_json::json!("s3cret")).await;
|
|
|
|
let current_map = InstanceConfig::from_db(&db)
|
|
.await
|
|
.unwrap()
|
|
.global_settings
|
|
.to_settings_map();
|
|
|
|
for whitespace in [" ", " ", "\t", "\n", ""] {
|
|
let mut desired: BTreeMap<String, serde_json::Value> = BTreeMap::new();
|
|
desired.insert("jwt_secret".to_string(), serde_json::json!(whitespace));
|
|
|
|
let diff = diff_global_settings(¤t_map, &desired, ApplyMode::Merge);
|
|
assert!(
|
|
!diff.deletes.iter().any(|k| k == "jwt_secret"),
|
|
"protected setting must not be routed to deletes for value {whitespace:?}"
|
|
);
|
|
assert!(
|
|
!diff.upserts.contains_key("jwt_secret"),
|
|
"protected setting must not be overwritten with whitespace for value {whitespace:?}"
|
|
);
|
|
}
|
|
|
|
// The underlying secret must still be present after applying each diff.
|
|
assert_eq!(
|
|
get_global_setting(&db, "jwt_secret").await,
|
|
Some(serde_json::json!("s3cret"))
|
|
);
|
|
}
|
|
|
|
/// Regression: a bulk `diff_global_settings` upsert must reject a
|
|
/// `worker_configs` key, even if a client PUT carries one in the flattened
|
|
/// extra map. This is the write-side guard that prevents the ghost row from
|
|
/// being resurrected on every InstanceSettings YAML save.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_diff_global_settings_rejects_worker_configs_upsert(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
let current: BTreeMap<String, serde_json::Value> = BTreeMap::new();
|
|
let mut desired: BTreeMap<String, serde_json::Value> = BTreeMap::new();
|
|
desired.insert(
|
|
"base_url".to_string(),
|
|
serde_json::json!("https://windmill.test"),
|
|
);
|
|
desired.insert(
|
|
"worker_configs".to_string(),
|
|
serde_json::json!({"default": {"worker_tags": ["deno"]}}),
|
|
);
|
|
|
|
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
|
assert!(diff.upserts.contains_key("base_url"));
|
|
assert!(
|
|
!diff.upserts.contains_key("worker_configs"),
|
|
"worker_configs must never be written as a global setting"
|
|
);
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
assert!(
|
|
get_global_setting(&db, "worker_configs").await.is_none(),
|
|
"worker_configs row must not exist in global_settings after apply"
|
|
);
|
|
assert_eq!(
|
|
get_global_setting(&db, "base_url").await,
|
|
Some(serde_json::json!("https://windmill.test"))
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_with_worker_configs(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
insert_config(
|
|
&db,
|
|
"worker__default",
|
|
serde_json::json!({"init_bash": "echo default"}),
|
|
)
|
|
.await;
|
|
insert_config(
|
|
&db,
|
|
"worker__gpu",
|
|
serde_json::json!({
|
|
"dedicated_worker": "ws:f/gpu_script",
|
|
"worker_tags": ["gpu", "cuda"]
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(config.worker_configs.len(), 2);
|
|
assert_eq!(
|
|
config.worker_configs["default"].init_bash.as_deref(),
|
|
Some("echo default")
|
|
);
|
|
assert_eq!(
|
|
config.worker_configs["gpu"].dedicated_worker.as_deref(),
|
|
Some("ws:f/gpu_script")
|
|
);
|
|
assert_eq!(
|
|
config.worker_configs["gpu"].worker_tags.as_ref().unwrap(),
|
|
&["gpu", "cuda"]
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_ignores_non_worker_configs(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Insert a config without worker__ prefix — should not appear
|
|
insert_config(&db, "server_config", serde_json::json!({"important": true})).await;
|
|
insert_config(
|
|
&db,
|
|
"worker__actual",
|
|
serde_json::json!({"init_bash": "echo hi"}),
|
|
)
|
|
.await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(config.worker_configs.len(), 1);
|
|
assert!(config.worker_configs.contains_key("actual"));
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_worker_config_prefix_stripping(db: Pool<Postgres>) {
|
|
insert_config(
|
|
&db,
|
|
"worker__my_group_name",
|
|
serde_json::json!({"cache_clear": 5}),
|
|
)
|
|
.await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert!(
|
|
config.worker_configs.contains_key("my_group_name"),
|
|
"worker__ prefix should be stripped"
|
|
);
|
|
assert_eq!(
|
|
config.worker_configs["my_group_name"].extra["cache_clear"],
|
|
serde_json::json!(5)
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_worker_config_unknown_fields_in_extra(db: Pool<Postgres>) {
|
|
insert_config(
|
|
&db,
|
|
"worker__test_extra",
|
|
serde_json::json!({
|
|
"init_bash": "echo hello",
|
|
"future_field": 999,
|
|
"another_future": {"nested": true}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
let wc = &config.worker_configs["test_extra"];
|
|
assert_eq!(wc.init_bash.as_deref(), Some("echo hello"));
|
|
assert_eq!(wc.extra["future_field"], serde_json::json!(999));
|
|
assert_eq!(
|
|
wc.extra["another_future"],
|
|
serde_json::json!({"nested": true})
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// apply_settings_diff() tests
|
|
// ========================================================================
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_settings_diff_upserts_only(db: Pool<Postgres>) {
|
|
let diff = SettingsDiff {
|
|
upserts: {
|
|
let mut m = BTreeMap::new();
|
|
m.insert("key_a".to_string(), serde_json::json!("val_a"));
|
|
m.insert("key_b".to_string(), serde_json::json!(123));
|
|
m
|
|
},
|
|
deletes: vec![],
|
|
..Default::default()
|
|
};
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
assert_eq!(
|
|
get_global_setting(&db, "key_a").await,
|
|
Some(serde_json::json!("val_a"))
|
|
);
|
|
assert_eq!(
|
|
get_global_setting(&db, "key_b").await,
|
|
Some(serde_json::json!(123))
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_settings_diff_deletes_only(db: Pool<Postgres>) {
|
|
insert_global_setting(&db, "to_delete_1", serde_json::json!("bye")).await;
|
|
insert_global_setting(&db, "to_delete_2", serde_json::json!("gone")).await;
|
|
insert_global_setting(&db, "to_keep", serde_json::json!("stay")).await;
|
|
|
|
let diff = SettingsDiff {
|
|
upserts: BTreeMap::new(),
|
|
deletes: vec!["to_delete_1".to_string(), "to_delete_2".to_string()],
|
|
..Default::default()
|
|
};
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
assert!(get_global_setting(&db, "to_delete_1").await.is_none());
|
|
assert!(get_global_setting(&db, "to_delete_2").await.is_none());
|
|
assert!(get_global_setting(&db, "to_keep").await.is_some());
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_settings_diff_upserts_and_deletes(db: Pool<Postgres>) {
|
|
insert_global_setting(&db, "old_key", serde_json::json!("old")).await;
|
|
|
|
let diff = SettingsDiff {
|
|
upserts: {
|
|
let mut m = BTreeMap::new();
|
|
m.insert("new_key".to_string(), serde_json::json!("new"));
|
|
m
|
|
},
|
|
deletes: vec!["old_key".to_string()],
|
|
..Default::default()
|
|
};
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
assert!(get_global_setting(&db, "old_key").await.is_none());
|
|
assert_eq!(
|
|
get_global_setting(&db, "new_key").await,
|
|
Some(serde_json::json!("new"))
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_settings_diff_empty_noop(db: Pool<Postgres>) {
|
|
insert_global_setting(&db, "preexisting", serde_json::json!("value")).await;
|
|
|
|
let diff = SettingsDiff::default();
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
assert_eq!(
|
|
get_global_setting(&db, "preexisting").await,
|
|
Some(serde_json::json!("value"))
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_settings_diff_upsert_overwrites(db: Pool<Postgres>) {
|
|
insert_global_setting(&db, "overwrite_me", serde_json::json!("old_value")).await;
|
|
|
|
let diff = SettingsDiff {
|
|
upserts: {
|
|
let mut m = BTreeMap::new();
|
|
m.insert("overwrite_me".to_string(), serde_json::json!("new_value"));
|
|
m
|
|
},
|
|
deletes: vec![],
|
|
..Default::default()
|
|
};
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
assert_eq!(
|
|
get_global_setting(&db, "overwrite_me").await,
|
|
Some(serde_json::json!("new_value"))
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_settings_diff_complex_json(db: Pool<Postgres>) {
|
|
let complex_value = serde_json::json!({
|
|
"host": "smtp.example.com",
|
|
"port": 587,
|
|
"auth": {"user": "admin", "pass": "secret"},
|
|
"tags": [1, 2, 3],
|
|
"nested": {"deep": {"value": null}}
|
|
});
|
|
|
|
let diff = SettingsDiff {
|
|
upserts: {
|
|
let mut m = BTreeMap::new();
|
|
m.insert("complex_setting".to_string(), complex_value.clone());
|
|
m
|
|
},
|
|
deletes: vec![],
|
|
..Default::default()
|
|
};
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
let stored = get_global_setting(&db, "complex_setting").await.unwrap();
|
|
assert_eq!(stored, complex_value);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_settings_diff_delete_nonexistent_is_noop(db: Pool<Postgres>) {
|
|
let diff = SettingsDiff {
|
|
upserts: BTreeMap::new(),
|
|
deletes: vec!["does_not_exist".to_string()],
|
|
..Default::default()
|
|
};
|
|
|
|
// Should not error
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
}
|
|
|
|
// ========================================================================
|
|
// apply_configs_diff() tests
|
|
// ========================================================================
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_configs_diff_upserts_with_prefix(db: Pool<Postgres>) {
|
|
let diff = ConfigsDiff {
|
|
upserts: {
|
|
let mut m = BTreeMap::new();
|
|
m.insert(
|
|
"mygroup".to_string(),
|
|
serde_json::json!({"init_bash": "echo hi"}),
|
|
);
|
|
m
|
|
},
|
|
deletes: vec![],
|
|
};
|
|
|
|
apply_configs_diff(&db, &diff).await.unwrap();
|
|
|
|
let stored = get_config(&db, "worker__mygroup").await.unwrap();
|
|
assert_eq!(stored["init_bash"], "echo hi");
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_configs_diff_deletes_with_prefix(db: Pool<Postgres>) {
|
|
insert_config(&db, "worker__to_remove", serde_json::json!({"a": 1})).await;
|
|
|
|
let diff = ConfigsDiff { upserts: BTreeMap::new(), deletes: vec!["to_remove".to_string()] };
|
|
|
|
apply_configs_diff(&db, &diff).await.unwrap();
|
|
|
|
assert!(get_config(&db, "worker__to_remove").await.is_none());
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_configs_diff_upsert_overwrites(db: Pool<Postgres>) {
|
|
insert_config(&db, "worker__grp", serde_json::json!({"old": true})).await;
|
|
|
|
let diff = ConfigsDiff {
|
|
upserts: {
|
|
let mut m = BTreeMap::new();
|
|
m.insert("grp".to_string(), serde_json::json!({"new": true}));
|
|
m
|
|
},
|
|
deletes: vec![],
|
|
};
|
|
|
|
apply_configs_diff(&db, &diff).await.unwrap();
|
|
|
|
let stored = get_config(&db, "worker__grp").await.unwrap();
|
|
assert_eq!(stored, serde_json::json!({"new": true}));
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_configs_diff_empty_noop(db: Pool<Postgres>) {
|
|
insert_config(&db, "worker__keep", serde_json::json!({"keep": true})).await;
|
|
|
|
let diff = ConfigsDiff::default();
|
|
apply_configs_diff(&db, &diff).await.unwrap();
|
|
|
|
assert!(get_config(&db, "worker__keep").await.is_some());
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_apply_configs_diff_does_not_touch_non_worker_configs(db: Pool<Postgres>) {
|
|
insert_config(&db, "server_config", serde_json::json!({"x": 1})).await;
|
|
|
|
let diff = ConfigsDiff { upserts: BTreeMap::new(), deletes: vec!["server_config".to_string()] };
|
|
|
|
apply_configs_diff(&db, &diff).await.unwrap();
|
|
|
|
// The delete targets "worker__server_config", not "server_config"
|
|
assert!(
|
|
get_config(&db, "server_config").await.is_some(),
|
|
"Non-worker config should not be affected"
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Full roundtrip tests
|
|
// ========================================================================
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_roundtrip_write_read_modify_apply(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Step 1: Seed initial state
|
|
insert_global_setting(&db, "base_url", serde_json::json!("https://v1.test")).await;
|
|
insert_global_setting(&db, "retention_period_secs", serde_json::json!(3600)).await;
|
|
insert_config(
|
|
&db,
|
|
"worker__default",
|
|
serde_json::json!({"init_bash": "echo v1"}),
|
|
)
|
|
.await;
|
|
|
|
// Step 2: Read via from_db
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config.global_settings.base_url.as_deref(),
|
|
Some("https://v1.test")
|
|
);
|
|
assert_eq!(config.global_settings.retention_period_secs, Some(3600));
|
|
assert_eq!(config.worker_configs.len(), 1);
|
|
|
|
// Step 3: Modify — change base_url, add a new setting, remove retention
|
|
let mut desired_settings = config.global_settings.clone();
|
|
desired_settings.base_url = Some("https://v2.test".to_string());
|
|
desired_settings.expose_metrics = Some(true);
|
|
desired_settings.retention_period_secs = None;
|
|
|
|
let current_map = config.global_settings.to_settings_map();
|
|
let desired_map = desired_settings.to_settings_map();
|
|
|
|
// Step 4: Diff + apply (Merge mode — no deletes)
|
|
let diff = diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge);
|
|
assert!(diff.upserts.contains_key("base_url"));
|
|
assert!(diff.upserts.contains_key("expose_metrics"));
|
|
assert!(diff.deletes.is_empty());
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
// Step 5: Read back and verify
|
|
let config2 = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config2.global_settings.base_url.as_deref(),
|
|
Some("https://v2.test")
|
|
);
|
|
assert_eq!(config2.global_settings.expose_metrics, Some(true));
|
|
// retention_period_secs still in DB because Merge mode doesn't delete
|
|
assert_eq!(config2.global_settings.retention_period_secs, Some(3600));
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_roundtrip_replace_mode_deletes(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Seed
|
|
insert_global_setting(&db, "base_url", serde_json::json!("https://old.test")).await;
|
|
insert_global_setting(&db, "retention_period_secs", serde_json::json!(7200)).await;
|
|
insert_global_setting(&db, "expose_metrics", serde_json::json!(false)).await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
let current_map = config.global_settings.to_settings_map();
|
|
|
|
// Desired: only base_url — retention and expose_metrics should be deleted
|
|
let mut desired_map = BTreeMap::new();
|
|
desired_map.insert(
|
|
"base_url".to_string(),
|
|
serde_json::json!("https://old.test"),
|
|
);
|
|
|
|
let diff = diff_global_settings(¤t_map, &desired_map, ApplyMode::Replace);
|
|
assert!(diff.upserts.is_empty());
|
|
assert!(diff.deletes.contains(&"retention_period_secs".to_string()));
|
|
assert!(diff.deletes.contains(&"expose_metrics".to_string()));
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
assert!(get_global_setting(&db, "retention_period_secs")
|
|
.await
|
|
.is_none());
|
|
assert!(get_global_setting(&db, "expose_metrics").await.is_none());
|
|
assert!(get_global_setting(&db, "base_url").await.is_some());
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_roundtrip_worker_configs(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Seed two worker configs
|
|
insert_config(
|
|
&db,
|
|
"worker__default",
|
|
serde_json::json!({"init_bash": "echo default"}),
|
|
)
|
|
.await;
|
|
insert_config(
|
|
&db,
|
|
"worker__legacy",
|
|
serde_json::json!({"init_bash": "echo legacy"}),
|
|
)
|
|
.await;
|
|
|
|
// Read
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(config.worker_configs.len(), 2);
|
|
|
|
// Desired: replace default, add gpu, remove legacy
|
|
let current_map: BTreeMap<String, serde_json::Value> = config
|
|
.worker_configs
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), serde_json::to_value(v).unwrap()))
|
|
.collect();
|
|
|
|
let mut desired_map = BTreeMap::new();
|
|
desired_map.insert(
|
|
"default".to_string(),
|
|
serde_json::json!({"init_bash": "echo default v2"}),
|
|
);
|
|
desired_map.insert(
|
|
"gpu".to_string(),
|
|
serde_json::json!({"dedicated_worker": "ws:f/gpu"}),
|
|
);
|
|
|
|
let diff = diff_worker_configs(¤t_map, &desired_map, ApplyMode::Replace);
|
|
assert!(diff.upserts.contains_key("default")); // changed
|
|
assert!(diff.upserts.contains_key("gpu")); // new
|
|
assert_eq!(diff.deletes, vec!["legacy".to_string()]);
|
|
|
|
apply_configs_diff(&db, &diff).await.unwrap();
|
|
|
|
// Verify
|
|
let config2 = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(config2.worker_configs.len(), 2);
|
|
assert_eq!(
|
|
config2.worker_configs["default"].init_bash.as_deref(),
|
|
Some("echo default v2")
|
|
);
|
|
assert_eq!(
|
|
config2.worker_configs["gpu"].dedicated_worker.as_deref(),
|
|
Some("ws:f/gpu")
|
|
);
|
|
assert!(!config2.worker_configs.contains_key("legacy"));
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_roundtrip_to_settings_map_from_db_consistency(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Write a GlobalSettings via to_settings_map + apply, then read back via from_db
|
|
let original = windmill_common::instance_config::GlobalSettings {
|
|
base_url: Some("https://roundtrip.test".to_string()),
|
|
retention_period_secs: Some(43200),
|
|
expose_metrics: Some(true),
|
|
smtp_settings: Some(windmill_common::instance_config::SmtpSettings {
|
|
smtp_host: Some("smtp.roundtrip.test".to_string()),
|
|
smtp_port: Some(465),
|
|
..Default::default()
|
|
}),
|
|
custom_tags: Some(vec!["tag1".to_string(), "tag2".to_string()]),
|
|
..Default::default()
|
|
};
|
|
|
|
let map = original.to_settings_map();
|
|
let diff =
|
|
SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() };
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(config.global_settings.base_url, original.base_url);
|
|
assert_eq!(
|
|
config.global_settings.retention_period_secs,
|
|
original.retention_period_secs
|
|
);
|
|
assert_eq!(
|
|
config.global_settings.expose_metrics,
|
|
original.expose_metrics
|
|
);
|
|
assert_eq!(
|
|
config
|
|
.global_settings
|
|
.smtp_settings
|
|
.as_ref()
|
|
.unwrap()
|
|
.smtp_host,
|
|
original.smtp_settings.as_ref().unwrap().smtp_host
|
|
);
|
|
assert_eq!(
|
|
config
|
|
.global_settings
|
|
.smtp_settings
|
|
.as_ref()
|
|
.unwrap()
|
|
.smtp_port,
|
|
original.smtp_settings.as_ref().unwrap().smtp_port
|
|
);
|
|
assert_eq!(config.global_settings.custom_tags, original.custom_tags);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_idempotent_apply(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
let diff = SettingsDiff {
|
|
upserts: {
|
|
let mut m = BTreeMap::new();
|
|
m.insert("idem_key".to_string(), serde_json::json!("idem_value"));
|
|
m
|
|
},
|
|
deletes: vec![],
|
|
..Default::default()
|
|
};
|
|
|
|
// Apply twice
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
assert_eq!(
|
|
get_global_setting(&db, "idem_key").await,
|
|
Some(serde_json::json!("idem_value"))
|
|
);
|
|
assert_eq!(count_global_settings(&db).await, 1);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_mixed_typed_and_extra(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Insert both typed and untyped settings
|
|
insert_global_setting(&db, "base_url", serde_json::json!("https://mixed.test")).await;
|
|
insert_global_setting(&db, "expose_metrics", serde_json::json!(true)).await;
|
|
insert_global_setting(
|
|
&db,
|
|
"unknown_future_setting",
|
|
serde_json::json!({"key": "val"}),
|
|
)
|
|
.await;
|
|
insert_global_setting(&db, "another_custom", serde_json::json!([1, 2, 3])).await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
|
|
// Typed fields
|
|
assert_eq!(
|
|
config.global_settings.base_url.as_deref(),
|
|
Some("https://mixed.test")
|
|
);
|
|
assert_eq!(config.global_settings.expose_metrics, Some(true));
|
|
|
|
// Extra fields
|
|
assert_eq!(
|
|
config.global_settings.extra["unknown_future_setting"],
|
|
serde_json::json!({"key": "val"})
|
|
);
|
|
assert_eq!(
|
|
config.global_settings.extra["another_custom"],
|
|
serde_json::json!([1, 2, 3])
|
|
);
|
|
|
|
// Roundtrip: to_settings_map should include everything
|
|
let map = config.global_settings.to_settings_map();
|
|
assert!(map.contains_key("base_url"));
|
|
assert!(map.contains_key("expose_metrics"));
|
|
assert!(map.contains_key("unknown_future_setting"));
|
|
assert!(map.contains_key("another_custom"));
|
|
assert_eq!(map.len(), 4);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_full_config_roundtrip(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Seed a realistic configuration
|
|
insert_global_setting(
|
|
&db,
|
|
"base_url",
|
|
serde_json::json!("https://prod.windmill.dev"),
|
|
)
|
|
.await;
|
|
insert_global_setting(&db, "license_key", serde_json::json!("prod-license-key")).await;
|
|
insert_global_setting(&db, "retention_period_secs", serde_json::json!(2592000)).await;
|
|
insert_global_setting(
|
|
&db,
|
|
"smtp_settings",
|
|
serde_json::json!({
|
|
"smtp_host": "smtp.prod.com",
|
|
"smtp_port": 587,
|
|
"smtp_from": "noreply@prod.com",
|
|
"smtp_tls_implicit": true
|
|
}),
|
|
)
|
|
.await;
|
|
insert_global_setting(
|
|
&db,
|
|
"critical_error_channels",
|
|
serde_json::json!([
|
|
{"email": "admin@prod.com"},
|
|
{"slack_channel": "#prod-alerts"}
|
|
]),
|
|
)
|
|
.await;
|
|
insert_global_setting(
|
|
&db,
|
|
"otel",
|
|
serde_json::json!({
|
|
"metrics_enabled": true,
|
|
"tracing_enabled": true,
|
|
"otel_exporter_otlp_endpoint": "http://otel-collector:4317"
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
insert_config(
|
|
&db,
|
|
"worker__default",
|
|
serde_json::json!({
|
|
"init_bash": "apt-get update",
|
|
"worker_tags": ["default", "deno", "bun"],
|
|
"cache_clear": 7
|
|
}),
|
|
)
|
|
.await;
|
|
insert_config(
|
|
&db,
|
|
"worker__gpu",
|
|
serde_json::json!({
|
|
"dedicated_worker": "ws:f/gpu_inference",
|
|
"autoscaling": {
|
|
"enabled": true,
|
|
"min_workers": 0,
|
|
"max_workers": 4,
|
|
"integration": {"type": "kubernetes"}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Read full config
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
|
|
assert_eq!(
|
|
config.global_settings.base_url.as_deref(),
|
|
Some("https://prod.windmill.dev")
|
|
);
|
|
assert_eq!(
|
|
config
|
|
.global_settings
|
|
.license_key
|
|
.as_ref()
|
|
.and_then(|v| v.as_literal()),
|
|
Some("prod-license-key")
|
|
);
|
|
assert_eq!(config.global_settings.retention_period_secs, Some(2592000));
|
|
|
|
let smtp = config.global_settings.smtp_settings.as_ref().unwrap();
|
|
assert_eq!(smtp.smtp_host.as_deref(), Some("smtp.prod.com"));
|
|
assert_eq!(smtp.smtp_from.as_deref(), Some("noreply@prod.com"));
|
|
|
|
let channels = config
|
|
.global_settings
|
|
.critical_error_channels
|
|
.as_ref()
|
|
.unwrap();
|
|
assert_eq!(channels.len(), 2);
|
|
|
|
let otel = config.global_settings.otel.as_ref().unwrap();
|
|
assert_eq!(otel.metrics_enabled, Some(true));
|
|
assert_eq!(otel.tracing_enabled, Some(true));
|
|
|
|
assert_eq!(config.worker_configs.len(), 2);
|
|
assert_eq!(
|
|
config.worker_configs["default"].extra["cache_clear"],
|
|
serde_json::json!(7)
|
|
);
|
|
let gpu_auto = config.worker_configs["gpu"].autoscaling.as_ref().unwrap();
|
|
assert!(gpu_auto.enabled);
|
|
assert_eq!(gpu_auto.min_workers, Some(0));
|
|
assert_eq!(gpu_auto.max_workers, Some(4));
|
|
|
|
// Verify settings count matches
|
|
let settings_map = config.global_settings.to_settings_map();
|
|
let db_count = count_global_settings(&db).await;
|
|
assert_eq!(
|
|
settings_map.len() as i64,
|
|
db_count,
|
|
"to_settings_map should produce same count as DB rows"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_diff_apply_only_touches_changed_rows(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Seed 3 settings
|
|
insert_global_setting(&db, "unchanged_1", serde_json::json!("val1")).await;
|
|
insert_global_setting(&db, "unchanged_2", serde_json::json!("val2")).await;
|
|
insert_global_setting(&db, "to_change", serde_json::json!("old")).await;
|
|
|
|
let mut current = BTreeMap::new();
|
|
current.insert("unchanged_1".to_string(), serde_json::json!("val1"));
|
|
current.insert("unchanged_2".to_string(), serde_json::json!("val2"));
|
|
current.insert("to_change".to_string(), serde_json::json!("old"));
|
|
|
|
let mut desired = current.clone();
|
|
desired.insert("to_change".to_string(), serde_json::json!("new"));
|
|
desired.insert("added".to_string(), serde_json::json!("fresh"));
|
|
|
|
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
|
|
|
// Only "to_change" and "added" should be in upserts
|
|
assert_eq!(diff.upserts.len(), 2);
|
|
assert!(diff.upserts.contains_key("to_change"));
|
|
assert!(diff.upserts.contains_key("added"));
|
|
assert!(!diff.upserts.contains_key("unchanged_1"));
|
|
assert!(!diff.upserts.contains_key("unchanged_2"));
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
// Verify all 4 settings present
|
|
assert_eq!(count_global_settings(&db).await, 4);
|
|
assert_eq!(
|
|
get_global_setting(&db, "to_change").await,
|
|
Some(serde_json::json!("new"))
|
|
);
|
|
assert_eq!(
|
|
get_global_setting(&db, "added").await,
|
|
Some(serde_json::json!("fresh"))
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_from_db_reads_migration_defaults(db: Pool<Postgres>) {
|
|
// Verify that from_db correctly reads the migration-seeded state
|
|
// without clearing — tests that pre-existing data is properly deserialized
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
|
|
// Migrations seed at least base_url, license_key, etc.
|
|
// Just verify from_db doesn't error and returns a populated struct
|
|
let map = config.global_settings.to_settings_map();
|
|
assert!(
|
|
!map.is_empty(),
|
|
"Migration-seeded DB should produce non-empty settings"
|
|
);
|
|
assert!(
|
|
!config.worker_configs.is_empty(),
|
|
"Migration-seeded DB should have worker configs"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_replace_mode_protects_settings_in_integration(db: Pool<Postgres>) {
|
|
// Seed a protected setting
|
|
insert_global_setting(
|
|
&db,
|
|
"ducklake_settings",
|
|
serde_json::json!({"ducklakes": {}}),
|
|
)
|
|
.await;
|
|
insert_global_setting(&db, "ducklake_user_pg_pwd", serde_json::json!("secret_pwd")).await;
|
|
insert_global_setting(
|
|
&db,
|
|
"custom_instance_pg_databases",
|
|
serde_json::json!({"databases": {}}),
|
|
)
|
|
.await;
|
|
insert_global_setting(&db, "normal_setting", serde_json::json!("will_be_deleted")).await;
|
|
|
|
// Read current state
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
let current_map = config.global_settings.to_settings_map();
|
|
|
|
// Desired: only keep_me — everything else should be deleted except protected
|
|
let mut desired_map = BTreeMap::new();
|
|
desired_map.insert("keep_me".to_string(), serde_json::json!("yes"));
|
|
|
|
let diff = diff_global_settings(¤t_map, &desired_map, ApplyMode::Replace);
|
|
|
|
// Protected keys should NOT be in deletes
|
|
assert!(
|
|
!diff.deletes.contains(&"ducklake_settings".to_string()),
|
|
"ducklake_settings is protected"
|
|
);
|
|
assert!(
|
|
!diff.deletes.contains(&"ducklake_user_pg_pwd".to_string()),
|
|
"ducklake_user_pg_pwd is protected"
|
|
);
|
|
assert!(
|
|
!diff
|
|
.deletes
|
|
.contains(&"custom_instance_pg_databases".to_string()),
|
|
"custom_instance_pg_databases is protected"
|
|
);
|
|
// But normal_setting should be deleted
|
|
assert!(diff.deletes.contains(&"normal_setting".to_string()));
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
// Verify protected settings survived
|
|
assert!(get_global_setting(&db, "ducklake_settings").await.is_some());
|
|
assert!(get_global_setting(&db, "ducklake_user_pg_pwd")
|
|
.await
|
|
.is_some());
|
|
assert!(get_global_setting(&db, "custom_instance_pg_databases")
|
|
.await
|
|
.is_some());
|
|
// Normal setting is gone
|
|
assert!(get_global_setting(&db, "normal_setting").await.is_none());
|
|
// New setting is present
|
|
assert_eq!(
|
|
get_global_setting(&db, "keep_me").await,
|
|
Some(serde_json::json!("yes"))
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// jwt_secret and rsa_keys declarative roundtrip
|
|
// ========================================================================
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_jwt_secret_roundtrip_as_string_or_secret_ref(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Simulate what the operator does: parse a GlobalSettings with jwt_secret
|
|
// as a resolved StringOrSecretRef::Literal, write to DB, read back.
|
|
let settings = windmill_common::instance_config::GlobalSettings {
|
|
jwt_secret: Some(
|
|
windmill_common::instance_config::StringOrSecretRef::Literal(
|
|
"my-jwt-secret-from-k8s".to_string(),
|
|
),
|
|
),
|
|
..Default::default()
|
|
};
|
|
|
|
let map = settings.to_settings_map();
|
|
|
|
// The serialized form should be a plain JSON string (not an object)
|
|
assert_eq!(
|
|
map["jwt_secret"],
|
|
serde_json::json!("my-jwt-secret-from-k8s")
|
|
);
|
|
|
|
let diff =
|
|
SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() };
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
// Read back from DB — jwt_secret should survive the roundtrip
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config
|
|
.global_settings
|
|
.jwt_secret
|
|
.as_ref()
|
|
.and_then(|v| v.as_literal()),
|
|
Some("my-jwt-secret-from-k8s")
|
|
);
|
|
|
|
// Verify the raw DB value is a plain string (not wrapped in an object)
|
|
let raw = get_global_setting(&db, "jwt_secret").await.unwrap();
|
|
assert!(
|
|
raw.is_string(),
|
|
"jwt_secret in DB should be a plain JSON string"
|
|
);
|
|
assert_eq!(raw.as_str().unwrap(), "my-jwt-secret-from-k8s");
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_rsa_keys_roundtrip_via_extra(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// rsa_keys is not a typed field — it flows through GlobalSettings.extra.
|
|
// Simulate a resolved secretKeyRef: the operator resolves the ref and
|
|
// writes the plain value to extra before syncing to DB.
|
|
let json_str = r#"{
|
|
"rsa_keys": {
|
|
"private_key": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-data\n-----END RSA PRIVATE KEY-----"
|
|
}
|
|
}"#;
|
|
let settings: windmill_common::instance_config::GlobalSettings =
|
|
serde_json::from_str(json_str).unwrap();
|
|
|
|
// rsa_keys should land in extra
|
|
assert!(settings.extra.contains_key("rsa_keys"));
|
|
|
|
let map = settings.to_settings_map();
|
|
let diff =
|
|
SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() };
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
// Read back from DB
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config.global_settings.extra["rsa_keys"]["private_key"],
|
|
"-----BEGIN RSA PRIVATE KEY-----\ntest-key-data\n-----END RSA PRIVATE KEY-----"
|
|
);
|
|
|
|
// Verify the raw DB value is a JSON object with private_key
|
|
let raw = get_global_setting(&db, "rsa_keys").await.unwrap();
|
|
assert!(raw.is_object());
|
|
assert_eq!(
|
|
raw["private_key"].as_str().unwrap(),
|
|
"-----BEGIN RSA PRIVATE KEY-----\ntest-key-data\n-----END RSA PRIVATE KEY-----"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_replace_mode_protects_jwt_secret_and_rsa_keys(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Seed jwt_secret and rsa_keys (both are PROTECTED_SETTINGS)
|
|
insert_global_setting(&db, "jwt_secret", serde_json::json!("existing-secret")).await;
|
|
insert_global_setting(
|
|
&db,
|
|
"rsa_keys",
|
|
serde_json::json!({"private_key": "existing-rsa-key"}),
|
|
)
|
|
.await;
|
|
insert_global_setting(&db, "normal_setting", serde_json::json!("will-go")).await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
let current_map = config.global_settings.to_settings_map();
|
|
|
|
// Desired state: only base_url — jwt_secret and rsa_keys should survive
|
|
let mut desired_map = BTreeMap::new();
|
|
desired_map.insert(
|
|
"base_url".to_string(),
|
|
serde_json::json!("https://example.com"),
|
|
);
|
|
|
|
let diff = diff_global_settings(¤t_map, &desired_map, ApplyMode::Replace);
|
|
|
|
assert!(
|
|
!diff.deletes.contains(&"jwt_secret".to_string()),
|
|
"jwt_secret is protected from deletion"
|
|
);
|
|
assert!(
|
|
!diff.deletes.contains(&"rsa_keys".to_string()),
|
|
"rsa_keys is protected from deletion"
|
|
);
|
|
assert!(
|
|
diff.deletes.contains(&"normal_setting".to_string()),
|
|
"normal_setting should be deleted"
|
|
);
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
// Both protected settings survive
|
|
assert_eq!(
|
|
get_global_setting(&db, "jwt_secret").await,
|
|
Some(serde_json::json!("existing-secret"))
|
|
);
|
|
assert_eq!(
|
|
get_global_setting(&db, "rsa_keys").await,
|
|
Some(serde_json::json!({"private_key": "existing-rsa-key"}))
|
|
);
|
|
assert!(get_global_setting(&db, "normal_setting").await.is_none());
|
|
}
|
|
|
|
// ========================================================================
|
|
// Alert config migration tests
|
|
// ========================================================================
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_alert_config_in_global_settings_roundtrip(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
let alert_value = serde_json::json!({
|
|
"alerts": [
|
|
{
|
|
"name": "Test Alert",
|
|
"tags_to_monitor": ["default", "gpu"],
|
|
"jobs_num_threshold": 5,
|
|
"alert_cooldown_seconds": 300,
|
|
"alert_time_threshold_seconds": 60
|
|
}
|
|
]
|
|
});
|
|
|
|
// Insert alert_config into global_settings
|
|
insert_global_setting(&db, "alert_job_queue_waiting", alert_value.clone()).await;
|
|
|
|
// Verify it appears in InstanceConfig global_settings (via extra)
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config.global_settings.extra["alert_job_queue_waiting"], alert_value,
|
|
"alert_config should appear in global_settings extra"
|
|
);
|
|
|
|
// Verify it does NOT appear in worker_configs
|
|
assert!(
|
|
!config
|
|
.worker_configs
|
|
.contains_key("alert_job_queue_waiting"),
|
|
"alert_config should not appear in worker_configs"
|
|
);
|
|
|
|
// Modify the alert_config
|
|
let updated_value = serde_json::json!({
|
|
"alerts": [
|
|
{
|
|
"name": "Updated Alert",
|
|
"tags_to_monitor": ["batch"],
|
|
"jobs_num_threshold": 10,
|
|
"alert_cooldown_seconds": 600,
|
|
"alert_time_threshold_seconds": 120
|
|
}
|
|
]
|
|
});
|
|
|
|
let current = config.global_settings.to_settings_map();
|
|
let mut desired = current.clone();
|
|
desired.insert("alert_job_queue_waiting".to_string(), updated_value.clone());
|
|
|
|
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
|
assert!(
|
|
diff.upserts.contains_key("alert_job_queue_waiting"),
|
|
"alert_config change should be detected in diff"
|
|
);
|
|
|
|
apply_settings_diff(&db, &diff).await.unwrap();
|
|
|
|
// Re-read and verify the update
|
|
let config2 = InstanceConfig::from_db(&db).await.unwrap();
|
|
assert_eq!(
|
|
config2.global_settings.extra["alert_job_queue_waiting"],
|
|
updated_value
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_alert_config_not_in_worker_configs(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
|
|
// Insert alert_config in global_settings (the correct location)
|
|
insert_global_setting(
|
|
&db,
|
|
"alert_job_queue_waiting",
|
|
serde_json::json!({"alerts": []}),
|
|
)
|
|
.await;
|
|
|
|
// Also insert a real worker config
|
|
insert_config(
|
|
&db,
|
|
"worker__default",
|
|
serde_json::json!({"worker_tags": ["default"]}),
|
|
)
|
|
.await;
|
|
|
|
let config = InstanceConfig::from_db(&db).await.unwrap();
|
|
|
|
// alert_config should be in global_settings, not worker_configs
|
|
assert!(
|
|
config
|
|
.global_settings
|
|
.extra
|
|
.contains_key("alert_job_queue_waiting"),
|
|
"alert_config should be in global_settings.extra"
|
|
);
|
|
assert_eq!(config.worker_configs.len(), 1);
|
|
assert!(
|
|
config.worker_configs.contains_key("default"),
|
|
"only the real worker config should be in worker_configs"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_no_alert_in_config_table_after_migration(db: Pool<Postgres>) {
|
|
// After the migration runs, no alert__* entries should remain in the config table
|
|
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM config WHERE name LIKE 'alert__%'")
|
|
.fetch_all(&db)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
rows.is_empty(),
|
|
"No alert entries should remain in config table after migration, found: {:?}",
|
|
rows.iter().map(|(n,)| n.as_str()).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
/// The declarative writers bypass the HTTP pre-write hook entirely, so this path owns
|
|
/// validating `github_app_webhook_base_url` itself. A rejected value must not be
|
|
/// half-applied: nothing at all may be written, or an unreachable receiver would be
|
|
/// persisted and only surface much later as a repository falling back to polling.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
let before = count_global_settings(&db).await;
|
|
|
|
let mut desired = BTreeMap::new();
|
|
desired.insert(
|
|
"base_url".to_string(),
|
|
serde_json::json!("https://wm.example.com"),
|
|
);
|
|
desired.insert(
|
|
"github_app_webhook_base_url".to_string(),
|
|
serde_json::json!("httpss://hooks.example.com"),
|
|
);
|
|
|
|
let err = windmill_common::instance_config::sync_global_settings_declarative(
|
|
&db,
|
|
&BTreeMap::new(),
|
|
&desired,
|
|
)
|
|
.await
|
|
.expect_err("an invalid webhook base url must fail the sync");
|
|
assert!(
|
|
err.to_string().contains("github_app_webhook_base_url"),
|
|
"the error should name the offending setting, got: {err}"
|
|
);
|
|
|
|
// A non-string shape must be rejected too, not silently treated as "absent" and
|
|
// then persisted by the diff.
|
|
let mut wrong_type = BTreeMap::new();
|
|
wrong_type.insert(
|
|
"github_app_webhook_base_url".to_string(),
|
|
serde_json::json!(true),
|
|
);
|
|
windmill_common::instance_config::sync_global_settings_declarative(
|
|
&db,
|
|
&BTreeMap::new(),
|
|
&wrong_type,
|
|
)
|
|
.await
|
|
.expect_err("a non-string webhook base url must fail the sync");
|
|
|
|
assert_eq!(
|
|
count_global_settings(&db).await,
|
|
before,
|
|
"validation must run before anything is applied"
|
|
);
|
|
assert!(
|
|
get_global_setting(&db, "base_url").await.is_none(),
|
|
"the other settings in the same apply must not have been written either"
|
|
);
|
|
}
|
|
|
|
/// Same contract for the announcement banner: this path owns its validation, and a value
|
|
/// that lands here unchecked reaches every user's browser. A rejected banner must not be
|
|
/// half-applied either.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
let before = count_global_settings(&db).await;
|
|
|
|
let mut desired = BTreeMap::new();
|
|
desired.insert(
|
|
"base_url".to_string(),
|
|
serde_json::json!("https://wm.example.com"),
|
|
);
|
|
desired.insert(
|
|
"instance_banner".to_string(),
|
|
serde_json::json!({ "enabled": true, "message": "down", "link": "javascript:alert(1)" }),
|
|
);
|
|
|
|
let err = windmill_common::instance_config::sync_global_settings_declarative(
|
|
&db,
|
|
&BTreeMap::new(),
|
|
&desired,
|
|
)
|
|
.await
|
|
.expect_err("a javascript: banner link must fail the sync");
|
|
assert!(
|
|
err.to_string().contains("instance_banner"),
|
|
"the error should name the offending setting, got: {err}"
|
|
);
|
|
|
|
assert_eq!(
|
|
count_global_settings(&db).await,
|
|
before,
|
|
"validation must run before anything is applied"
|
|
);
|
|
assert!(
|
|
get_global_setting(&db, "base_url").await.is_none(),
|
|
"the other settings in the same apply must not have been written either"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool<Postgres>) {
|
|
clear_settings_and_configs(&db).await;
|
|
let before = count_global_settings(&db).await;
|
|
|
|
let mut desired = BTreeMap::new();
|
|
desired.insert(
|
|
"max_token_expiration_days".to_string(),
|
|
serde_json::json!("7.0"),
|
|
);
|
|
|
|
let err = windmill_common::instance_config::sync_global_settings_declarative(
|
|
&db,
|
|
&BTreeMap::new(),
|
|
&desired,
|
|
)
|
|
.await
|
|
.expect_err("a ceiling the token routes cannot read must fail the sync");
|
|
assert!(
|
|
err.to_string().contains("max_token_expiration_days"),
|
|
"the error should name the offending setting, got: {err}"
|
|
);
|
|
assert_eq!(count_global_settings(&db).await, before);
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<Postgres>) {
|
|
// The declarative writers (the sync-config CLI, the operator's ConfigMap
|
|
// sync) do not run the HTTP layer's pre-write hook, so an origin list that
|
|
// cannot be parsed would persist here, be dropped at boot, and leave the
|
|
// instance with no restriction at all.
|
|
clear_settings_and_configs(&db).await;
|
|
let before = count_global_settings(&db).await;
|
|
|
|
for bad in [
|
|
serde_json::json!([""]),
|
|
serde_json::json!(["https://a.example,https://b.example"]),
|
|
serde_json::json!("null"),
|
|
] {
|
|
let mut desired = BTreeMap::new();
|
|
desired.insert(
|
|
"http_route_default_allowed_origins".to_string(),
|
|
bad.clone(),
|
|
);
|
|
let err = windmill_common::instance_config::sync_global_settings_declarative(
|
|
&db,
|
|
&BTreeMap::new(),
|
|
&desired,
|
|
)
|
|
.await
|
|
.expect_err(&format!("{bad} must fail the sync"));
|
|
assert!(
|
|
err.to_string()
|
|
.contains("http_route_default_allowed_origins"),
|
|
"the error should name the offending setting, got: {err}"
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
count_global_settings(&db).await,
|
|
before,
|
|
"a rejected sync must not have persisted anything"
|
|
);
|
|
|
|
// A usable list still syncs.
|
|
let mut desired = BTreeMap::new();
|
|
desired.insert(
|
|
"http_route_default_allowed_origins".to_string(),
|
|
serde_json::json!(["https://app.example.com"]),
|
|
);
|
|
windmill_common::instance_config::sync_global_settings_declarative(
|
|
&db,
|
|
&BTreeMap::new(),
|
|
&desired,
|
|
)
|
|
.await
|
|
.expect("a valid origin list must sync");
|
|
}
|