Files
windmill/backend/tests/resource_versioning.rs
hugocasa c09de594b6 feat: version resource values with history, diff and restore (#10596)
* feat: version resource values with history, diff and restore

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: record resource versions in a trigger so direct writes are covered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show the selected version's value and tighten history write access

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: gate resource version recording in trigger WHEN clauses

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: clear a resource's past versions, and address review nits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restore the displayed version and keep author attribution on pooled writes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope history to the selected workspace and gate clearing on ownership

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate restore on write access and clearing on the signed-in workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): share the version-history row between script and resource drawers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: trim resource version history in the monitor sweep, not on write

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): match the script versions drawer shell for resource history

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(frontend): highlight version values instead of mounting monaco

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): match the script drawer's code preview presentation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: rank version trim in one windowed pass instead of a correlated delete

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): treat the newest version as current by position

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: gate the resource version trim to an hourly sweep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: unnest the version row action and correct the trim cadence docs

* perf: cap the history listing and use sets for reference lookup

* feat: warn when a resource is written more than 60 times a minute

* fix: lower the resource write advisory to 20 per minute

* fix: discard stale history loads and never diff against an unread value

* fix: correct the write advisory boundary and document the eviction lock

* fix: read history and the live value from one snapshot

* refactor: read the drawer's diff baseline from versions, not the live resource

* fix: open the history drawer with no version selected

* fix: disarm the clear confirmation and clear the pane when the selection moves

* fix: explain the missing diff and drop a guard that can no longer fire

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:32:18 +02:00

216 lines
7.7 KiB
Rust

//! Regression tests for resource value history.
//!
//! Four properties that a later refactor could plausibly undo, each with a cost
//! that is invisible until it bites:
//! - `state` and `cache` resources are excluded. They are rewritten by every job
//! that calls `setState` or caches a result, so versioning them would grow the
//! table without bound and without anyone asking for it.
//! - an unchanged value mints nothing, which is what keeps no-op saves, renames and
//! description edits out of the history.
//! - restore appends the old value as a new version rather than rewinding, so the
//! history stays append-only and the restore is itself attributable.
//! - writes that never touch the resource handlers are still recorded. Variable
//! renames, workspace forks and native integrations all write `resource` directly,
//! and a rename that changes path and value in one statement used to leave the
//! newest version holding the pre-rename value while the UI labelled it "Current".
//! This is why recording lives in a database trigger rather than the handlers.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
b.header("Authorization", "Bearer RVER_ADMIN_TOKEN")
}
async fn history(base: &str, path: &str) -> anyhow::Result<Vec<Value>> {
let body: Value = authed(client().get(format!("{base}/resources/history/p/{path}")))
.send()
.await?
.json()
.await?;
Ok(body["versions"].as_array().cloned().unwrap_or_default())
}
#[sqlx::test(fixtures("resource_versioning"))]
async fn test_resource_version_history(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api/w/rver-ws", server.addr.port());
let path = "u/rver-admin/db";
let resp = authed(client().post(format!("{base}/resources/create")))
.json(&json!({
"path": path,
"value": {"host": "one"},
"resource_type": "postgresql"
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create should succeed");
assert_eq!(history(&base, path).await?.len(), 1, "create mints v1");
authed(client().post(format!("{base}/resources/update_value/{path}")))
.json(&json!({"value": {"host": "two"}}))
.send()
.await?;
let versions = history(&base, path).await?;
assert_eq!(versions.len(), 2, "a changed value mints a version");
authed(client().post(format!("{base}/resources/update_value/{path}")))
.json(&json!({"value": {"host": "two"}}))
.send()
.await?;
assert_eq!(
history(&base, path).await?.len(),
2,
"re-saving an identical value must not mint a version"
);
// Restore the oldest version: the value goes back, the history grows.
let oldest = versions.last().unwrap()["id"].as_i64().unwrap();
let resp = authed(client().post(format!("{base}/resources/history/restore/v/{oldest}")))
.send()
.await?;
assert_eq!(resp.status(), 200, "restore should succeed");
let restored: Value = authed(client().get(format!("{base}/resources/get_value/{path}")))
.send()
.await?
.json()
.await?;
assert_eq!(restored["host"], "one", "restore brings the old value back");
assert_eq!(
history(&base, path).await?.len(),
3,
"restore appends rather than rewinding"
);
// Machine-written resource types stay out of the history entirely.
for internal in ["state", "cache"] {
let internal_path = format!("u/rver-admin/{internal}_item");
authed(client().post(format!("{base}/resources/create")))
.json(&json!({
"path": internal_path,
"value": {"a": 1},
"resource_type": internal
}))
.send()
.await?;
assert_eq!(
history(&base, &internal_path).await?.len(),
0,
"{internal} resources must not be versioned"
);
}
Ok(())
}
/// A version's reported dangling references. Pins that `$jsonvar:` is matched on its own prefix
/// rather than colliding with `$var:` — the two share a suffix, so a check written with a
/// substring test instead of a prefix test would report every `$jsonvar:` as missing.
#[sqlx::test(fixtures("resource_versioning"))]
async fn test_missing_references(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api/w/rver-ws", server.addr.port());
let path = "u/rver-admin/refs";
authed(client().post(format!("{base}/variables/create")))
.json(&json!({
"path": "u/rver-admin/present",
"value": "v",
"is_secret": false,
"description": ""
}))
.send()
.await?;
authed(client().post(format!("{base}/resources/create")))
.json(&json!({
"path": path,
"value": {
"a": "$jsonvar:u/rver-admin/present",
"b": "$jsonvar:u/rver-admin/absent",
"c": "$var:u/rver-admin/present",
"d": "$res:u/rver-admin/absent"
},
"resource_type": "postgresql"
}))
.send()
.await?;
let id = history(&base, path).await?[0]["id"].as_i64().unwrap();
let version: Value = authed(client().get(format!("{base}/resources/history/v/{id}")))
.send()
.await?
.json()
.await?;
let mut missing: Vec<&str> = version["missing_references"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
missing.sort();
assert_eq!(
missing,
vec!["$jsonvar:u/rver-admin/absent", "$res:u/rver-admin/absent"],
"only the references that do not resolve should be reported"
);
Ok(())
}
/// Writes that bypass the resource handlers entirely. A variable rename changes a linked
/// resource's path and value in one statement; the cascading FK moves the history to the new
/// path, so without trigger-level recording the newest row would keep the pre-rename value and
/// disagree with the resource it describes.
#[sqlx::test(fixtures("resource_versioning"))]
async fn test_direct_writes_are_recorded(db: Pool<Postgres>) -> anyhow::Result<()> {
let path = "u/rver-admin/direct";
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by, edited_at)
VALUES ('rver-ws', $1, '{\"h\":\"one\"}', 'postgresql', 'rver-admin', now())",
)
.bind(path)
.execute(&db)
.await?;
let renamed = "u/rver-admin/direct_renamed";
sqlx::query(
"UPDATE resource SET path = $1, value = '{\"h\":\"two\"}', edited_at = now()
WHERE workspace_id = 'rver-ws' AND path = $2",
)
.bind(renamed)
.bind(path)
.execute(&db)
.await?;
let (count, newest): (i64, Option<String>) = sqlx::query_as(
"SELECT count(*), (SELECT value->>'h' FROM resource_version
WHERE workspace_id = 'rver-ws' AND path = $1
ORDER BY id DESC LIMIT 1)
FROM resource_version WHERE workspace_id = 'rver-ws' AND path = $1",
)
.bind(renamed)
.fetch_one(&db)
.await?;
assert_eq!(count, 2, "the direct insert and the rename both record");
assert_eq!(
newest.as_deref(),
Some("two"),
"newest version must match the live value after a rename that also changed it"
);
Ok(())
}