Files
windmill/backend/tests/app_conversation_scoping.rs
T
hugocasaandClaude Opus 5.5 07d4f7fe61 feat: app conversations through the memory_id query parameter (#12025)
* feat: app conversations through the memory_id query parameter, chat-mode flows included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: regenerate the mcp session instructions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: type backendAsync's memoryId option in the eval diagnostics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin git-sync hub scripts to windmill-cli 1.849.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: memoryId option on the sync backend call too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: bump the ui builder artifact for the memoryId option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: shared-utils 1.0.14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: cli takes shared-utils 1.0.14 for the memoryId option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: pin the app conversation scoping and the memoryId signatures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: assert the ownership refusal, not any refusal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: match the memoryId option in the cli wmill.ts signature tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: file an app's turns as the deployed flow's conversation, preview included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:51:35 +01:00

127 lines
4.5 KiB
Rust

//! An app viewer names a flow's memory with `run_query_params.memory_id`, and that id must
//! never reach another viewer's memory: a chat-mode flow files the turn in a conversation
//! owned by the viewer, and any other flow keys its memory per app and viewer.
//!
//! Users from the `base` fixture:
//! test-user (admin, token SECRET_TOKEN)
//! test-user-2 (developer, token SECRET_TOKEN_2)
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const TOKENS: [&str; 2] = ["SECRET_TOKEN", "SECRET_TOKEN_2"];
const MEMORY_ID: &str = "8b7c3d52-0a4e-4f6b-9d1a-2c3e4f5a6b7c";
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
/// Deploys a flow and an app that runs it.
async fn setup(ws: &str, flow_path: &str, chat: bool) -> anyhow::Result<()> {
let client = reqwest::Client::new();
let resp = authed(client.post(format!("{ws}/flows/create")), TOKENS[0])
.json(&json!({
"path": flow_path,
"summary": "",
"value": { "modules": [], "chat_input_enabled": chat },
"schema": { "type": "object", "properties": {} }
}))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
let resp = authed(client.post(format!("{ws}/apps/create")), TOKENS[0])
.json(&json!({
"path": format!("{flow_path}_app"),
"summary": "",
"value": {},
"policy": {
"execution_mode": "anonymous",
"triggerables_v2": {
format!("run:flow/{flow_path}"): { "static_inputs": {}, "one_of_inputs": {} }
}
}
}))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
Ok(())
}
async fn run(
ws: &str,
token: &str,
flow_path: &str,
preview: bool,
) -> anyhow::Result<reqwest::Response> {
let mut body = json!({
"component": "run",
"path": format!("flow/{flow_path}"),
"args": { "user_message": "hi" },
"run_query_params": { "memory_id": MEMORY_ID }
});
if preview {
body["force_viewer_static_fields"] = json!({});
}
Ok(authed(
reqwest::Client::new().post(format!("{ws}/apps_u/execute_component/{flow_path}_app")),
token,
)
.json(&body)
.send()
.await?)
}
// One test, one database: the server caches flow versions by id across the tests of a
// process, and two fresh databases number their first flow version alike.
#[sqlx::test(fixtures("base"))]
async fn a_viewer_cannot_reach_anothers_flow_memory(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let ws = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let plain = "u/test-user/plain";
setup(&ws, plain, false).await?;
let mut keys = vec![];
for token in TOKENS {
let resp = run(&ws, token, plain, false).await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
let job_id: uuid::Uuid = resp.text().await?.parse()?;
let key: Option<String> =
sqlx::query_scalar("SELECT flow_status->>'memory_id' FROM v2_job_status WHERE id = $1")
.bind(job_id)
.fetch_one(&db)
.await?;
keys.push(key.expect("the run keeps a memory key"));
}
assert_ne!(
keys[0], keys[1],
"two viewers naming one id share no memory"
);
assert!(
keys.iter().all(|k| k != MEMORY_ID),
"the named id is never the key"
);
let chat = "u/test-user/chat";
setup(&ws, chat, true).await?;
// From the app editor's preview: the app still runs the deployed flow, so the
// conversation is the deployed flow's.
let resp = run(&ws, TOKENS[0], chat, true).await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
let (owner, is_test): (String, bool) =
sqlx::query_as("SELECT created_by, is_test FROM flow_conversation WHERE id = $1::uuid")
.bind(MEMORY_ID)
.fetch_one(&db)
.await?;
assert_eq!((owner.as_str(), is_test), ("test-user", false));
let resp = run(&ws, TOKENS[1], chat, false).await?;
// The first turn never runs here, so a refusal for the turn still running would pass too.
let body = resp.text().await?;
assert!(body.contains("belongs to another user"), "{body}");
Ok(())
}