#![allow(clippy::expect_used, clippy::unwrap_used)]
mod harness;
use serde_json::json;
use tailscale_mcp::config::API_BASE_URL_ENV;
use tailscale_rest::credentials::TAILNET_ENV;
use tailscale_rest::fake::{FakeControlPlane, Response};
use harness::{Setup, TEST_API_KEY};
const DEVICES: &str = "/api/v2/tailnet/-/devices";
#[tokio::test]
async fn a_tool_call_reaches_the_control_plane_with_the_session_credential() {
let harness = Setup::new()
.toolsets("tailnet-devices")
.api_answers("GET", DEVICES, Response::json(json!({"devices": []})))
.await
.start()
.await;
let answer = harness.call_ok("tailnet_device_list", json!({})).await;
assert_eq!(answer["devices"], json!([]));
let request = harness.control_plane().only_request();
assert_eq!(request.path, DEVICES);
assert_eq!(
request.authorization(),
Some(format!("Bearer {TEST_API_KEY}").as_str()),
"the credential the session was built with should be the one on the wire"
);
harness.shutdown().await;
}
#[tokio::test]
async fn the_tailnet_a_tool_acts_on_is_the_one_the_environment_named() {
let path = "/api/v2/tailnet/example.com/devices";
let harness = Setup::new()
.toolsets("tailnet-devices")
.env(TAILNET_ENV, "example.com")
.api_answers("GET", path, Response::json(json!({"devices": []})))
.await
.start()
.await;
harness.call_ok("tailnet_device_list", json!({})).await;
assert_eq!(harness.control_plane().only_request().path, path);
harness.shutdown().await;
}
#[tokio::test]
async fn an_answer_over_the_session_cap_is_refused_rather_than_truncated() {
let big = json!({
"devices": (0..50)
.map(|n| json!({"name": format!("example-node-{n}")}))
.collect::<Vec<_>>(),
});
let harness = Setup::new()
.toolsets("tailnet-devices")
.env("TAILSCALE_MCP_MAX_RESULT_BYTES", "128")
.api_answers("GET", DEVICES, Response::json(&big))
.await
.start()
.await;
let error = harness.call_err("tailnet_device_list", json!({})).await;
assert_eq!(error["code"], json!("result_too_large"));
let message = error["message"].as_str().expect("a message");
assert!(
message.contains(DEVICES) && message.contains("more than 128 bytes"),
"the refusal should be the transport's, before the body was parsed: {error:#?}"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_control_plane_that_cannot_be_reached_is_the_surface_being_unavailable() {
let address = {
let fake = FakeControlPlane::start().await.expect("a loopback socket");
fake.base_url().to_owned()
};
let harness = Setup::new()
.toolsets("tailnet-devices")
.env(API_BASE_URL_ENV, &address)
.start()
.await;
let error = harness.call_err("tailnet_device_list", json!({})).await;
assert_eq!(error["code"], json!("backend_unavailable"));
assert!(
error["message"]
.as_str()
.is_some_and(|m| m.contains("the control plane is unavailable")),
"the refusal should say which backend: {error:#?}"
);
harness.shutdown().await;
}
#[tokio::test]
async fn without_a_credential_the_tailnet_tools_are_absent_and_the_session_is_told_why() {
let harness = Setup::new()
.toolsets("local-status,tailnet-devices")
.without_credentials()
.start()
.await;
let offered = harness.tool_names().await;
assert!(
offered.iter().any(|name| name.starts_with("tailscale_")),
"the local tools are unaffected: {offered:?}"
);
assert!(
!offered.iter().any(|name| name.starts_with("tailnet_")),
"no tailnet tool should be offered: {offered:?}"
);
assert!(
harness
.instructions()
.contains("tailnet surface is not available"),
"a session that cannot reach the control plane should be told so"
);
harness.shutdown().await;
}
#[tokio::test]
async fn both_device_identifier_forms_reach_the_device_they_name() {
for id in ["n1111111CNTRL", "123456789"] {
let path = format!("/api/v2/device/{id}");
let harness = Setup::new()
.toolsets("tailnet-devices")
.api_answers("GET", &path, Response::json(json!({"nodeId": id})))
.await
.start()
.await;
let answer = harness
.call_ok("tailnet_device_get", json!({"device_id": id}))
.await;
assert_eq!(answer["nodeId"], json!(id));
assert_eq!(harness.control_plane().only_request().path, path);
harness.shutdown().await;
}
}
#[tokio::test]
async fn field_selection_and_filters_travel_as_the_api_spells_them() {
let harness = Setup::new()
.toolsets("tailnet-devices")
.api_answers("GET", DEVICES, Response::json(json!({"devices": []})))
.await
.start()
.await;
harness
.call_ok(
"tailnet_device_list",
json!({"fields": "all", "filters": {"isEphemeral": "true", "tags": "tag:example"}}),
)
.await;
let query = harness.control_plane().only_request().query.clone();
assert_eq!(
query.get("fields").map(String::as_str),
Some("all"),
"{query:?}"
);
assert_eq!(
query.get("isEphemeral").map(String::as_str),
Some("true"),
"a filter travels as its own query parameter: {query:?}"
);
assert_eq!(
query.get("tags").map(String::as_str),
Some("tag:example"),
"{query:?}"
);
harness.shutdown().await;
}
#[tokio::test]
async fn the_window_slices_the_listing_without_changing_its_shape() {
let devices: Vec<_> = (0..5)
.map(|n| json!({"nodeId": format!("n111111{n}CNTRL")}))
.collect();
let harness = Setup::new()
.toolsets("tailnet-devices")
.api_answers("GET", DEVICES, Response::json(json!({"devices": devices})))
.await
.start()
.await;
let answer = harness
.call_ok("tailnet_device_list", json!({"offset": 1, "limit": 2}))
.await;
let listed = answer["devices"].as_array().expect("still a device list");
assert_eq!(listed.len(), 2, "{answer:#?}");
assert_eq!(listed[0]["nodeId"], json!("n1111111CNTRL"));
assert_eq!(
answer["window"],
json!({"total": 5, "returned": 2, "offset": 1, "limit": 2}),
"a windowed answer says how much it left out, and what was asked for"
);
let query = harness.control_plane().only_request().query.clone();
assert!(!query.contains_key("limit"), "{query:?}");
assert!(!query.contains_key("offset"), "{query:?}");
harness.shutdown().await;
}
#[tokio::test]
async fn an_unwindowed_listing_is_the_api_answer_and_nothing_else() {
let harness = Setup::new()
.toolsets("tailnet-devices")
.api_answers(
"GET",
DEVICES,
Response::json(json!({"devices": [{"nodeId": "n1111111CNTRL"}]})),
)
.await
.start()
.await;
let answer = harness.call_ok("tailnet_device_list", json!({})).await;
assert_eq!(answer, json!({"devices": [{"nodeId": "n1111111CNTRL"}]}));
harness.shutdown().await;
}
#[tokio::test]
async fn a_posture_attribute_round_trips_through_the_control_plane() {
let device = "n1111111CNTRL";
let attribute = format!("/api/v2/device/{device}/attributes/custom:example");
let harness = Setup::new()
.toolsets("tailnet-devices")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers("POST", &attribute, Response::empty())
.await
.api_answers(
"GET",
&format!("/api/v2/device/{device}/attributes"),
Response::json(json!({
"attributes": {"custom:example": 80},
"expiries": {"custom:example": "2027-01-01T00:00:00Z"},
})),
)
.await
.api_answers("DELETE", &attribute, Response::empty())
.await
.start()
.await;
harness
.call_ok(
"tailnet_device_attribute_set",
json!({"device_id": device, "attribute_key": "custom:example", "value": 80,
"expiry": "2027-01-01T00:00:00Z"}),
)
.await;
let read = harness
.call_ok(
"tailnet_device_attributes_get",
json!({"device_id": device}),
)
.await;
assert_eq!(read["attributes"]["custom:example"], json!(80));
let deleted = harness
.call_ok(
"tailnet_device_attribute_delete",
json!({"device_id": device, "attribute_key": "custom:example"}),
)
.await;
assert_eq!(deleted["done"], json!("attribute deleted"));
let sent = harness.control_plane().recorded();
assert_eq!(sent.len(), 3, "one call each: {sent:#?}");
assert_eq!(
sent[0].json(),
json!({"value": 80, "expiry": "2027-01-01T00:00:00Z"}),
"the body is Tailscale's own shape, and carries only what was given"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_batched_attribute_update_refuses_a_key_the_api_would_reject() {
let harness = Setup::new()
.toolsets("tailnet-devices")
.tier(tailscale_mcp::meta::Tier::Write)
.api_answers(
"PATCH",
"/api/v2/tailnet/-/device-attributes",
Response::empty(),
)
.await
.start()
.await;
let error = harness
.call_err(
"tailnet_device_attributes_update",
json!({"nodes": {"n1111111CNTRL": {"node:os": "linux"}}}),
)
.await;
assert_eq!(error["code"], json!("invalid_args"));
assert!(
error["message"]
.as_str()
.is_some_and(|m| m.contains("custom:") && m.contains("n1111111CNTRL")),
"the refusal should name the key and the device: {error:#?}"
);
assert_eq!(
harness.control_plane().request_count(),
0,
"nothing should have been sent"
);
harness.shutdown().await;
}
#[tokio::test]
async fn the_split_dns_update_and_replace_forms_are_different_calls() {
let path = "/api/v2/tailnet/-/dns/split-dns";
let domains = json!({"domains": {"example.com": ["10.0.0.1"]}});
let harness = Setup::new()
.toolsets("tailnet-dns")
.tier(tailscale_mcp::meta::Tier::Write)
.api_answers(
"PATCH",
path,
Response::json(json!({"example.com": ["10.0.0.1"]})),
)
.await
.api_answers(
"PUT",
path,
Response::json(json!({"example.com": ["10.0.0.1"]})),
)
.await
.start()
.await;
harness
.call_ok("tailnet_dns_split_update", domains.clone())
.await;
harness.call_ok("tailnet_dns_split_replace", domains).await;
let sent = harness.control_plane().recorded();
assert_eq!(
sent.iter().map(|r| r.method.as_str()).collect::<Vec<_>>(),
["PATCH", "PUT"],
"the merge and the replace reach different verbs: {sent:#?}"
);
for request in &sent {
assert_eq!(request.json(), json!({"example.com": ["10.0.0.1"]}));
}
harness.shutdown().await;
}
#[tokio::test]
async fn reading_the_policy_answers_with_its_version_and_the_document_as_written() {
let hujson = "{\n // a comment, which JSON does not have\n \"acls\": [],\n}";
let harness = Setup::new()
.toolsets("tailnet-policy")
.api_answers(
"GET",
"/api/v2/tailnet/-/acl",
Response::text("application/hujson", hujson).with_header("etag", "\"e0b2816b418\""),
)
.await
.start()
.await;
let answer = harness.call_ok("tailnet_policy_get", json!({})).await;
assert_eq!(answer["format"], json!("hujson"));
assert_eq!(answer["etag"], json!("\"e0b2816b418\""));
assert_eq!(
answer["policy"],
json!(hujson),
"the comments are the part a person wrote, so the document comes back as written"
);
assert_eq!(
harness.control_plane().only_request().header("accept"),
Some("application/hujson"),
"and that is what was asked for"
);
harness.shutdown().await;
}
#[tokio::test]
async fn the_json_spelling_is_asked_for_and_comes_back_parsed() {
let harness = Setup::new()
.toolsets("tailnet-policy")
.api_answers(
"GET",
"/api/v2/tailnet/-/acl",
Response::text("application/json", "{\"acls\": [{\"action\": \"accept\"}]}"),
)
.await
.start()
.await;
let answer = harness
.call_ok("tailnet_policy_get", json!({"format": "json"}))
.await;
assert_eq!(answer["format"], json!("json"));
assert_eq!(
answer["policy"],
json!({"acls": [{"action": "accept"}]}),
"asked for as JSON, so handed back parsed rather than as a string"
);
assert_eq!(
harness.control_plane().only_request().header("accept"),
Some("application/json")
);
harness.shutdown().await;
}
#[tokio::test]
async fn the_detailed_report_is_asked_for_without_an_accept_and_is_its_own_shape() {
let harness = Setup::new()
.toolsets("tailnet-policy")
.api_answers(
"GET",
"/api/v2/tailnet/-/acl",
Response::json(json!({
"acl": "eyJhY2xzIjogW119",
"warnings": ["a group is not syncing"],
"errors": [],
})),
)
.await
.start()
.await;
let answer = harness
.call_ok("tailnet_policy_get", json!({"details": true}))
.await;
assert_eq!(
answer["details"]["warnings"][0],
json!("a group is not syncing")
);
assert_eq!(answer["details"]["acl"], json!("eyJhY2xzIjogW119"));
assert!(answer.get("policy").is_none(), "{answer:#?}");
assert!(answer.get("format").is_none(), "{answer:#?}");
let request = harness.control_plane().only_request();
assert_eq!(
request.query.get("details").map(String::as_str),
Some("true")
);
assert_eq!(
request.header("accept"),
Some("*/*"),
"no format should have been asked for: {:?}",
request.headers
);
harness.shutdown().await;
}
#[tokio::test]
async fn asking_for_a_format_and_the_report_at_once_is_refused() {
let harness = Setup::new()
.toolsets("tailnet-policy")
.api_answers(
"GET",
"/api/v2/tailnet/-/acl",
Response::json(json!({"acl": "", "warnings": [], "errors": []})),
)
.await
.start()
.await;
let error = harness
.call_err(
"tailnet_policy_get",
json!({"details": true, "format": "json"}),
)
.await;
assert_eq!(error["code"], json!("invalid_args"));
assert_eq!(harness.control_plane().request_count(), 0);
harness.shutdown().await;
}
#[tokio::test]
async fn a_policy_write_without_a_guard_never_reaches_the_control_plane() {
let harness = Setup::new()
.toolsets("tailnet-policy")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers(
"POST",
"/api/v2/tailnet/-/acl",
Response::text("application/hujson", "{}"),
)
.await
.start()
.await;
let error = harness
.call_err("tailnet_policy_set", json!({"policy": "{\"acls\": []}"}))
.await;
assert_eq!(error["code"], json!("invalid_args"));
assert_eq!(
harness.control_plane().request_count(),
0,
"nothing should have been sent"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_stale_version_is_a_conflict_that_says_to_read_it_again() {
let harness = Setup::new()
.toolsets("tailnet-policy")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers(
"POST",
"/api/v2/tailnet/-/acl",
Response::status(
412,
json!({"message": "precondition failed, invalid old hash"}),
),
)
.await
.start()
.await;
let error = harness
.call_err(
"tailnet_policy_set",
json!({"policy": "{\"acls\": []}", "etag": "\"stale\""}),
)
.await;
assert_eq!(error["code"], json!("conflict"));
assert_eq!(error["status"], json!(412));
assert!(
error["hint"]
.as_str()
.is_some_and(|h| h.contains("tailnet_policy_get")),
"the remedy is to read it again: {error:#?}"
);
assert_eq!(
harness.control_plane().only_request().header("if-match"),
Some("\"stale\""),
"and the version the caller gave is what was on the wire"
);
harness.shutdown().await;
}
#[tokio::test]
async fn writing_over_the_default_is_the_other_way_to_pass_the_guard() {
let harness = Setup::new()
.toolsets("tailnet-policy")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers(
"POST",
"/api/v2/tailnet/-/acl",
Response::text("application/hujson", "{}"),
)
.await
.start()
.await;
harness
.call_ok(
"tailnet_policy_set",
json!({"policy": "{\n // written by hand\n \"acls\": [],\n}",
"over_default": true}),
)
.await;
let request = harness.control_plane().only_request();
assert_eq!(request.header("if-match"), Some("\"ts-default\""));
assert_eq!(
request.header("content-type"),
Some("application/hujson"),
"a document given as a string is sent as HuJSON, comments and all"
);
assert!(
request.body.contains("// written by hand"),
"and unescaped: {:?}",
request.body
);
harness.shutdown().await;
}
#[tokio::test]
async fn the_two_validation_modes_are_told_apart_by_what_was_given() {
let path = "/api/v2/tailnet/-/acl/validate";
let harness = Setup::new()
.toolsets("tailnet-policy")
.api_answers("POST", path, Response::empty())
.await
.api_answers("POST", path, Response::empty())
.await
.start()
.await;
let tests = json!([{"src": "someone@example.com", "accept": ["10.0.0.1:80"]}]);
let passed = harness
.call_ok("tailnet_policy_validate", json!({"tests": tests}))
.await;
assert_eq!(
passed["passed"],
json!(true),
"an empty answer is a pass, and says so rather than answering nothing"
);
harness
.call_ok("tailnet_policy_validate", json!({"policy": {"acls": []}}))
.await;
let sent = harness.control_plane().recorded();
assert!(
sent[0].json().is_array(),
"the tests go as an array: {sent:#?}"
);
assert!(
sent[1].json().is_object(),
"the policy goes as an object: {sent:#?}"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_posture_integration_secret_reaches_the_control_plane_as_client_secret() {
let harness = Setup::new()
.toolsets("tailnet-posture")
.tier(tailscale_mcp::meta::Tier::Write)
.api_answers(
"POST",
"/api/v2/tailnet/-/posture/integrations",
Response::json(json!({"id": "pi-example", "provider": "falcon"})),
)
.await
.start()
.await;
let answer = harness
.call_ok(
"tailnet_posture_integration_create",
json!({"provider": "falcon", "client_secret": "example-secret-value"}),
)
.await;
assert_eq!(answer["id"], json!("pi-example"));
assert_eq!(
harness.control_plane().only_request().json()["clientSecret"],
json!("example-secret-value"),
"the secret should have reached the control plane as `clientSecret`"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_posture_integration_update_that_changes_nothing_is_refused_before_it_is_sent() {
let harness = Setup::new()
.toolsets("tailnet-posture")
.tier(tailscale_mcp::meta::Tier::Write)
.api_answers(
"PATCH",
"/api/v2/posture/integrations/pi-example",
Response::json(json!({"id": "pi-example", "provider": "falcon"})),
)
.await
.start()
.await;
let error = harness
.call_err(
"tailnet_posture_integration_update",
json!({"integration_id": "pi-example"}),
)
.await;
assert_eq!(error["code"], json!("invalid_args"));
assert_eq!(
harness.control_plane().request_count(),
0,
"nothing should have been sent"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_key_listing_states_its_scope_on_the_wire_whether_or_not_it_was_asked_to() {
let path = "/api/v2/tailnet/-/keys";
let harness = Setup::new()
.toolsets("tailnet-keys")
.api_answers("GET", path, Response::json(json!({"keys": []})))
.await
.api_answers("GET", path, Response::json(json!({"keys": []})))
.await
.start()
.await;
harness.call_ok("tailnet_key_list", json!({})).await;
harness
.call_ok("tailnet_key_list", json!({"all": false}))
.await;
let asked = harness.control_plane().recorded();
assert_eq!(
asked[0].query.get("all").map(String::as_str),
Some("true"),
"an unasked listing should still say which listing it wants"
);
assert_eq!(
asked[1].query.get("all").map(String::as_str),
Some("false"),
"and the narrower question stays available"
);
harness.shutdown().await;
}
#[tokio::test]
async fn an_invitation_refused_for_the_credential_says_which_credential_it_needs() {
let path = "/api/v2/tailnet/-/user-invites";
let harness = Setup::new()
.toolsets("tailnet-invites")
.tier(tailscale_mcp::meta::Tier::Write)
.api_answers(
"POST",
path,
Response::status(403, json!({"message": "calling user not found"})),
)
.await
.start()
.await;
let error = harness
.call_err(
"tailnet_user_invite_create",
json!({"invites": [{"role": "member", "email": "someone@example.com"}]}),
)
.await;
let hint = error["hint"].as_str().expect("a hint");
assert!(
hint.contains("owned by a user") && hint.contains("OAuth client"),
"the refusal should name the requirement, not just repeat the status: {hint}"
);
assert_eq!(error["status"], 403, "and still report what came back");
harness.shutdown().await;
}
#[tokio::test]
async fn a_service_call_asks_the_other_spelling_when_the_documented_one_is_not_there() {
let documented = "/api/v2/tailnet/-/services/svc:example";
let go_client = "/api/v2/tailnet/-/vip-services/svc:example";
let harness = Setup::new()
.toolsets("tailnet-services")
.api_answers(
"GET",
documented,
Response::status(404, json!({"message": "not found"})),
)
.await
.api_answers(
"GET",
go_client,
Response::json(json!({"name": "svc:example", "addrs": ["100.64.0.1"]})),
)
.await
.start()
.await;
let answer = harness
.call_ok(
"tailnet_service_get",
json!({"service_name": "svc:example"}),
)
.await;
assert_eq!(
answer["name"],
json!("svc:example"),
"the second path's answer"
);
let asked = harness.control_plane().recorded();
assert_eq!(
asked.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
vec![documented, go_client],
"the documented spelling should be tried first, and only then the other"
);
harness.shutdown().await;
}
#[tokio::test]
async fn withdrawing_a_host_needs_the_destructive_tier_and_approving_does_not() {
let path = "/api/v2/tailnet/-/services/svc:example/device/n1111111CNTRL/approved";
let harness = Setup::new()
.toolsets("tailnet-services")
.tier(tailscale_mcp::meta::Tier::Write)
.api_answers("POST", path, Response::json(json!({"approved": true})))
.await
.start()
.await;
harness
.call_ok(
"tailnet_service_approval_set",
json!({"service_name": "svc:example", "device_id": "n1111111CNTRL", "approved": true}),
)
.await;
let error = harness
.call_err(
"tailnet_service_approval_set",
json!({"service_name": "svc:example", "device_id": "n1111111CNTRL", "approved": false}),
)
.await;
assert_eq!(error["code"], "not_permitted");
assert_eq!(
harness.control_plane().request_count(),
1,
"the refusal should happen here, not at the control plane"
);
harness.shutdown().await;
}
#[tokio::test]
async fn the_one_paginated_listing_follows_its_cursor_to_the_end() {
let path = "/api/v2/organizations/example.com/tailnets";
let harness = Setup::new()
.toolsets("tailnet-org")
.api_answers_once(
"GET",
path,
Response::json(json!({
"tailnets": [{"id": "T111111CNTRL"}],
"cursor": "page-two",
"totalCount": 2
})),
)
.await
.api_answers_once(
"GET",
path,
Response::json(json!({"tailnets": [{"id": "T222222CNTRL"}], "totalCount": 2})),
)
.await
.start()
.await;
let answer = harness
.call_ok(
"tailnet_organization_tailnet_list",
json!({"organization": "example.com"}),
)
.await;
assert_eq!(
answer["tailnets"],
json!([{"id": "T111111CNTRL"}, {"id": "T222222CNTRL"}]),
"both pages, in order"
);
assert_eq!(answer["totalCount"], json!(2));
assert_eq!(
answer.get("cursor"),
None,
"a walk that finished has nothing left to hand back"
);
let asked = harness.control_plane().recorded();
assert_eq!(asked.len(), 2);
assert_eq!(
asked[0].query.get("limit").map(String::as_str),
Some("100"),
"the API's maximum page size, and its default"
);
assert_eq!(asked[0].query.get("cursor"), None);
assert_eq!(
asked[1].query.get("cursor").map(String::as_str),
Some("page-two"),
"the second page should be asked for with what the first answered"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_tailnet_cannot_be_deleted_by_the_dash_that_means_our_own() {
let harness = Setup::new()
.toolsets("tailnet-org")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers("DELETE", "/api/v2/tailnet/-", Response::empty())
.await
.start()
.await;
for given in ["-", " - "] {
let error = harness
.call_err(
"tailnet_organization_tailnet_delete",
json!({"tailnet": given, "confirm": true}),
)
.await;
assert_eq!(error["code"], "invalid_args", "for {given:?}: {error}");
assert!(
error["message"]
.as_str()
.is_some_and(|m| m.contains("Name the one you mean")),
"the refusal should say what to do instead: {error}"
);
}
assert_eq!(
harness.control_plane().request_count(),
0,
"and nothing should have reached the control plane"
);
let harness2 = Setup::new()
.toolsets("tailnet-org")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers("DELETE", "/api/v2/tailnet/T111111CNTRL", Response::empty())
.await
.start()
.await;
let answer = harness2
.call_ok(
"tailnet_organization_tailnet_delete",
json!({"tailnet": "T111111CNTRL", "confirm": true}),
)
.await;
assert_eq!(answer["done"], json!("tailnet deleted"));
harness.shutdown().await;
harness2.shutdown().await;
}
#[tokio::test]
async fn deleting_this_nodes_own_device_needs_the_call_to_say_so() {
let ours = "/api/v2/device/n1111111CNTRL";
let harness = Setup::new()
.toolsets("tailnet-devices")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers("DELETE", ours, Response::empty())
.await
.start()
.await;
let error = harness
.call_err(
"tailnet_device_delete",
json!({"device_id": "n1111111CNTRL"}),
)
.await;
assert_eq!(error["code"], "confirmation_required");
assert_eq!(
harness.control_plane().request_count(),
0,
"the refusal happens here; nothing should reach the control plane"
);
let answer = harness
.call_ok(
"tailnet_device_delete",
json!({"device_id": "n1111111CNTRL", "confirm": true}),
)
.await;
assert_eq!(answer["done"], json!("deleted"));
harness.shutdown().await;
}
#[tokio::test]
async fn the_same_call_against_another_device_is_an_ordinary_one() {
let theirs = "/api/v2/device/n2222222CNTRL";
let harness = Setup::new()
.toolsets("tailnet-devices")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers("DELETE", theirs, Response::empty())
.await
.start()
.await;
let answer = harness
.call_ok(
"tailnet_device_delete",
json!({"device_id": "n2222222CNTRL"}),
)
.await;
assert_eq!(answer["done"], json!("deleted"));
assert_eq!(harness.control_plane().request_count(), 1);
harness.shutdown().await;
}
#[tokio::test]
async fn this_node_is_recognised_by_its_numeric_id_too() {
let harness = Setup::new()
.toolsets("tailnet-devices")
.tier(tailscale_mcp::meta::Tier::Destructive)
.api_answers(
"GET",
"/api/v2/device/n1111111CNTRL",
Response::json(json!({"nodeId": "n1111111CNTRL", "id": "92960230385"})),
)
.await
.api_answers("DELETE", "/api/v2/device/92960230385", Response::empty())
.await
.start()
.await;
let error = harness
.call_err("tailnet_device_delete", json!({"device_id": "92960230385"}))
.await;
assert_eq!(
error["code"], "confirmation_required",
"the numeric id names this node just as the node id does"
);
let before = harness.control_plane().request_count();
let error = harness
.call_err("tailnet_device_delete", json!({"device_id": "92960230385"}))
.await;
assert_eq!(error["code"], "confirmation_required");
assert_eq!(
harness.control_plane().request_count(),
before,
"the numeric id does not change while the node id stays the same"
);
let answer = harness
.call_ok(
"tailnet_device_delete",
json!({"device_id": "92960230385", "confirm": true}),
)
.await;
assert_eq!(answer["done"], json!("deleted"));
harness.shutdown().await;
}
#[tokio::test]
async fn this_node_is_recognised_by_any_name_the_api_accepts() {
let harness = Setup::new()
.toolsets("tailnet-devices")
.tier(tailscale_mcp::meta::Tier::Destructive)
.start()
.await;
for name in [
"n1111111CNTRL",
"100.64.0.1",
"workstation.example-tailnet.ts.net",
] {
let error = harness
.call_err("tailnet_device_expire", json!({"device_id": name}))
.await;
assert_eq!(
error["code"], "confirmation_required",
"`{name}` should be recognised as this node: {error:#?}"
);
}
assert_eq!(harness.control_plane().request_count(), 0);
harness.shutdown().await;
}
#[tokio::test]
async fn with_no_local_surface_there_is_no_identity_and_the_call_is_ordinary() {
let ours = "/api/v2/device/n1111111CNTRL";
let harness = Setup::new()
.toolsets("tailnet-devices")
.tier(tailscale_mcp::meta::Tier::Destructive)
.without_cli()
.api_answers("DELETE", ours, Response::empty())
.await
.start()
.await;
let answer = harness
.call_ok(
"tailnet_device_delete",
json!({"device_id": "n1111111CNTRL"}),
)
.await;
assert_eq!(answer["done"], json!("deleted"));
harness.shutdown().await;
}
#[tokio::test]
async fn an_argument_the_tool_does_not_take_is_refused_by_name() {
let harness = Setup::new()
.toolsets("tailnet-invites")
.tier(tailscale_mcp::meta::Tier::Write)
.start()
.await;
for (args, (named, accepted)) in [
(
json!({"device_id": "n2222222CNTRL", "invites": [{"multiUse": true}], "emails": []}),
("`emails`", "`invites`"),
),
(
json!({"device_id": "n2222222CNTRL", "invites": [{"email": "a@example.com"}, {"multi_use": true}]}),
("`invites[1].multi_use`", "`multiUse`"),
),
] {
let error = harness.call_err("tailnet_device_invite_create", args).await;
assert_eq!(error["code"], "invalid_args", "{error:#?}");
let message = error["message"].as_str().unwrap_or_default();
assert!(message.contains(named), "{message}");
assert!(
message.contains(accepted),
"the accepted names help the retry: {message}"
);
}
assert_eq!(harness.control_plane().request_count(), 0);
harness.shutdown().await;
}
#[tokio::test]
async fn a_free_form_document_still_carries_whatever_it_carries() {
let harness = Setup::new()
.toolsets("tailnet-settings")
.tier(tailscale_mcp::meta::Tier::Write)
.api_answers("PATCH", "/api/v2/tailnet/-/settings", Response::empty())
.await
.start()
.await;
let settings = json!({"routeSelection": "regional-routing", "aSettingFromNextYear": 1});
harness
.call_ok("tailnet_settings_update", json!({"settings": settings}))
.await;
assert_eq!(harness.control_plane().only_request().json(), settings);
harness.shutdown().await;
}