Skip to main content

ignition_core/client/
mod.rs

1//! The gateway HTTP seam: a coarse [`GatewayApi`] trait so actions never
2//! touch reqwest types, plus the production [`ReqwestGatewayApi`].
3//!
4//! LOCKED: the trait uses `async_trait` (research Open Question 2,
5//! resolved) — dyn-compatible today, ubiquitous. The trait stays COARSE —
6//! one method per capability, not per endpoint — so Phase 2 grows it
7//! without churn.
8//!
9//! Auth-header rule (verified against a live 8.3.6 gateway, 02-RESEARCH
10//! §Auth Model): a token credential sends `X-Ignition-API-Token`; a basic
11//! credential sends `Authorization: Basic <b64>`; NEVER both — enforced by
12//! a match in [`ReqwestGatewayApi::apply_auth`], the ONE place
13//! [`Secret::expose`] is called outside the secret module (the
14//! grep-auditable redaction boundary; CORE-02).
15//!
16//! Basic is loudly demoted there: valid Basic credentials → 401 on every
17//! 8.3 `/data` route (verified), so each use warns — never silently
18//! retried. Note gateway-info itself DOES require auth under 8.3 default
19//! security (header-less → 401, re-verified live 2026-08-21 — the 83-api
20//! collection's `auth: none` tag does not hold); a `None` credential
21//! proceeds header-less and classifies the answer.
22//!
23//! Redirects are never followed (`Policy::none()`): an uncommissioned
24//! gateway 302s EVERYTHING to `/welcome` and the default follow would
25//! render the wizard's HTML as a 200 (02-RESEARCH Pitfall 6). The 3xx is
26//! classified by [`classify`] instead.
27//!
28//! Every request runs the pipeline: build URL → apply auth (opt-in) →
29//! send (transport error → `Network`) → [`classify`] → parse the body.
30//! Nothing ever calls `.json()` on a response that skipped `classify()`.
31
32use std::path::Path;
33use std::time::Duration;
34
35pub mod adopt;
36pub mod apicall;
37pub mod backup;
38pub(crate) mod classify;
39pub mod config_resources;
40pub mod connections;
41pub mod deploy_api;
42pub mod diagnostics;
43pub mod eam;
44pub mod gan;
45pub mod idp;
46pub mod license;
47pub mod logs;
48pub mod metrics;
49pub mod mode;
50pub mod projects;
51pub mod query;
52pub mod redundancy;
53pub mod resources;
54pub mod restart;
55pub mod scan;
56pub mod scripts_codec;
57pub mod sessions;
58pub mod status;
59pub mod tags;
60pub mod threads;
61pub mod trial;
62pub mod version;
63pub mod webdev;
64pub mod workspace;
65
66/// The deploy-facing scan-lock and scan calls (a trait of their own, so
67/// [`GatewayApi`] and its test doubles stay untouched).
68pub use deploy_api::DeployApi;
69
70use crate::client::connections::GatewayConnection;
71use crate::client::diagnostics::BundleStatusWire;
72use crate::client::eam::{
73    DeleteOutcome, EamHistoryItem, EamScheduledTask, EamTaskRecord, ModifyOutcome,
74};
75use crate::client::gan::GanStatusWire;
76use crate::client::license::LicenseStatusWire;
77use crate::client::logs::{LogDownload, LogEntry, LogQuery, LoggerInfo};
78use crate::client::metrics::{CurrentGauges, PerformanceCharts, ThreadCounts};
79use crate::client::projects::{
80    ExportMeta, ImportOutcome, ProjectCopy, ProjectCreate, ProjectModify, ProjectRecord,
81    ProjectRenameBody,
82};
83use crate::client::query::ListEnvelope;
84use crate::client::redundancy::RedundancyStatusWire;
85use crate::client::restart::SecurityProperties;
86use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
87use crate::client::status::{ModuleInfo, Overview, StatusPing};
88use crate::client::tags::{TagProviderCreate, TagProviderModify, TagProviderRecord};
89use crate::client::trial::{BannerSet, TrialWire};
90use crate::client::version::GatewayInfo;
91use crate::client::webdev::{RouteBody, RouteProbe};
92use crate::config::{Credential, Profile, Secret};
93use crate::error::CoreError;
94
95/// GET path of the gateway-info capability.
96const GATEWAY_INFO_PATH: &str = "/data/api/v1/gateway-info";
97
98/// One capability per method — coarse on purpose. Phase 2 adds status,
99/// modules, metrics, … as methods here; actions never see reqwest types.
100///
101/// (All impl bodies live in the ONE `impl GatewayApi for
102/// ReqwestGatewayApi` block below: Rust rejects a second impl block of
103/// the same trait for the same type, so the per-capability files own the
104/// models + verified path constants and this block owns the delegation.)
105#[async_trait::async_trait]
106pub trait GatewayApi: Send + Sync {
107    /// Fetch `/data/api/v1/gateway-info`.
108    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError>;
109    /// Fetch `/data/api/v1/overview` (authed) — platform + runtime.
110    async fn overview(&self) -> Result<Overview, CoreError>;
111    /// Fetch `/StatusPing` **header-less** (auth=false) — the
112    /// unauthenticated readiness anchor: it must keep answering when
113    /// credentials are broken or absent and mid-restart (02-02).
114    async fn status_ping(&self) -> Result<StatusPing, CoreError>;
115    /// Fetch `/data/api/v1/modules/healthy` (`quarantined = false`) or
116    /// `/modules/quarantined` (`true`) with the standard list params.
117    async fn modules(
118        &self,
119        quarantined: bool,
120        query: &query::ListQuery,
121    ) -> Result<ListEnvelope<ModuleInfo>, CoreError>;
122    /// DELETE `/data/api/v1/modules/uninstall` (Phase 19,
123    /// D-19-03/D-19-04) — uninstalls ONE module by its GATEWAY id.
124    /// **Denial rides HTTP 200**: the gateway always answers 200, so
125    /// the verdict comes from the body's `success`/`failedUninstalls`,
126    /// never the status alone (live-verified: a stock module, a bogus
127    /// id, and a still-mounted module all answer 200 with
128    /// `success:false`). Never batches — exactly one id per call
129    /// (D-19-04 sidesteps the never-observed mixed-batch shape
130    /// entirely). `module_id` is the REGISTRY slug (never sent on the
131    /// wire) so a denial can name both ids without a reverse lookup.
132    async fn uninstall_module(
133        &self,
134        gateway_module_id: &str,
135        module_id: &str,
136    ) -> Result<(), CoreError>;
137    /// Fetch `/data/api/v1/systemPerformance/currentGauges` (authed) —
138    /// cpu in PERCENT (contrast [`Overview::cpu`], a 0–1 fraction).
139    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError>;
140    /// Fetch `/data/api/v1/systemPerformance/charts` (authed) — historic
141    /// cpu/heap/non-heap datapoints (epoch-ms timestamps).
142    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError>;
143    /// Fetch `/data/api/v1/systemPerformance/threads` (authed) — thread
144    /// execution counts (running/waiting/timedWaiting/blocked).
145    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError>;
146    /// Fetch `/data/api/v1/diagnostics/threads/dump/formatted` (authed)
147    /// — the Gateway web UI's Diagnostics→Threads page data: one entry
148    /// per JVM thread (state, daemon, cpuUsage, stacktrace, monitors).
149    /// Every field tolerant — the spec's own "not all fields
150    /// guaranteed" caveat.
151    async fn thread_dump(&self) -> Result<threads::FormattedThreadDump, CoreError>;
152    /// Fetch `/data/api/v1/diagnostics/threads/deadlocks` (authed) —
153    /// the JVM-reported deadlocked thread ids (empty = healthy).
154    async fn thread_deadlocks(&self) -> Result<threads::DeadlocksWire, CoreError>;
155    /// Fetch `/data/api/v1/designers` (authed) — active Designer
156    /// sessions (02-03, HLTH-08).
157    async fn designers(
158        &self,
159        query: &query::ListQuery,
160    ) -> Result<ListEnvelope<DesignerInfo>, CoreError>;
161    /// Fetch `/data/perspective/api/v1/sessions/` (authed) — the EXACT
162    /// trailing slash is the contract (Pitfall 8; module-scoped prefix).
163    async fn perspective_sessions(
164        &self,
165        query: &query::ListQuery,
166    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError>;
167    /// Fetch `/data/vision/api/v1/clients` (authed) — active Vision
168    /// clients (designer shape + `tagCount`).
169    async fn vision_clients(
170        &self,
171        query: &query::ListQuery,
172    ) -> Result<ListEnvelope<VisionClient>, CoreError>;
173    /// DELETE `/data/perspective/api/v1/sessions?sessionId=<id>` (+ an
174    /// optional `message` shown to the session's user) — NO trailing
175    /// slash on the DELETE (spec). Audit-logged server-side.
176    async fn terminate_perspective_session(
177        &self,
178        id: &str,
179        message: Option<&str>,
180    ) -> Result<(), CoreError>;
181    /// DELETE `/data/vision/api/v1/client/{id}` — terminate a Vision
182    /// client. Audit-logged server-side.
183    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError>;
184    /// DELETE `/data/api/v1/designer/{id}` — prune a Designer session.
185    /// Audit-logged server-side.
186    async fn prune_designer(&self, id: &str) -> Result<(), CoreError>;
187    /// Fetch `/data/api/v1/resources/list/ignition/database-connection`
188    /// (authed) — the web UI's Connections→Databases poll (HLTH-05).
189    /// `healthchecks` is raw passthrough (LOW-confidence populated
190    /// shape, research Open Question 1).
191    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
192    /// Fetch `/data/api/v1/resources/list/ignition/opc-connection`
193    /// (authed) — the Connections→OPC poll (HLTH-06), same family.
194    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
195    /// Fetch `/data/api/v1/logs` (authed) with [`LogQuery`] — the tail
196    /// primitive: `startTime` (epoch ms) is the cursor, no server push
197    /// exists (02-04, HLTH-03). The query ALWAYS carries an explicit
198    /// `limit` (Pitfall 9 — the server default is unlimited).
199    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError>;
200    /// GET `/data/api/v1/logs/download` (authed, per-request 120 s
201    /// timeout) — a SQLite `.idb` archive, returned byte-for-byte with
202    /// the `Content-Disposition` filename and `Content-Type`. NEVER
203    /// zipped/extracted (Pitfall 7; Don't-Hand-Roll table).
204    async fn logs_download(&self) -> Result<LogDownload, CoreError>;
205    /// Fetch `/data/api/v1/logs/loggers` (authed) — the logger registry
206    /// (HLTH-04; ~1250 loggers on a fresh gateway).
207    async fn loggers(
208        &self,
209        query: &query::ListQuery,
210    ) -> Result<ListEnvelope<LoggerInfo>, CoreError>;
211    /// POST `/data/api/v1/logs/loggers/{loggerName}?level=X` (authed,
212    /// empty body, NO CSRF — verified: token mutations need none).
213    /// Logger names are Java identifiers `[A-Za-z0-9._]` — URL-safe,
214    /// embedded as-is. Audit-logged server-side.
215    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError>;
216    /// POST `/data/api/v1/logs/levelreset` (authed, empty body) — reset
217    /// all custom logger levels to defaults. Audit-logged server-side.
218    async fn reset_logger_levels(&self) -> Result<(), CoreError>;
219    /// POST `/data/api/v1/restart-tasks/restart?confirm=true` (authed,
220    /// empty body, NO CSRF — token mutations need none) — the one big
221    /// red button. The gateway answers 200 with the literal body `true`
222    /// almost immediately; the ~40 s wait is poller-side (02-05's
223    /// `restart --wait` owns it). Audit-logged server-side.
224    async fn restart(&self) -> Result<(), CoreError>;
225    /// POST `/data/api/v1/scan/projects` (authed) — the harmless
226    /// project-rescan write probe (`ign doctor --check-write`; 2xx =
227    /// write permission, 403 = read-only token).
228    async fn scan_projects(&self) -> Result<(), CoreError>;
229    /// GET `/data/api/v1/resources/ignition/security-properties`
230    /// (authed) — the security config singleton; the doctor's
231    /// permissions deep-dive surfaces `readPermissions`/
232    /// `writePermissions` verbatim (passthrough shape).
233    async fn security_properties(&self) -> Result<SecurityProperties, CoreError>;
234    /// GET `/system/webdev/<route>` (authed) reporting the RAW HTTP
235    /// status — the doctor's route-presence probe (404 = absent;
236    /// 200/401/403 = exists). Deliberately NOT classified: presence
237    /// IS the answer; only transport failures are errors.
238    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError>;
239    /// POST `/system/webdev/{project}/cli/{route}` (authed + any
240    /// caller headers — scriptExec's secret gate) with the action
241    /// JSON. classify() runs for transport/status errors, BUT the
242    /// 200 BODY is the route envelope `{ok, data|error}` — WebDev
243    /// IGNORES `status`, so denials ride HTTP 200: `ok:false` maps
244    /// `error.code` onto the taxonomy (05-03), `ok:true` returns
245    /// `data`. HTTP 200 alone is NEVER a success verdict.
246    async fn webdev_route_call(
247        &self,
248        project: &str,
249        route: &str,
250        body: &serde_json::Value,
251        extra_headers: &[(&str, &str)],
252    ) -> Result<serde_json::Value, CoreError>;
253    /// POST the route action with a PER-REQUEST timeout override —
254    /// the large-payload escape hatch (the `get_bytes`
255    /// `RequestBuilder::timeout` pattern, 02-04/09-05): the 30 s
256    /// client default would truncate the Phase-11 bulk exportTags
257    /// transfer (research Pitfall 6), so the bulk arms override with
258    /// [`crate::client::tags::TAGS_EXPORT_TIMEOUT`]. Default body =
259    /// the plain call — test doubles and any impl without an
260    /// override path ride the client default (the override only
261    /// lengthens the ceiling; request/response semantics are the
262    /// same classify + envelope parse).
263    async fn webdev_route_call_with_timeout(
264        &self,
265        project: &str,
266        route: &str,
267        body: &serde_json::Value,
268        extra_headers: &[(&str, &str)],
269        _timeout: Duration,
270    ) -> Result<serde_json::Value, CoreError> {
271        self.webdev_route_call(project, route, body, extra_headers)
272            .await
273    }
274    /// POST the route's `{"action":"version"}` handshake and
275    /// discriminate ([`webdev::RouteProbe`]): 200-body-ok →
276    /// `Present{route_version}`, 405 → `Absent` (the live-proven 8.3
277    /// marker — NOT 404), 402 → `Unlicensed`, 401/403 → `AuthGated`,
278    /// 200-body-denial → `Denied{code,message}`. Deliberately NOT
279    /// classified — the status code IS the answer (the
280    /// `webdev_route_status` precedent); only transport failures and
281    /// shapes the enum has no variant for (wizard redirects, 503
282    /// restarts, foreign 404s) are errors.
283    async fn webdev_route_probe(
284        &self,
285        project: &str,
286        route: &str,
287        extra_headers: &[(&str, &str)],
288    ) -> Result<RouteProbe, CoreError>;
289    /// GET `/data/api/v1/projects/list` (authed) — every RUNNABLE
290    /// project with inheritance info from the items themselves
291    /// (PROJ-01; standard list params, `limit=-1` UI convention).
292    async fn projects(
293        &self,
294        query: &query::ListQuery,
295    ) -> Result<ListEnvelope<ProjectRecord>, CoreError>;
296    /// GET `/data/api/v1/projects/find/{name}` (authed, name
297    /// percent-encoded per segment) — one project's full record; 404 →
298    /// `NotFound` via classify (this doubles as 03-02's collision
299    /// pre-check).
300    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError>;
301    /// POST `/data/api/v1/projects` (authed, JSON body) — create. Ok
302    /// classification IS the success contract (create's response body
303    /// is unverified LOW — the restart `literal true` precedent;
304    /// callers that want data re-`find`). Audit-logged server-side.
305    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError>;
306    /// POST `/data/api/v1/projects/copy` (authed, body exactly
307    /// `{"fromName":…,"toName":…}`) — an exact copy of all resources.
308    /// Audit-logged server-side.
309    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError>;
310    /// POST `/data/api/v1/projects/rename/{name}` (authed, body
311    /// `{"name": "<new>"}`) — native rename, NOT copy+delete.
312    /// Audit-logged server-side.
313    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError>;
314    /// PUT `/data/api/v1/projects/{name}` (authed, JSON body WITHOUT
315    /// `name`) — modify/reparent (`set --parent` IS the inheritance
316    /// move). Audit-logged server-side.
317    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError>;
318    /// DELETE `/data/api/v1/projects/{name}?confirm=true` (authed,
319    /// empty body) — the server's own confirmation guard rides the
320    /// QUERY string (Pitfall 8: BOTH layers, always — the CLI's
321    /// `--yes` and the wire's `confirm=true`). Audit-logged
322    /// server-side.
323    async fn project_delete(&self, name: &str) -> Result<(), CoreError>;
324    /// GET `/data/api/v1/projects/export/{name}` (authed, per-request
325    /// [`projects::PROJECT_EXPORT_TIMEOUT`] = 120 s) — the project ZIP
326    /// STREAMED to `out` chunk-by-chunk via `bytes_stream` (Pitfall 2:
327    /// NO `Vec<u8>` accumulation anywhere), with the disposition
328    /// filename + byte count in the meta. Audit-relevant only as a
329    /// read (exports never mutate).
330    async fn project_export_to_file(&self, name: &str, out: &Path)
331    -> Result<ExportMeta, CoreError>;
332    /// POST `/data/api/v1/projects/import/{name}?overwrite=<bool>`
333    /// (authed, per-request [`projects::PROJECT_IMPORT_TIMEOUT`] =
334    /// 300 s) — the ZIP as the RAW body with `Content-Type:
335    /// application/zip` and a known `Content-Length` (a `Vec<u8>`
336    /// sidesteps the chunked-encoding question entirely — Pitfall 3's
337    /// timeout is handled by the override). Synchronous, no job IDs
338    /// (verified). Audit-logged server-side.
339    async fn project_import(
340        &self,
341        name: &str,
342        zip: Vec<u8>,
343        overwrite: bool,
344    ) -> Result<ImportOutcome, CoreError>;
345    /// GET `/data/api/v1/resources/list/ignition/tag-provider`
346    /// (authed) — the tag-provider resource list: full records
347    /// incl. `config`, `metrics.tagCount`, `healthchecks.status`
348    /// (05-04, TAGS-01 — the NATIVE provider seam; no deployed
349    /// route involved). Standard list params (limit=-1, the UI
350    /// convention).
351    async fn tag_provider_list(
352        &self,
353        query: &query::ListQuery,
354    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError>;
355    /// GET `/data/api/v1/tags/export?provider=<P>&type=json` (authed,
356    /// per-request [`tags::TAGS_EXPORT_TIMEOUT`] = 300 s) — the native
357    /// export of one provider's whole tag tree, as the gateway's own
358    /// bytes. Read-only; `provider` rides the query, never the path.
359    /// The default body goes through [`Self::api_call`] (the
360    /// `webdev_route_call_with_timeout` precedent: test doubles and any
361    /// impl without an override ride the client default); the real
362    /// client overrides it with the 300 s ceiling.
363    async fn tags_export_provider(&self, provider: &str) -> Result<Vec<u8>, CoreError> {
364        let call = apicall::ApiCallRequest {
365            method: "GET".to_string(),
366            path: tags::TAGS_EXPORT_PATH.to_string(),
367            body: None,
368            headers: Vec::new(),
369            query: vec![
370                ("provider".to_string(), provider.to_string()),
371                ("type".to_string(), "json".to_string()),
372            ],
373        };
374        let data = self.api_call(&call).await?;
375        Ok(data.data.get().as_bytes().to_vec())
376    }
377    /// POST `/data/api/v1/tags/import?provider=<P>&type=json&collisionPolicy=<policy>`
378    /// (authed, per-request [`tags::TAGS_IMPORT_TIMEOUT`] = 300 s), with
379    /// `path=<parent>` only when `path` is non-empty: the native JSON
380    /// `body` (`{"tags":[...]}`) as `application/octet-stream`. The gateway
381    /// writes its own files (D-08). The answer is read fail-closed by
382    /// [`tags::TagImportAnswer::parse`]; the caller decides what a non-clean
383    /// answer means. The default body goes through [`Self::api_call`] (the
384    /// [`Self::tags_export_provider`] precedent); the real client overrides
385    /// it with the 300 s ceiling.
386    async fn tags_import(
387        &self,
388        provider: &str,
389        path: &str,
390        policy: tags::CollisionPolicy,
391        body: Vec<u8>,
392    ) -> Result<tags::TagImportAnswer, CoreError> {
393        let text = String::from_utf8(body).map_err(|_| CoreError::InvalidInput {
394            reason: "a tags/import body must be UTF-8 JSON".to_string(),
395        })?;
396        let call = apicall::ApiCallRequest {
397            method: "POST".to_string(),
398            path: tags::TAGS_IMPORT_PATH.to_string(),
399            body: Some(text),
400            headers: vec![(
401                "Content-Type".to_string(),
402                "application/octet-stream".to_string(),
403            )],
404            query: tags_import_query(provider, path, policy),
405        };
406        let data = self.api_call(&call).await?;
407        tags::TagImportAnswer::parse(data.data.get().as_bytes())
408    }
409    /// GET `/data/api/v1/resources/find/ignition/tag-provider/{name}`
410    /// (authed, name percent-encoded per segment) — one provider's
411    /// full record incl. the `signature` the chained delete needs.
412    /// 404 → `NotFound` via classify.
413    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError>;
414    /// POST `/data/api/v1/resources/ignition/tag-provider` (authed)
415    /// with a JSON **ARRAY** body of create records — the
416    /// live-proven create shape (05-RESEARCH provider table).
417    /// Audit-logged server-side.
418    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError>;
419    /// PUT `/data/api/v1/resources/ignition/tag-provider` (authed) with a
420    /// JSON **ARRAY** body of modify records (26-10): the shape capture C11
421    /// recorded, the signature from a fresh find. The default body goes
422    /// through [`Self::api_call`] (the [`Self::tags_export_provider`]
423    /// precedent); the real client overrides it with the deploy-scoped
424    /// resource write, so a stale or missing signature is
425    /// `resource_signature_rejected` (D3) and a `success:false` answer is an
426    /// error. Audit-logged server-side.
427    async fn tag_provider_modify(&self, body: &[TagProviderModify]) -> Result<(), CoreError> {
428        let text = serde_json::to_string(body).map_err(|err| {
429            CoreError::Internal(format!(
430                "the tag provider modify body did not serialize: {err}"
431            ))
432        })?;
433        let call = apicall::ApiCallRequest {
434            method: "PUT".to_string(),
435            path: tags::TAG_PROVIDERS_CREATE_PATH.to_string(),
436            body: Some(text),
437            headers: vec![("Content-Type".to_string(), "application/json".to_string())],
438            query: Vec::new(),
439        };
440        self.api_call(&call).await.map(|_| ())
441    }
442    /// DELETE `/data/api/v1/resources/ignition/tag-provider/{name}/{signature}`
443    /// (authed, both segments percent-encoded) — delete-by-signature;
444    /// the signature comes from find. Audit-logged server-side.
445    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError>;
446    /// GET `/data/api/v1/trial` — the trial state, live-verified
447    /// UNAUTHENTICATED on 8.3.3 + 8.3.6 (both trial states): auth
448    /// headers ride ONLY when the client carries a credential (fresh
449    /// rigs have none — the version-command degradation precedent,
450    /// rig-family edition).
451    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError>;
452    /// GET `/data/api/v1/overview/banners` — the trial cross-check
453    /// (severity/expireTime semantics, Pitfall 7). Same conditional
454    /// auth as [`Self::trial_status_wire`].
455    async fn banners(&self) -> Result<BannerSet, CoreError>;
456    /// POST `/data/api/v1/trial` (authed, empty body) — the trial
457    /// RESET, tier 0 of the ladder: a token credential plausibly
458    /// satisfies it without CSRF (token mutations need none — the
459    /// restart/set-logger precedent). The 2xx body IS the fresh
460    /// [`TrialWire`] (live-observed). NOTE (live-discovered state
461    /// gate): the gateway 403s resets on a NON-expired trial — the
462    /// action layer pre-checks expiry.
463    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError>;
464    /// GET `/data/api/v1/backup?type={roaming|all}` (authed,
465    /// [`backup::BACKUP_TIMEOUT`] = 300 s, `Accept:
466    /// application/octet-stream`) — the portable gwbk STREAMED to
467    /// `out` chunk-by-chunk through the 03-02 `download_to_file`
468    /// pipeline (the ONE streaming body-consumption site — never a
469    /// `Vec<u8>`, Pitfall 2). Byte count + metadata ride out in
470    /// [`ExportMeta`] (04-04, RIG-04; 07-02 param-ized the type —
471    /// `Roaming` stays the caller default).
472    async fn backup_download(
473        &self,
474        out: &Path,
475        backup_type: backup::BackupType,
476    ) -> Result<ExportMeta, CoreError>;
477    /// POST `/data/api/v1/backup` (authed, [`backup::BACKUP_TIMEOUT`]
478    /// = 300 s) — the RESTORE: the gwbk bytes as a RAW
479    /// `application/octet-stream` body (NOT multipart — the postman
480    /// collection's exact shape) with the four scope params EXPLICIT
481    /// on the query string. Synchronous AND followed by a gateway
482    /// restart (Pitfall 6): the 2xx means the restore was ACCEPTED —
483    /// the actions layer owns the post-restore RUNNING wait. The
484    /// upload direction buffers by design (the import precedent).
485    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError>;
486    /// GET `/data/eam/api/v1/eam-tasks/history` (authed) — task run
487    /// history, the standard `{items, metadata}` envelope. `limit`
488    /// defaults to [`eam::EAM_HISTORY_DEFAULT_LIMIT`] (200 — EAM
489    /// history grows unboundedly; an explicit limit ALWAYS rides the
490    /// wire, the logs discipline). A stock (non-controller) gateway
491    /// 403s → [`CoreError::EamNotController`] via classify
492    /// (path-scoped message classification — never a misleading
493    /// `auth_rejected`).
494    async fn eam_task_history(
495        &self,
496        limit: Option<u32>,
497        search: Option<&str>,
498    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError>;
499    /// GET `/data/api/v1/resources/list/com.inductiveautomation.eam/
500    /// eam-tasks` (authed) — task DEFINITIONS through the standard
501    /// config-resource family (the tag-provider pattern; available
502    /// on stock gateways — no controller needed for definitions).
503    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError>;
504    /// GET `/data/api/v1/resources/find/com.inductiveautomation.eam/
505    /// eam-tasks/{name}` (authed) — one definition's full record
506    /// incl. the `scheduledTaskState` healthcheck
507    /// (`currentState`/`nextScheduled`/`owner` under `details`) and
508    /// the mutation `signature`. 404 → `NotFound` via classify.
509    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError>;
510    /// POST `/data/api/v1/resources/com.inductiveautomation.eam/
511    /// eam-tasks` (authed) with a JSON **ARRAY** body of one
512    /// definition record — the config-resource create shape (the
513    /// tag-provider precedent). Ok classification IS the success
514    /// contract (create's response body is unverified — the
515    /// project-create precedent; callers that want data re-find).
516    /// Audit-logged server-side.
517    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError>;
518    /// POST `/data/eam/api/v1/eam-tasks/force/{owner}/{name}` (authed,
519    /// empty body) — dispatch a task NOW. Live-proven success shape:
520    /// **204** (any 2xx is done — the route-status style; execution
521    /// OUTCOMES surface later in history as data, never on this
522    /// response). Runtime seam: the controller gate classifies.
523    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError>;
524    /// POST `/data/eam/api/v1/eam-tasks/suspend/{name}` (authed,
525    /// empty body) — suspend the task's scheduler trigger.
526    /// Live-proven success shape: **204**; the flag PERSISTS into
527    /// `config.profile.isSuspended` (10-LIVE-CAPTURES §1c/Decision 1).
528    /// Requires a live scheduler trigger — an OnDemand/untriggered
529    /// (or unknown-named, §7) task answers 500 Jetty HTML, which
530    /// classifies as `Internal` with the page's own message (no
531    /// honest 404 exists on this seam).
532    async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError>;
533    /// POST `/data/eam/api/v1/eam-tasks/resume/{name}` (authed,
534    /// empty body) — the inverse of [`Self::eam_task_suspend`]:
535    /// **204** success, `isSuspended` back to `false` (§1d). Unknown
536    /// names answer 500 HTML naming the task (§7).
537    async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError>;
538    /// POST `/data/eam/api/v1/eam-tasks/cancel/{name}` (authed,
539    /// empty body) — cancel a PENDING execution. Always **204** on
540    /// the captured shapes: nothing-pending AND unknown-name are
541    /// silent successes (§7) — cancel is never a name-validation
542    /// tool (the actions layer owns find-before-write).
543    async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError>;
544    /// GET `/data/eam/api/v1/eam-tasks/scheduled/{running}` (authed)
545    /// — the pending-execution read; `{running}` is the LITERAL word
546    /// `true`/`false` (§2). Answers the standard
547    /// `{items, metadata}` envelope; this method unwraps it to the
548    /// items ([`EamScheduledTask`] — 13 capture-locked keys,
549    /// `taskState` String vocabulary). A stock gateway 403s →
550    /// [`CoreError::EamNotController`] via the same path-scoped arm
551    /// as every runtime seam call.
552    async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError>;
553    /// PUT `/data/api/v1/resources/com.inductiveautomation.eam/
554    /// eam-tasks` (authed) with a single-element JSON **ARRAY**
555    /// carrying the FULL find record (settings included — omitting
556    /// `config.settings` ⇒ 422, the create trap) and the ORIGINAL
557    /// `signature`. The 200 body is the captured
558    /// [`ModifyOutcome`] `{success, changes[], problem}`
559    /// (§6a); `None` = a 2xx with an empty body (lenient). Rename via
560    /// PUT is NOT supported (§5: changed name + original signature ⇒
561    /// 404 — the actions layer must compose create-new + delete-old).
562    async fn eam_task_modify(
563        &self,
564        definition: &serde_json::Value,
565    ) -> Result<Option<ModifyOutcome>, CoreError>;
566    /// DELETE `/data/api/v1/resources/com.inductiveautomation.eam/
567    /// eam-tasks/{name}/{signature}` (authed, both segments
568    /// percent-encoded) with `?collection=core` ALWAYS (the
569    /// collection VALUE — `collection=eam-tasks` 404s, §3c) and
570    /// `confirm=true` only when the caller opts in: a lone-resource
571    /// delete SUCCEEDS without it (§3b) and the confirm-demand shape
572    /// is UNOBSERVED (§3d) — never hard-coded. The 200 body is the
573    /// captured [`DeleteOutcome`] (adds `references`). A signature
574    /// mismatch answers HTTP 500 + `problem` — see the
575    /// [`eam`] module docs for the recorded FINDING (not classified
576    /// here; slug decision belongs to 10-03/10-04).
577    async fn eam_task_delete(
578        &self,
579        name: &str,
580        signature: &str,
581        confirm: bool,
582    ) -> Result<DeleteOutcome, CoreError>;
583    /// The raw passthrough (09-03, EXT-01): send `call` with its
584    /// arbitrary method, caller headers, query pairs, and optional raw
585    /// body (ANY method — GET/DELETE bodies allowed, curl parity).
586    /// Auth rides [`ReqwestGatewayApi::apply_auth`] (the ONE
587    /// `Secret::expose` site — user auth-pattern headers are refused
588    /// by [`apicall::refuse_auth_headers`] at the CLI/action layer,
589    /// never stripped); classification rides the api-call pipeline
590    /// ([`ReqwestGatewayApi::send_and_classify_for_api`]), so an
591    /// unclassified gateway 4xx is `GatewayClientError` (exit 2,
592    /// verbatim capped body). The 2xx body returns VERBATIM
593    /// ([`apicall::ApiCallData`] — `RawValue` passthrough: no field
594    /// dropped, no value coerced, key order preserved); a non-JSON
595    /// 2xx body is the honest internal-class refusal. The usage
596    /// guards live in [`apicall`] and the action layer — BOTH run
597    /// them, so in-process callers cannot skip the checks.
598    async fn api_call(
599        &self,
600        call: &apicall::ApiCallRequest,
601    ) -> Result<apicall::ApiCallData, CoreError>;
602    /// GET `/data/api/v1/licenses` (authed) — the license inventory
603    /// (09-04). The wire model is PARTIAL-CURATED: the morning-check
604    /// skeleton typed, array elements + `details` passthrough (the
605    /// fresh-rig captures answered empty arrays — element shapes are
606    /// not capture-proven).
607    async fn license_status(&self) -> Result<LicenseStatusWire, CoreError>;
608    /// GET `/data/api/v1/redundancy` (authed) — the flat 11-field
609    /// redundancy status (09-04). Units are capture-locked at the
610    /// model (`uptime` ms-since-start wall-clock-proven; the
611    /// `lastSyncTimestamp` `-1` sentinel normalized via
612    /// [`redundancy::RedundancyStatusWire::last_sync_epoch_ms`]).
613    async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError>;
614    /// GET `/data/api/v1/overview/gan` (authed) — the 5-field GAN
615    /// summary (09-04); a non-GAN gateway's zero-connection body IS
616    /// the canonical capture.
617    async fn gan_status(&self) -> Result<GanStatusWire, CoreError>;
618    /// POST `/data/api/v1/diagnostics/bundle/generate` (authed, no
619    /// body) — start bundle generation (09-05). The 200 body IS the
620    /// fresh [`BundleStatusWire`] (live capture:
621    /// `{"state":"Generating"}`). Audit-logged server-side.
622    async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError>;
623    /// GET `/data/api/v1/diagnostics/bundle/status` (authed) — the
624    /// status poll: captured state vocabulary + `fileSize` (bytes,
625    /// absent while generating — 09-LIVE-CAPTURES §5).
626    async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError>;
627    /// GET `/data/api/v1/diagnostics/bundle/download` (authed,
628    /// per-request [`diagnostics::BUNDLE_DOWNLOAD_TIMEOUT`] = 300 s —
629    /// Pitfall 8: the 30 s client default would truncate MB-sized
630    /// bundles) — the ZIP STREAMED to `out` chunk-by-chunk through the
631    /// `download_to_file` pipeline (classify-first; NO `Vec<u8>`
632    /// anywhere). The `Content-Disposition` filename + `Content-Type`
633    /// ride the pipeline's [`ExportMeta`].
634    async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError>;
635    /// The base URL and `ssl_verify` this client sends every request with,
636    /// post env-overlay. A check on where a secret may travel reads this, so
637    /// it checks the URL the request actually goes to (26-REVIEW-D WR-D2).
638    /// `None` for a test double that has no URL of its own.
639    fn gateway_transport(&self) -> Option<(url::Url, bool)> {
640        None
641    }
642}
643
644/// Production [`GatewayApi`] over reqwest.
645pub struct ReqwestGatewayApi {
646    base: url::Url,
647    credential: Option<Credential>,
648    client: reqwest::Client,
649    /// Whether [`Self::apply_auth`] warns that Basic cannot authenticate
650    /// `/data` routes. False only for the WebDev-only Basic client of the
651    /// tagDelete route ([`Self::webdev_basic`], 26-18), whose route
652    /// requires Basic.
653    warn_basic: bool,
654    /// The `ssl_verify` the client was built with (reported by
655    /// [`GatewayApi::gateway_transport`]).
656    ssl_verify: bool,
657}
658
659impl ReqwestGatewayApi {
660    /// Build from a resolved profile (post env-overlay — the dispatch site
661    /// owns that precedence) and an optional credential (`None` = proceed
662    /// header-less; the gateway's answer is then classified — 401 under
663    /// 8.3 default security).
664    ///
665    /// Timeouts: 10s connect / 30s overall (per-class refinements land in
666    /// Phase 2). `ssl_verify = false` accepts invalid certs — dev-rig
667    /// only, per-profile, never global.
668    pub fn new(profile: &Profile, credential: Option<Credential>) -> Result<Self, CoreError> {
669        let client = build_client(profile.ssl_verify)?;
670        Ok(Self {
671            base: profile.url.clone(),
672            credential,
673            client,
674            warn_basic: true,
675            ssl_verify: profile.ssl_verify,
676        })
677    }
678
679    /// A WebDev-only Basic client (26-18, D-13): the tagDelete route is
680    /// `require-auth` and authenticates a Basic user of its user source, so
681    /// this client carries Basic and nothing else, and does not emit the
682    /// `/data` Basic warning. It is crate-visible: only
683    /// [`crate::deploy::tag_delete::TagDeleteClient`] builds one, and that
684    /// client calls WebDev routes only.
685    pub(crate) fn webdev_basic(
686        base: &url::Url,
687        ssl_verify: bool,
688        user: Secret,
689        password: Secret,
690    ) -> Result<Self, CoreError> {
691        Ok(Self {
692            base: base.clone(),
693            credential: Some(Credential::Basic(user, password)),
694            client: build_client(ssl_verify)?,
695            warn_basic: false,
696            ssl_verify,
697        })
698    }
699
700    /// Test constructor: base URL + credential, no profile needed.
701    pub fn for_tests(base_url: &str, credential: Option<Credential>) -> Self {
702        Self {
703            base: url::Url::parse(base_url).expect("test base URL parses"),
704            credential,
705            client: build_client(true).expect("test client builds"),
706            warn_basic: true,
707            ssl_verify: true,
708        }
709    }
710
711    /// The full request URL for `path` (bases are normalized to a trailing
712    /// slash; an absolute path replaces from root).
713    fn url_for(&self, path: &str) -> url::Url {
714        self.base.join(path).expect("base joins an absolute path")
715    }
716
717    /// The auth-header rule in ONE place: token XOR basic XOR neither — a
718    /// match, not if/if-else chains. [`Secret::expose`] is called at
719    /// exactly this site (the redaction boundary MOVED here in 02-01, not
720    /// duplicated).
721    ///
722    /// Basic carries a loud demotion warning: it cannot authenticate 8.3
723    /// `/data` routes (verified: valid commissioned credentials → 401) —
724    /// warn once per call, never silently retry (02-RESEARCH Auth §2).
725    fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
726        let mut request = request;
727        match &self.credential {
728            Some(Credential::Token(token)) => {
729                request = request.header("X-Ignition-API-Token", token.expose());
730            }
731            Some(Credential::Basic(user, password)) => {
732                if self.warn_basic {
733                    tracing::warn!(
734                        "Basic auth does not authenticate Ignition 8.3 /data routes \
735                         (verified: valid credentials → 401); use an API token"
736                    );
737                }
738                request = request.basic_auth(user.expose(), Some(password.expose()));
739            }
740            None => {}
741        }
742        request
743    }
744
745    /// GET `path` (with pre-built query `pairs` when given) → classify →
746    /// deserialize into `T`. `auth = false` fetches header-less (the
747    /// `/StatusPing` readiness probe, 02-02 — it must work with broken
748    /// credentials). Callers build pairs via `to_query_pairs()` so the
749    /// param-name mapping stays in the capability files.
750    async fn get_json<T: serde::de::DeserializeOwned>(
751        &self,
752        path: &str,
753        pairs: Option<&[(String, String)]>,
754        auth: bool,
755    ) -> Result<T, CoreError> {
756        let url = self.url_for(path);
757        let mut request = self.client.get(url.clone());
758        if let Some(pairs) = pairs {
759            request = request.query(&pairs);
760        }
761        if auth {
762            request = self.apply_auth(request);
763        }
764        let response = self.send_and_classify(request, &url).await?;
765        response.json::<T>().await.map_err(|err| {
766            CoreError::Internal(format!(
767                "response from {url} did not match the expected shape: {err}"
768            ))
769        })
770    }
771
772    /// GET `path` → classify → read the response as BYTES plus the
773    /// `Content-Disposition` filename and `Content-Type` — the
774    /// archive-download pipeline (02-04). `timeout` overrides the 30 s
775    /// client default PER REQUEST (a large `.idb` archive must not be
776    /// truncated) — `RequestBuilder::timeout`, not a second client.
777    async fn get_bytes(&self, path: &str, timeout: Duration) -> Result<LogDownload, CoreError> {
778        let url = self.url_for(path);
779        let request = self.client.get(url.clone()).timeout(timeout);
780        let request = self.apply_auth(request);
781        let response = self.send_and_classify(request, &url).await?;
782        let filename = response
783            .headers()
784            .get(reqwest::header::CONTENT_DISPOSITION)
785            .and_then(|value| value.to_str().ok())
786            .and_then(logs::filename_from_content_disposition);
787        let content_type = response
788            .headers()
789            .get(reqwest::header::CONTENT_TYPE)
790            .and_then(|value| value.to_str().ok())
791            .map(str::to_string);
792        let bytes = response.bytes().await.map_err(|err| CoreError::Network {
793            url: url.to_string(),
794            source: Some(err),
795            observation: None,
796        })?;
797        Ok(LogDownload {
798            bytes: bytes.to_vec(),
799            filename,
800            content_type,
801        })
802    }
803
804    /// GET `path` → classify → STREAM the body to `out` chunk-by-chunk
805    /// — the file-download pipeline (03-02). The response body is
806    /// consumed HERE, at a pipeline site, classify-first like every
807    /// other: an error answer must classify (never stream), and on
808    /// success each `bytes_stream()` chunk goes straight through
809    /// `AsyncWriteExt::write_all` into a `tokio::fs::File` — NO
810    /// `Vec<u8>` accumulation anywhere (Pitfall 2: a multi-hundred-MB
811    /// export ZIP must not buffer in memory). The response metadata
812    /// (`Content-Disposition` filename, `Content-Type`) and the
813    /// chunk-counted byte total ride out in [`ExportMeta`]. Requires
814    /// the workspace `reqwest` `stream` + `tokio` `fs` features (the
815    /// research-flagged dep gap this plan closed).
816    ///
817    /// `accept` adds an OPTIONAL `Accept` header for the callers whose
818    /// server contract names one (04-04's gwbk download sends
819    /// `application/octet-stream`; the 03-02 export sends none) — a
820    /// minimal parameterization that keeps THIS the one streaming
821    /// site instead of forking a second copy of the chunk loop.
822    async fn download_to_file(
823        &self,
824        path: &str,
825        out: &Path,
826        timeout: Duration,
827        accept: Option<&str>,
828    ) -> Result<ExportMeta, CoreError> {
829        use futures_util::StreamExt;
830        use tokio::io::AsyncWriteExt;
831
832        let url = self.url_for(path);
833        let mut request = self.client.get(url.clone()).timeout(timeout);
834        if let Some(accept) = accept {
835            request = request.header(reqwest::header::ACCEPT, accept);
836        }
837        let request = self.apply_auth(request);
838        let response = self.send_and_classify(request, &url).await?;
839        let filename = response
840            .headers()
841            .get(reqwest::header::CONTENT_DISPOSITION)
842            .and_then(|value| value.to_str().ok())
843            .and_then(logs::filename_from_content_disposition);
844        let content_type = response
845            .headers()
846            .get(reqwest::header::CONTENT_TYPE)
847            .and_then(|value| value.to_str().ok())
848            .map(str::to_string);
849
850        let mut file = tokio::fs::File::create(out).await.map_err(|err| {
851            CoreError::Internal(format!("cannot create {}: {err}", out.display()))
852        })?;
853        let mut stream = response.bytes_stream();
854        let mut bytes: u64 = 0;
855        while let Some(chunk) = stream.next().await {
856            let chunk = chunk.map_err(|err| CoreError::Network {
857                url: url.to_string(),
858                source: Some(err),
859                observation: None,
860            })?;
861            file.write_all(&chunk).await.map_err(|err| {
862                CoreError::Internal(format!("cannot write {}: {err}", out.display()))
863            })?;
864            bytes += chunk.len() as u64;
865        }
866        file.flush()
867            .await
868            .map_err(|err| CoreError::Internal(format!("cannot flush {}: {err}", out.display())))?;
869        Ok(ExportMeta {
870            filename,
871            bytes,
872            content_type,
873        })
874    }
875
876    /// POST `path` with `pairs` as QUERY params and an empty body →
877    /// classify → hand back the response (callers read `true`/JSON as
878    /// their capability needs). Production callers since 02-04:
879    /// `set_logger_level`, `reset_logger_levels` (and 02-05's restart
880    /// with `confirm=true`). Token-auth POSTs need NO CSRF (verified
881    /// 02-RESEARCH §Auth Model).
882    async fn post_empty(
883        &self,
884        path: &str,
885        pairs: &[(&str, String)],
886        auth: bool,
887    ) -> Result<reqwest::Response, CoreError> {
888        let url = self.url_for(path);
889        let mut request = self.client.post(url.clone()).query(pairs);
890        if auth {
891            request = self.apply_auth(request);
892        }
893        self.send_and_classify(request, &url).await
894    }
895
896    /// DELETE `path` with `pairs` as QUERY params (empty body) →
897    /// classify → `Ok(())` on any classified success. Token-auth DELETEs
898    /// need NO CSRF (verified 02-RESEARCH §Auth Model: CSRF is only for
899    /// cookie/session auth); the classified bodies (`{terminated: N}`,
900    /// `{message: …}`) are advisory — Ok classification IS the success
901    /// contract.
902    async fn delete_with_query(
903        &self,
904        path: &str,
905        pairs: &[(&str, String)],
906    ) -> Result<(), CoreError> {
907        let url = self.url_for(path);
908        let mut request = self.client.delete(url.clone()).query(pairs);
909        request = self.apply_auth(request);
910        self.send_and_classify(request, &url).await.map(|_| ())
911    }
912
913    /// DELETE `path` with a JSON body → classify → hand back the
914    /// response — [`Self::delete_with_query`] cannot carry a body (it
915    /// sends an EMPTY one). Mirrors [`Self::post_json`] exactly (Phase
916    /// 19: the module-uninstall DELETE is the first DELETE-with-body
917    /// caller). Token-auth DELETEs need NO CSRF (verified 02-RESEARCH
918    /// §Auth Model).
919    async fn delete_json<T: serde::Serialize + ?Sized>(
920        &self,
921        path: &str,
922        body: &T,
923    ) -> Result<reqwest::Response, CoreError> {
924        let url = self.url_for(path);
925        let request = self.apply_auth(self.client.delete(url.clone()).json(body));
926        self.send_and_classify(request, &url).await
927    }
928
929    /// POST `path` with a JSON body → classify → hand back the response
930    /// (callers read the body as their capability needs; the project
931    /// mutations treat Ok classification AS the success contract —
932    /// those bodies are unverified LOW, the restart `literal true`
933    /// precedent). Token-auth POSTs need NO CSRF (verified
934    /// 02-RESEARCH §Auth Model). One of the two body-carrying pipeline
935    /// helpers (03-01); serde serializes struct fields in declaration order, so
936    /// recorded bodies are deterministic for the wiremock pins.
937    async fn post_json<T: serde::Serialize + ?Sized>(
938        &self,
939        path: &str,
940        body: &T,
941    ) -> Result<reqwest::Response, CoreError> {
942        let url = self.url_for(path);
943        let request = self.apply_auth(self.client.post(url.clone()).json(body));
944        self.send_and_classify(request, &url).await
945    }
946
947    /// POST the action JSON to a webdev route with caller headers +
948    /// auth applied, returning `(full URL, response)` — the shared
949    /// head of the two webdev seam methods (05-03). NO classify here:
950    /// `webdev_route_probe` reads the raw status (the code IS the
951    /// answer); `webdev_route_call` classifies downstream. Transport
952    /// failures map to `Network` like every pipeline.
953    pub(crate) async fn webdev_post_raw(
954        &self,
955        project: &str,
956        route: &str,
957        body: &serde_json::Value,
958        extra_headers: &[(&str, &str)],
959        timeout: Option<Duration>,
960    ) -> Result<(String, reqwest::Response), CoreError> {
961        let path = webdev::route_url(project, route);
962        let url = self.url_for(&path);
963        let mut request = self.client.post(url.clone()).json(body);
964        if let Some(t) = timeout {
965            // Per-request override WITHOUT a second client — the
966            // RequestBuilder::timeout pattern (02-04/09-05).
967            request = request.timeout(t);
968        }
969        for (name, value) in extra_headers {
970            request = request.header(*name, *value);
971        }
972        let request = self.apply_auth(request);
973        let response = request.send().await.map_err(|err| CoreError::Network {
974            url: url.to_string(),
975            source: Some(err),
976            observation: None,
977        })?;
978        Ok((url.to_string(), response))
979    }
980
981    /// POST the JSON body to a TESTING-bundle route
982    /// (`/system/webdev/{project}/testing/{route}`) RAW — the
983    /// `webdev_post_raw` twin for the sibling URL space (QUICK-p0g).
984    ///
985    /// NO classify, deliberately (the `webdev_route_probe`
986    /// precedent): the testing bundle's presence discrimination
987    /// needs the raw status, because [`classify`] collapses
988    /// 405/500/501 into `Internal` (exit 1) and would make an
989    /// undeployed bundle indistinguishable from a CLI bug. Transport
990    /// failures map to `Network` like every pipeline.
991    ///
992    /// The classified siblings ([`webdev::testing_discover`],
993    /// [`webdev::testing_run`]) are untouched — `ign adopt --testing`
994    /// keeps its exact behavior.
995    pub(crate) async fn testing_post_raw(
996        &self,
997        project: &str,
998        route: &str,
999        body: &serde_json::Value,
1000    ) -> Result<(String, reqwest::Response), CoreError> {
1001        let path = webdev::testing_route_url(project, route);
1002        let url = self.url_for(&path);
1003        let request = self.apply_auth(self.client.post(url.clone()).json(body));
1004        let response = request.send().await.map_err(|err| CoreError::Network {
1005            url: url.to_string(),
1006            source: Some(err),
1007            observation: None,
1008        })?;
1009        Ok((url.to_string(), response))
1010    }
1011
1012    /// PUT `path` with a JSON body → classify → `Ok(())` (modify/
1013    /// reparent; resource puts in 03-03). Token-auth PUTs need NO
1014    /// CSRF. The classify-first rule holds: nothing consumes a body
1015    /// that skipped classify.
1016    async fn put_json<T: serde::Serialize + ?Sized>(
1017        &self,
1018        path: &str,
1019        body: &T,
1020    ) -> Result<(), CoreError> {
1021        let url = self.url_for(path);
1022        let request = self.apply_auth(self.client.put(url.clone()).json(body));
1023        self.send_and_classify(request, &url).await.map(|_| ())
1024    }
1025
1026    /// Send + transport-error mapping + [`classify`] — the shared tail of
1027    /// every pipeline helper. Transport failures (connect/timeout/TLS) →
1028    /// `Network` (exit 4); everything the gateway ANSWERED goes through
1029    /// the classifier. Curated traffic: `api_call = false` — the
1030    /// catch-all cannot fire without the parameter (09-01 Pitfall 1).
1031    async fn send_and_classify(
1032        &self,
1033        request: reqwest::RequestBuilder,
1034        url: &url::Url,
1035    ) -> Result<reqwest::Response, CoreError> {
1036        let response = request.send().await.map_err(|err| CoreError::Network {
1037            url: url.to_string(),
1038            source: Some(err),
1039            observation: None,
1040        })?;
1041        classify::classify(response, url.as_ref(), false).await
1042    }
1043
1044    /// The api-call-scoped pipeline entry (09-01): identical
1045    /// transport-error → `Network` mapping, then [`classify`] with
1046    /// `api_call = true` so an unclassified gateway 4xx maps to
1047    /// `GatewayClientError` (exit 2, verbatim capped body) instead of
1048    /// `Internal`. Public because `ign api call`'s action layer (09-03)
1049    /// is the production consumer and the contract tests
1050    /// (tests/api_classify_contract.rs) pin the full exit partition
1051    /// through it — nothing in the curated pipeline switches to it.
1052    pub async fn send_and_classify_for_api(
1053        &self,
1054        request: reqwest::RequestBuilder,
1055        url: &url::Url,
1056    ) -> Result<reqwest::Response, CoreError> {
1057        let response = request.send().await.map_err(|err| CoreError::Network {
1058            url: url.to_string(),
1059            source: Some(err),
1060            observation: None,
1061        })?;
1062        classify::classify(response, url.as_ref(), true).await
1063    }
1064}
1065
1066/// Install the process-wide TLS crypto provider (ring). reqwest is built
1067/// with `rustls-no-provider` (workspace Cargo.toml) so the provider is the
1068/// app's responsibility — this is the single install point for the binary,
1069/// and it is `pub` so the e2e tests' raw `reqwest::Client` constructions
1070/// can install it too. Safe to call any number of times.
1071pub fn install_crypto_provider() {
1072    // ring is the process-wide TLS provider (workspace Cargo.toml: reqwest
1073    // rides rustls-no-provider). install_default() errors on a second call —
1074    // the Once makes every later construction a no-op instead.
1075    static ONCE: std::sync::Once = std::sync::Once::new();
1076    ONCE.call_once(|| {
1077        let _ = rustls::crypto::ring::default_provider().install_default();
1078    });
1079}
1080
1081/// How long an idle pooled connection is kept. The gateway's Jetty closes
1082/// a connection after 30 s idle, and reqwest's default (90 s) would reuse
1083/// a socket the gateway has already closed: a request sent after a long
1084/// host step (ssh, sftp) then fails as a network error. Dropping idle
1085/// connections well before 30 s makes such a request open a fresh one.
1086pub(crate) const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(20);
1087
1088fn build_client(ssl_verify: bool) -> Result<reqwest::Client, CoreError> {
1089    build_client_with_idle(ssl_verify, POOL_IDLE_TIMEOUT)
1090}
1091
1092fn build_client_with_idle(
1093    ssl_verify: bool,
1094    pool_idle_timeout: Duration,
1095) -> Result<reqwest::Client, CoreError> {
1096    install_crypto_provider();
1097    let mut builder = reqwest::Client::builder()
1098        .pool_idle_timeout(pool_idle_timeout)
1099        // Never follow redirects: an uncommissioned gateway 302s everything
1100        // to /welcome and the follow would render the wizard HTML as a 200
1101        // (02-RESEARCH Pitfall 6). classify() maps the 3xx instead.
1102        .redirect(reqwest::redirect::Policy::none())
1103        .connect_timeout(Duration::from_secs(10))
1104        .timeout(Duration::from_secs(30));
1105    if !ssl_verify {
1106        builder = builder.danger_accept_invalid_certs(true);
1107    }
1108    builder
1109        .build()
1110        .map_err(|err| CoreError::Internal(format!("cannot build HTTP client: {err}")))
1111}
1112
1113#[async_trait::async_trait]
1114impl GatewayApi for ReqwestGatewayApi {
1115    fn gateway_transport(&self) -> Option<(url::Url, bool)> {
1116        Some((self.base.clone(), self.ssl_verify))
1117    }
1118
1119    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
1120        let mut info: GatewayInfo = self.get_json(GATEWAY_INFO_PATH, None, true).await?;
1121        info.endpoint = Some(self.url_for(GATEWAY_INFO_PATH).to_string());
1122        Ok(info)
1123    }
1124
1125    async fn overview(&self) -> Result<Overview, CoreError> {
1126        self.get_json(status::OVERVIEW_PATH, None, true).await
1127    }
1128
1129    async fn status_ping(&self) -> Result<StatusPing, CoreError> {
1130        // auth = false — the whole point: the readiness anchor must not
1131        // depend on credentials (pinned by the wiremock header-absence
1132        // proof in tests/status_contract.rs).
1133        self.get_json(status::STATUS_PING_PATH, None, false).await
1134    }
1135
1136    async fn modules(
1137        &self,
1138        quarantined: bool,
1139        query: &query::ListQuery,
1140    ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
1141        let path = if quarantined {
1142            status::MODULES_QUARANTINED_PATH
1143        } else {
1144            status::MODULES_HEALTHY_PATH
1145        };
1146        self.get_json(path, Some(&query.to_query_pairs()), true)
1147            .await
1148    }
1149
1150    async fn uninstall_module(
1151        &self,
1152        gateway_module_id: &str,
1153        module_id: &str,
1154    ) -> Result<(), CoreError> {
1155        let url = self.url_for(status::MODULES_UNINSTALL_PATH);
1156        let body = status::ModuleUninstallRequest {
1157            uninstall: vec![gateway_module_id.to_string()],
1158        };
1159        let response = self
1160            .delete_json(status::MODULES_UNINSTALL_PATH, &body)
1161            .await?;
1162        // Denial-rides-200 (D-19-03, live-verified): a stock module, a
1163        // bogus id, and a still-mounted module all answered 200 with
1164        // success:false. An unparseable body, a missing `success`, or
1165        // an absent `failedUninstalls` must NEVER degrade into a
1166        // reported success — every one of those shapes falls through
1167        // to the denial below, never to `Ok`.
1168        let text = response.text().await.unwrap_or_default();
1169        let denial = |failed: Vec<String>| CoreError::ModuleUninstallDenied {
1170            module_id: module_id.to_string(),
1171            gateway_module_id: gateway_module_id.to_string(),
1172            failed,
1173            endpoint: Some(url.to_string()),
1174        };
1175        let Ok(parsed) = serde_json::from_str::<status::ModuleUninstallResponse>(text.trim())
1176        else {
1177            return Err(denial(vec![gateway_module_id.to_string()]));
1178        };
1179        if parsed.success {
1180            return Ok(());
1181        }
1182        let failed = parsed
1183            .failed_uninstalls
1184            .map(|detail| detail.uninstall)
1185            .filter(|ids| !ids.is_empty())
1186            .unwrap_or_else(|| vec![gateway_module_id.to_string()]);
1187        Err(denial(failed))
1188    }
1189
1190    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
1191        self.get_json(metrics::CURRENT_GAUGES_PATH, None, true)
1192            .await
1193    }
1194
1195    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
1196        self.get_json(metrics::CHARTS_PATH, None, true).await
1197    }
1198
1199    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
1200        self.get_json(metrics::THREADS_PATH, None, true).await
1201    }
1202
1203    async fn thread_dump(&self) -> Result<threads::FormattedThreadDump, CoreError> {
1204        self.get_json(threads::THREAD_DUMP_FORMATTED_PATH, None, true)
1205            .await
1206    }
1207
1208    async fn thread_deadlocks(&self) -> Result<threads::DeadlocksWire, CoreError> {
1209        self.get_json(threads::THREAD_DEADLOCKS_PATH, None, true)
1210            .await
1211    }
1212
1213    async fn designers(
1214        &self,
1215        query: &query::ListQuery,
1216    ) -> Result<ListEnvelope<DesignerInfo>, CoreError> {
1217        self.get_json(
1218            sessions::DESIGNERS_PATH,
1219            Some(&query.to_query_pairs()),
1220            true,
1221        )
1222        .await
1223    }
1224
1225    async fn perspective_sessions(
1226        &self,
1227        query: &query::ListQuery,
1228    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError> {
1229        // The trailing slash is PART OF THE PATH (Pitfall 8) — url_for's
1230        // join preserves it; the exact-path wiremock matcher in
1231        // tests/sessions_contract.rs pins it.
1232        self.get_json(
1233            sessions::PERSPECTIVE_SESSIONS_LIST_PATH,
1234            Some(&query.to_query_pairs()),
1235            true,
1236        )
1237        .await
1238    }
1239
1240    async fn vision_clients(
1241        &self,
1242        query: &query::ListQuery,
1243    ) -> Result<ListEnvelope<VisionClient>, CoreError> {
1244        self.get_json(
1245            sessions::VISION_CLIENTS_PATH,
1246            Some(&query.to_query_pairs()),
1247            true,
1248        )
1249        .await
1250    }
1251
1252    async fn terminate_perspective_session(
1253        &self,
1254        id: &str,
1255        message: Option<&str>,
1256    ) -> Result<(), CoreError> {
1257        // sessionId is a QUERY param on the spec's DELETE route — never
1258        // a body (recorded-request proof in tests/sessions_contract.rs).
1259        let mut pairs = vec![("sessionId", id.to_string())];
1260        if let Some(message) = message {
1261            pairs.push(("message", message.to_string()));
1262        }
1263        self.delete_with_query(sessions::PERSPECTIVE_SESSIONS_TERMINATE_PATH, &pairs)
1264            .await
1265    }
1266
1267    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError> {
1268        self.delete_with_query(&sessions::vision_client_terminate_path(id), &[])
1269            .await
1270    }
1271
1272    async fn prune_designer(&self, id: &str) -> Result<(), CoreError> {
1273        self.delete_with_query(&sessions::designer_prune_path(id), &[])
1274            .await
1275    }
1276
1277    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1278        // The UI polls the resource list with limit=-1 — same convention
1279        // as every other list capability.
1280        self.get_json(
1281            connections::DATABASE_CONNECTIONS_PATH,
1282            Some(&query::ListQuery::default().to_query_pairs()),
1283            true,
1284        )
1285        .await
1286    }
1287
1288    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1289        self.get_json(
1290            connections::OPC_CONNECTIONS_PATH,
1291            Some(&query::ListQuery::default().to_query_pairs()),
1292            true,
1293        )
1294        .await
1295    }
1296
1297    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError> {
1298        // Explicit limit ALWAYS rides the wire (Pitfall 9) — enforced by
1299        // LogQuery::to_query_pairs, pinned by the contract test.
1300        self.get_json(logs::LOGS_PATH, Some(&filter.to_query_pairs()), true)
1301            .await
1302    }
1303
1304    async fn logs_download(&self) -> Result<LogDownload, CoreError> {
1305        // Per-request timeout override: the 30 s client default would
1306        // truncate large archives (per-class timeout WITHOUT a second
1307        // client — RequestBuilder::timeout, 02-RESEARCH §Architecture).
1308        self.get_bytes(logs::LOGS_DOWNLOAD_PATH, Duration::from_secs(120))
1309            .await
1310    }
1311
1312    async fn loggers(
1313        &self,
1314        query: &query::ListQuery,
1315    ) -> Result<ListEnvelope<LoggerInfo>, CoreError> {
1316        self.get_json(logs::LOGGERS_PATH, Some(&query.to_query_pairs()), true)
1317            .await
1318    }
1319
1320    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError> {
1321        // `level` rides the QUERY string against an EMPTY body (verified
1322        // live: 200 + the level flips; recorded-request proof in
1323        // tests/logs_contract.rs).
1324        self.post_empty(
1325            &logs::logger_set_path(logger),
1326            &[("level", level.to_string())],
1327            true,
1328        )
1329        .await
1330        .map(|_| ())
1331    }
1332
1333    async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1334        self.post_empty(logs::LEVEL_RESET_PATH, &[], true)
1335            .await
1336            .map(|_| ())
1337    }
1338
1339    async fn restart(&self) -> Result<(), CoreError> {
1340        // `confirm=true` rides the QUERY string against an empty body
1341        // (the verified shape; recorded-request proof in
1342        // tests/restart_wait_contract.rs). Token-auth POSTs need no
1343        // CSRF (02-RESEARCH §Auth Model).
1344        let response = self
1345            .post_empty(
1346                restart::RESTART_PATH,
1347                &[("confirm", "true".to_string())],
1348                true,
1349            )
1350            .await?;
1351        // Success-shape drift guard: the verified body is the literal
1352        // `true`. Any other 2xx body still means the POST was accepted
1353        // — warn, don't fail (the wait half reports what happens next).
1354        let body = response.text().await.unwrap_or_default();
1355        if body.trim() != "true" {
1356            tracing::warn!(
1357                body = %body,
1358                "restart POST answered an unexpected 2xx body (expected the literal `true`)"
1359            );
1360        }
1361        Ok(())
1362    }
1363
1364    async fn scan_projects(&self) -> Result<(), CoreError> {
1365        self.post_empty(restart::SCAN_PROJECTS_PATH, &[], true)
1366            .await
1367            .map(|_| ())
1368    }
1369
1370    async fn security_properties(&self) -> Result<SecurityProperties, CoreError> {
1371        // The singleton READ (ADOPT-RESEARCH §2, live-corrected): the
1372        // record nests the permissions under `config` — deserialize
1373        // from there, tolerating a legacy flat record (config absent
1374        // → the record itself parses).
1375        let record: serde_json::Value = self
1376            .get_json(
1377                restart::SECURITY_PROPERTIES_PATH,
1378                Some(&[("defaultIfUndefined".to_string(), "true".to_string())]),
1379                true,
1380            )
1381            .await?;
1382        let config = record.get("config").cloned().unwrap_or(record);
1383        serde_json::from_value(config).map_err(|err| {
1384            CoreError::Internal(format!(
1385                "security-properties did not match the expected shape: {err}"
1386            ))
1387        })
1388    }
1389
1390    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError> {
1391        // The raw-status probe: send, surface the status code, never
1392        // classify (404 vs 200/401/403 is the ANSWER, not an error).
1393        // Only transport failures (DNS/refused/timeout) error out.
1394        let path = restart::webdev_route_path(route);
1395        let url = self.url_for(&path);
1396        let request = self.apply_auth(self.client.get(url.clone()));
1397        let response = request.send().await.map_err(|err| CoreError::Network {
1398            url: url.to_string(),
1399            source: Some(err),
1400            observation: None,
1401        })?;
1402        Ok(response.status().as_u16())
1403    }
1404
1405    async fn webdev_route_call(
1406        &self,
1407        project: &str,
1408        route: &str,
1409        body: &serde_json::Value,
1410        extra_headers: &[(&str, &str)],
1411    ) -> Result<serde_json::Value, CoreError> {
1412        // classify() runs normally for transport/status errors; the
1413        // 200 BODY is then the route envelope — WebDev ignores
1414        // `status`, so denials ride HTTP 200 and the body verdict is
1415        // the ONLY success oracle (never the status line alone).
1416        let (url, response) = self
1417            .webdev_post_raw(project, route, body, extra_headers, None)
1418            .await?;
1419        let response = classify::classify(response, &url, false).await?;
1420        let text = response.text().await.unwrap_or_default();
1421        match webdev::parse_route_body(&text)? {
1422            RouteBody::Ok(data) => Ok(data),
1423            RouteBody::Denied {
1424                code,
1425                message,
1426                traceback,
1427            } => Err(webdev::denial_to_error(
1428                &code,
1429                &message,
1430                traceback.as_deref(),
1431                url,
1432            )),
1433        }
1434    }
1435
1436    async fn webdev_route_call_with_timeout(
1437        &self,
1438        project: &str,
1439        route: &str,
1440        body: &serde_json::Value,
1441        extra_headers: &[(&str, &str)],
1442        timeout: Duration,
1443    ) -> Result<serde_json::Value, CoreError> {
1444        // The SAME classify + envelope-parse tail as
1445        // `webdev_route_call` — the ONLY delta is the per-request
1446        // ceiling (behavior-identical request semantics).
1447        let (url, response) = self
1448            .webdev_post_raw(project, route, body, extra_headers, Some(timeout))
1449            .await?;
1450        let response = classify::classify(response, &url, false).await?;
1451        let text = response.text().await.unwrap_or_default();
1452        match webdev::parse_route_body(&text)? {
1453            RouteBody::Ok(data) => Ok(data),
1454            RouteBody::Denied {
1455                code,
1456                message,
1457                traceback,
1458            } => Err(webdev::denial_to_error(
1459                &code,
1460                &message,
1461                traceback.as_deref(),
1462                url,
1463            )),
1464        }
1465    }
1466
1467    async fn webdev_route_probe(
1468        &self,
1469        project: &str,
1470        route: &str,
1471        extra_headers: &[(&str, &str)],
1472    ) -> Result<RouteProbe, CoreError> {
1473        // NOT classified — the status code IS the answer (the
1474        // webdev_route_status precedent): 405/402/401 discriminate
1475        // presence/licensing/gating, and a 200 body carries the
1476        // version handshake or the structured denial.
1477        let (url, response) = self
1478            .webdev_post_raw(
1479                project,
1480                route,
1481                &serde_json::json!({"action": "version"}),
1482                extra_headers,
1483                None,
1484            )
1485            .await?;
1486        let status = response.status();
1487        if status.is_success() {
1488            let text = response.text().await.unwrap_or_default();
1489            return match webdev::parse_route_body(&text)? {
1490                RouteBody::Ok(data) => {
1491                    let route_version = data
1492                        .get("routeVersion")
1493                        .and_then(serde_json::Value::as_str)
1494                        .map(str::to_string)
1495                        .ok_or_else(|| {
1496                            CoreError::Internal(format!(
1497                                "webdev route version action from {url} answered no routeVersion"
1498                            ))
1499                        })?;
1500                    Ok(RouteProbe::Present { route_version })
1501                }
1502                RouteBody::Denied {
1503                    code,
1504                    message,
1505                    traceback,
1506                } => Ok(RouteProbe::Denied {
1507                    code,
1508                    message,
1509                    traceback,
1510                }),
1511            };
1512        }
1513        match status.as_u16() {
1514            401 | 403 => Ok(RouteProbe::AuthGated),
1515            402 => Ok(RouteProbe::Unlicensed),
1516            405 => Ok(RouteProbe::Absent),
1517            // Shapes the enum has no variant for (wizard redirects,
1518            // mid-restart 503s, foreign 404s) — reuse classify's
1519            // status mappings verbatim; every non-success response
1520            // classifies to Err, and the Ok arm is unreachable by
1521            // construction (all 2xx took the body branch above).
1522            _ => match classify::classify(response, &url, false).await {
1523                Err(err) => Err(err),
1524                Ok(_) => Err(CoreError::Internal(format!(
1525                    "unexpected HTTP {status} from webdev route probe at {url}"
1526                ))),
1527            },
1528        }
1529    }
1530
1531    async fn projects(
1532        &self,
1533        query: &query::ListQuery,
1534    ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1535        // Standard list params (limit=-1 = the UI's "everything").
1536        self.get_json(
1537            projects::PROJECTS_LIST_PATH,
1538            Some(&query.to_query_pairs()),
1539            true,
1540        )
1541        .await
1542    }
1543
1544    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1545        // The {name} segment is percent-encoded (Pitfall 6) — the
1546        // spaced-name recorded-request proof in tests/projects_contract.rs.
1547        self.get_json(&projects::project_find_path(name), None, true)
1548            .await
1549    }
1550
1551    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1552        // Ok classification IS the success contract; callers that want
1553        // data re-`find` (the actions layer's read-back).
1554        self.post_json(projects::PROJECTS_CREATE_PATH, body)
1555            .await
1556            .map(|_| ())
1557    }
1558
1559    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError> {
1560        let body = ProjectCopy {
1561            from_name: from.to_string(),
1562            to_name: to.to_string(),
1563        };
1564        self.post_json(projects::PROJECTS_COPY_PATH, &body)
1565            .await
1566            .map(|_| ())
1567    }
1568
1569    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError> {
1570        let body = ProjectRenameBody {
1571            name: new_name.to_string(),
1572        };
1573        self.post_json(&projects::project_rename_path(name), &body)
1574            .await
1575            .map(|_| ())
1576    }
1577
1578    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1579        self.put_json(&projects::project_modify_path(name), body)
1580            .await
1581    }
1582
1583    async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1584        // BOTH guard layers (Pitfall 8): the CLI already refused
1585        // without --yes (exit 2, pre-resolution) AND the wire request
1586        // always carries the server's own `confirm=true` query param
1587        // (wiremock recorded-request proof).
1588        self.delete_with_query(
1589            &projects::project_delete_path(name),
1590            &[("confirm", "true".to_string())],
1591        )
1592        .await
1593    }
1594
1595    async fn project_export_to_file(
1596        &self,
1597        name: &str,
1598        out: &Path,
1599    ) -> Result<ExportMeta, CoreError> {
1600        // The 120 s per-request override rides the RequestBuilder (the
1601        // logs-download precedent); the streaming itself lives in
1602        // download_to_file (classify FIRST, then chunk loop). No
1603        // `Accept` header — the export contract never named one.
1604        self.download_to_file(
1605            &projects::project_export_path(name),
1606            out,
1607            projects::PROJECT_EXPORT_TIMEOUT,
1608            None,
1609        )
1610        .await
1611    }
1612
1613    async fn project_import(
1614        &self,
1615        name: &str,
1616        zip: Vec<u8>,
1617        overwrite: bool,
1618    ) -> Result<ImportOutcome, CoreError> {
1619        // `overwrite` rides the QUERY string; the ZIP is the RAW body
1620        // with Content-Type application/zip and a known Content-Length
1621        // (Vec<u8> — chunked encoding never enters the picture). The
1622        // 300 s per-request override owns Pitfall 3. Token-auth POSTs
1623        // need no CSRF (02-RESEARCH §Auth Model).
1624        let url = self.url_for(&projects::project_import_path(name));
1625        let request = self
1626            .client
1627            .post(url.clone())
1628            .timeout(projects::PROJECT_IMPORT_TIMEOUT)
1629            .query(&[("overwrite", if overwrite { "true" } else { "false" })])
1630            .header(reqwest::header::CONTENT_TYPE, "application/zip")
1631            .body(zip);
1632        let request = self.apply_auth(request);
1633        let response = self.send_and_classify(request, &url).await?;
1634        // Opaque-success: parse the body when it is a JSON OBJECT,
1635        // else the fallback object (the body is unverified MEDIUM —
1636        // restart's `literal true` is the same family and normalizes
1637        // the same way, so agents always see a stable object shape).
1638        let body = response.text().await.unwrap_or_default();
1639        let parsed = serde_json::from_str::<serde_json::Value>(body.trim())
1640            .ok()
1641            .filter(|value| value.is_object())
1642            .unwrap_or_else(|| serde_json::json!({"status": "success"}));
1643        // Denial honesty (05-07, UAT Gap 1): the gateway refuses
1644        // imports over HTTP 200 with {success:false, problem} —
1645        // live-witnessed while NOTHING landed. ONE seam here fixes
1646        // every import caller at once (resource put/delete, project
1647        // import, webdev deploy) — per-caller checks are forbidden;
1648        // this IS the contract (the WebDev 200-denial precedent
1649        // applied to the import family).
1650        if let Some(problem) = projects::import_denied(&parsed) {
1651            return Err(CoreError::ImportDenied {
1652                project: name.to_string(),
1653                problem,
1654                endpoint: Some(url.to_string()),
1655            });
1656        }
1657        Ok(ImportOutcome { response: parsed })
1658    }
1659
1660    async fn tag_provider_list(
1661        &self,
1662        query: &query::ListQuery,
1663    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError> {
1664        // Standard list params (limit=-1 = the UI's "everything") —
1665        // the connections-family resource lists' exact shape.
1666        self.get_json(
1667            tags::TAG_PROVIDERS_LIST_PATH,
1668            Some(&query.to_query_pairs()),
1669            true,
1670        )
1671        .await
1672    }
1673
1674    async fn tags_export_provider(&self, provider: &str) -> Result<Vec<u8>, CoreError> {
1675        // The `get_bytes` pipeline with the provider on the QUERY (a name
1676        // holding a space is percent-encoded by `.query`, never spliced into
1677        // the path) and the 300 s bulk-transfer ceiling.
1678        let url = self.url_for(tags::TAGS_EXPORT_PATH);
1679        let request = self
1680            .client
1681            .get(url.clone())
1682            .query(&[("provider", provider), ("type", "json")])
1683            .timeout(tags::TAGS_EXPORT_TIMEOUT);
1684        let request = self.apply_auth(request);
1685        let response = self.send_and_classify(request, &url).await?;
1686        let bytes = response.bytes().await.map_err(|err| CoreError::Network {
1687            url: url.to_string(),
1688            source: Some(err),
1689            observation: None,
1690        })?;
1691        Ok(bytes.to_vec())
1692    }
1693
1694    async fn tags_import(
1695        &self,
1696        provider: &str,
1697        path: &str,
1698        policy: tags::CollisionPolicy,
1699        body: Vec<u8>,
1700    ) -> Result<tags::TagImportAnswer, CoreError> {
1701        // The export override's shape in the other direction: provider,
1702        // parent path and policy on the QUERY, the raw body with a known
1703        // length, and the 300 s ceiling.
1704        let url = self.url_for(tags::TAGS_IMPORT_PATH);
1705        let request = self
1706            .client
1707            .post(url.clone())
1708            .query(&tags_import_query(provider, path, policy))
1709            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1710            .body(body)
1711            .timeout(tags::TAGS_IMPORT_TIMEOUT);
1712        let request = self.apply_auth(request);
1713        let response = self.send_and_classify(request, &url).await?;
1714        let bytes = response.bytes().await.map_err(|err| CoreError::Network {
1715            url: url.to_string(),
1716            source: Some(err),
1717            observation: None,
1718        })?;
1719        tags::TagImportAnswer::parse(&bytes)
1720    }
1721
1722    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError> {
1723        self.get_json(&tags::tag_provider_find_path(name), None, true)
1724            .await
1725    }
1726
1727    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError> {
1728        // The ARRAY body is the wire contract (a bare object 400s);
1729        // serde serializes elements in declaration order so the
1730        // recorded body is deterministic. Ok classification IS the
1731        // success contract (the project-create precedent).
1732        self.post_json(tags::TAG_PROVIDERS_CREATE_PATH, body)
1733            .await
1734            .map(|_| ())
1735    }
1736
1737    async fn tag_provider_modify(&self, body: &[TagProviderModify]) -> Result<(), CoreError> {
1738        // The deploy-scoped resource write (D3): the one place a signature
1739        // refusal gets its typed error, and a 2xx must say `success:true`.
1740        if body.is_empty() {
1741            return Err(CoreError::InvalidInput {
1742                reason: "a tag provider modify needs at least one record".to_string(),
1743            });
1744        }
1745        let names: Vec<&str> = body.iter().map(|record| record.name.as_str()).collect();
1746        self.resource_update_items("ignition", "tag-provider", body, &names)
1747            .await
1748            .map(|_| ())
1749    }
1750
1751    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError> {
1752        // The signature rides the PATH (from find) — the
1753        // live-proven delete-by-signature chain; both segments
1754        // percent-encoded through the ONE locked encoder.
1755        self.delete_with_query(&tags::tag_provider_delete_path(name, signature), &[])
1756            .await
1757    }
1758
1759    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError> {
1760        // Conditional auth: the endpoints answer unauthenticated
1761        // (live-verified both rigs), so a header-less client degrades
1762        // cleanly — but a carried credential rides along harmlessly
1763        // (future-proofing if a gateway version starts gating them).
1764        let auth = self.credential.is_some();
1765        self.get_json(trial::TRIAL_PATH, None, auth).await
1766    }
1767
1768    async fn banners(&self) -> Result<BannerSet, CoreError> {
1769        let auth = self.credential.is_some();
1770        self.get_json(trial::BANNERS_PATH, None, auth).await
1771    }
1772
1773    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError> {
1774        // Empty body, authed POST (the UI mutation's exact shape —
1775        // decompiled ia-gateway.js: {method:"POST",
1776        // url:"/data/api/v1/trial"}). Token-auth POSTs need no CSRF
1777        // (02-RESEARCH §Auth Model); on 403 the tier-1 session+CSRF
1778        // flow takes over (actions layer owns the ladder).
1779        let response = self.post_empty(trial::TRIAL_PATH, &[], true).await?;
1780        let body = response.text().await.unwrap_or_default();
1781        serde_json::from_str(&body).map_err(|err| {
1782            CoreError::Internal(format!(
1783                "trial reset response did not match the trial shape: {err}"
1784            ))
1785        })
1786    }
1787
1788    async fn backup_download(
1789        &self,
1790        out: &Path,
1791        backup_type: backup::BackupType,
1792    ) -> Result<ExportMeta, CoreError> {
1793        // Pure reuse: the type query rides the path builder, the
1794        // Accept header rides the helper's optional param, and the
1795        // 300 s class rides the RequestBuilder — the 03-02 chunk loop
1796        // stays THE one streaming body-consumption site (04-04).
1797        self.download_to_file(
1798            &backup::backup_download_path(backup_type),
1799            out,
1800            backup::BACKUP_TIMEOUT,
1801            Some(backup::BACKUP_ACCEPT),
1802        )
1803        .await
1804    }
1805
1806    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError> {
1807        // The upload direction buffers BY DESIGN (the import
1808        // precedent: a known Content-Length raw body sidesteps the
1809        // chunked-encoding question entirely). Token-auth POSTs need
1810        // no CSRF (02-RESEARCH §Auth Model). Ok classification IS the
1811        // acceptance contract — the actions layer owns the
1812        // post-restore RUNNING wait (Pitfall 6: the gateway restarts
1813        // after answering).
1814        let body = tokio::fs::read(gwbk)
1815            .await
1816            .map_err(|err| CoreError::InvalidInput {
1817                reason: format!("cannot read {}: {err}", gwbk.display()),
1818            })?;
1819        let url = self.url_for(backup::BACKUP_PATH);
1820        let request = self
1821            .client
1822            .post(url.clone())
1823            .timeout(backup::BACKUP_TIMEOUT)
1824            .query(&backup::restore_query())
1825            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1826            .body(body);
1827        let request = self.apply_auth(request);
1828        self.send_and_classify(request, &url).await.map(|_| ())
1829    }
1830
1831    async fn eam_task_history(
1832        &self,
1833        limit: Option<u32>,
1834        search: Option<&str>,
1835    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError> {
1836        let query = query::ListQuery {
1837            limit: limit
1838                .map(i64::from)
1839                .unwrap_or(eam::EAM_HISTORY_DEFAULT_LIMIT),
1840            search: search.map(str::to_string),
1841            ..query::ListQuery::default()
1842        };
1843        self.get_json(eam::EAM_HISTORY_PATH, Some(&query.to_query_pairs()), true)
1844            .await
1845    }
1846
1847    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError> {
1848        // Standard list params (limit=-1 = the UI's "everything") —
1849        // definition counts are small; the connections-family
1850        // resource lists' exact shape.
1851        self.get_json(
1852            &eam::eam_tasks_list_path(),
1853            Some(&query::ListQuery::default().to_query_pairs()),
1854            true,
1855        )
1856        .await
1857    }
1858
1859    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError> {
1860        self.get_json(&eam::eam_task_find_path(name), None, true)
1861            .await
1862    }
1863
1864    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError> {
1865        // The ARRAY body is the wire contract (a bare object 400s —
1866        // the tag-provider create precedent); the caller's composed
1867        // definition rides as the single element. Ok classification
1868        // IS the success contract.
1869        self.post_json(&eam::eam_tasks_create_path(), &[definition])
1870            .await
1871            .map(|_| ())
1872    }
1873
1874    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError> {
1875        // Empty body, authed POST — 204 is the live-proven success
1876        // shape; classify()'s 2xx pass-through IS the oracle (any
1877        // 2xx = dispatched; outcomes land in history as data).
1878        self.post_empty(&eam::eam_force_path(owner, name), &[], true)
1879            .await
1880            .map(|_| ())
1881    }
1882
1883    async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError> {
1884        // Empty body, authed POST — 204 is the captured success shape
1885        // (10-LIVE-CAPTURES §1b/§1c); the suspend/resume failure
1886        // modes are 500 HTML (classify → Internal with the page's
1887        // message — the module-doc finding; no 404 exists here).
1888        self.post_empty(&eam::eam_task_suspend_path(name), &[], true)
1889            .await
1890            .map(|_| ())
1891    }
1892
1893    async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError> {
1894        // Same shape as suspend: 204 success (§1b/§1d — resume of a
1895        // never-suspended task is ALSO a 204; idempotent wire).
1896        self.post_empty(&eam::eam_task_resume_path(name), &[], true)
1897            .await
1898            .map(|_| ())
1899    }
1900
1901    async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError> {
1902        // 204 whether a pending execution existed, the task has
1903        // nothing pending, or the name is unknown (§7) — cancel is
1904        // wire-idempotent; name validation is the actions layer's
1905        // find-before-write job.
1906        self.post_empty(&eam::eam_task_cancel_path(name), &[], true)
1907            .await
1908            .map(|_| ())
1909    }
1910
1911    async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError> {
1912        // The standard {items, metadata} envelope (§2 — byte-identical
1913        // shape across both rigs, incl. the empty-list quiet body);
1914        // this method unwraps the envelope to the items.
1915        let envelope: query::ListEnvelope<EamScheduledTask> = self
1916            .get_json(&eam::eam_tasks_scheduled_path(running), None, true)
1917            .await?;
1918        Ok(envelope.items)
1919    }
1920
1921    async fn eam_task_modify(
1922        &self,
1923        definition: &serde_json::Value,
1924    ) -> Result<Option<ModifyOutcome>, CoreError> {
1925        // The ARRAY body is the wire contract (single element — the
1926        // §6a capture; a bare object is not the shape the resource
1927        // PUT family speaks). The classify-first rule holds, then the
1928        // captured 200 body parses into ModifyOutcome (None = a 2xx
1929        // that carried no body — the lenient `Option` per the plan).
1930        let url = self.url_for(&eam::eam_tasks_modify_path());
1931        let request = self.apply_auth(self.client.put(url.clone()).json(&[definition]));
1932        let response = self.send_and_classify(request, &url).await?;
1933        let text = response.text().await.unwrap_or_default();
1934        if text.trim().is_empty() {
1935            return Ok(None);
1936        }
1937        serde_json::from_str(&text).map(Some).map_err(|err| {
1938            CoreError::Internal(format!(
1939                "response from {url} did not match the expected shape: {err}"
1940            ))
1941        })
1942    }
1943
1944    async fn eam_task_delete(
1945        &self,
1946        name: &str,
1947        signature: &str,
1948        confirm: bool,
1949    ) -> Result<DeleteOutcome, CoreError> {
1950        // `collection=core` ALWAYS (the captured success value — the
1951        // type token 404s, §3c); `confirm=true` rides ONLY on
1952        // explicit opt-in (a lone-resource delete succeeds without
1953        // it, §3b; the confirm-demand shape is unobserved, §3d —
1954        // never hard-coded). The 200 body is the captured
1955        // DeleteOutcome; classify-first as everywhere.
1956        let url = self.url_for(&eam::eam_task_delete_path(name, signature));
1957        let mut pairs: Vec<(&str, String)> = vec![("collection", "core".to_string())];
1958        if confirm {
1959            pairs.push(("confirm", "true".to_string()));
1960        }
1961        let request = self.apply_auth(self.client.delete(url.clone()).query(&pairs));
1962        let response = self.send_and_classify(request, &url).await?;
1963        let text = response.text().await.unwrap_or_default();
1964        serde_json::from_str(&text).map_err(|err| {
1965            CoreError::Internal(format!(
1966                "response from {url} did not match the expected shape: {err}"
1967            ))
1968        })
1969    }
1970
1971    async fn api_call(
1972        &self,
1973        call: &apicall::ApiCallRequest,
1974    ) -> Result<apicall::ApiCallData, CoreError> {
1975        let url = self.url_for(&call.path);
1976        // Any RFC verb (lowercase input normalized); an unparseable
1977        // method is a usage-class refusal BEFORE the wire — reqwest's
1978        // own `Method` parse is the validator (no hand-rolled list).
1979        let method =
1980            reqwest::Method::from_bytes(call.method.to_uppercase().as_bytes()).map_err(|_| {
1981                CoreError::InvalidInput {
1982                    reason: format!(
1983                        "{:?} is not a valid HTTP method — use an RFC verb \
1984                     (GET/POST/PUT/DELETE/PATCH/HEAD, …)",
1985                        call.method
1986                    ),
1987                }
1988            })?;
1989        let mut request = self.client.request(method, url.clone());
1990        for (name, value) in &call.headers {
1991            // reqwest's `.header()` PANICS on an invalid name/value —
1992            // these strings are user-supplied, so they are validated
1993            // HERE: a bad header is an exit-2 refusal, never a crash
1994            // (the webdev extra_headers loop never carried raw user
1995            // input; this one does).
1996            let name =
1997                reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()).map_err(|_| {
1998                    CoreError::InvalidInput {
1999                        reason: format!("{name:?} is not a valid HTTP header name"),
2000                    }
2001                })?;
2002            let value = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
2003                CoreError::InvalidInput {
2004                    reason: format!("{value:?} is not a valid HTTP header value"),
2005                }
2006            })?;
2007            request = request.header(name, value);
2008        }
2009        // The ONE query mechanism: reqwest's own serializer (the same
2010        // `.query(&pairs)` shape every other capability uses) — never
2011        // a `?` smuggled through the path (validate_path refuses it).
2012        if !call.query.is_empty() {
2013            request = request.query(&call.query);
2014        }
2015        if let Some(body) = &call.body {
2016            // Raw TEXT on ANY method — GET/DELETE body passthrough is
2017            // allowed (curl parity; the gateway's answer classifies).
2018            request = request.body(body.clone());
2019        }
2020        // THE auth site: profile credentials only (the redaction
2021        // boundary); the pipeline tail is the api-call-scoped
2022        // classifier so unclassified gateway 4xx ride
2023        // GatewayClientError instead of Internal.
2024        let request = self.apply_auth(request);
2025        let response = self.send_and_classify_for_api(request, &url).await?;
2026        let status = response.status().as_u16();
2027        let text = response.text().await.map_err(|err| CoreError::Network {
2028            url: url.to_string(),
2029            source: Some(err),
2030            observation: None,
2031        })?;
2032        // THE verbatim decision (research OQ1): `from_string` both
2033        // preserves the gateway's bytes (key order, unknown fields)
2034        // AND validates JSON in one call — no parse-re-serialize. A
2035        // non-JSON 2xx body is the documented internal-class honesty
2036        // refusal (binary endpoints ride the download pipelines).
2037        let data = serde_json::value::RawValue::from_string(text).map_err(|err| {
2038            CoreError::Internal(format!(
2039                "the gateway answered 2xx with a non-JSON body — api call returns \
2040                 JSON; use logs/backup downloads for binary endpoints ({err})"
2041            ))
2042        })?;
2043        Ok(apicall::ApiCallData { status, data })
2044    }
2045
2046    async fn license_status(&self) -> Result<LicenseStatusWire, CoreError> {
2047        // auth = true — a /data route under 8.3 default security (the
2048        // gateway-info rule); the capture session rode a token header.
2049        self.get_json(license::LICENSES_PATH, None, true).await
2050    }
2051
2052    async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError> {
2053        self.get_json(redundancy::REDUNDANCY_PATH, None, true).await
2054    }
2055
2056    async fn gan_status(&self) -> Result<GanStatusWire, CoreError> {
2057        self.get_json(gan::GAN_OVERVIEW_PATH, None, true).await
2058    }
2059
2060    async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError> {
2061        // Empty body, authed POST (the restart/set-logger precedent;
2062        // token mutations need no CSRF). The 200 body IS the status
2063        // wire per capture ({"state":"Generating"}) — classify-first
2064        // through the shared pipeline, then parse.
2065        let url = self.url_for(diagnostics::DIAGNOSTICS_GENERATE_PATH);
2066        let response = self
2067            .send_and_classify(self.apply_auth(self.client.post(url.clone())), &url)
2068            .await?;
2069        response.json::<BundleStatusWire>().await.map_err(|err| {
2070            CoreError::Internal(format!(
2071                "response from {url} did not match the expected shape: {err}"
2072            ))
2073        })
2074    }
2075
2076    async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError> {
2077        // auth = true — a /data route under 8.3 default security; the
2078        // capture session rode a token header.
2079        self.get_json(diagnostics::DIAGNOSTICS_STATUS_PATH, None, true)
2080            .await
2081    }
2082
2083    async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError> {
2084        // The 300 s per-request override rides the RequestBuilder
2085        // inside download_to_file (Pitfall 8: the 30 s client default
2086        // would truncate MB-sized bundles); the chunk loop stays THE
2087        // one streaming body-consumption site. No Accept header — the
2088        // capture named none (Content-Type: application/zip is the
2089        // answer). Disposition filename + content type ride ExportMeta.
2090        self.download_to_file(
2091            diagnostics::DIAGNOSTICS_DOWNLOAD_PATH,
2092            out,
2093            diagnostics::BUNDLE_DOWNLOAD_TIMEOUT,
2094            None,
2095        )
2096        .await
2097    }
2098}
2099
2100/// The `tags/import` query: `provider`, `type=json`, `collisionPolicy`, and
2101/// `path` only when the parent path is non-empty (an empty path is the
2102/// provider root).
2103fn tags_import_query(
2104    provider: &str,
2105    path: &str,
2106    policy: tags::CollisionPolicy,
2107) -> Vec<(String, String)> {
2108    let mut query = vec![
2109        ("provider".to_string(), provider.to_string()),
2110        ("type".to_string(), "json".to_string()),
2111        ("collisionPolicy".to_string(), policy.as_wire().to_string()),
2112    ];
2113    if !path.is_empty() {
2114        query.push(("path".to_string(), path.to_string()));
2115    }
2116    query
2117}
2118
2119#[cfg(test)]
2120mod tests {
2121    use super::{GatewayApi, ReqwestGatewayApi};
2122
2123    /// The gateway's Jetty closes a connection after 30 s idle. The pool
2124    /// must drop it first, or a request sent after a long host step (ssh,
2125    /// sftp) goes out on a socket the gateway already closed.
2126    #[test]
2127    fn pool_idle_timeout_is_below_the_gateway_idle_close() {
2128        assert!(super::POOL_IDLE_TIMEOUT < std::time::Duration::from_secs(30));
2129    }
2130
2131    /// The pool idle timeout reaches the client: after the client has
2132    /// been idle longer than the timeout, the next request opens a new
2133    /// connection instead of reusing the pooled one.
2134    #[tokio::test]
2135    async fn idle_pooled_connection_is_not_reused_after_the_timeout() {
2136        use std::sync::Arc;
2137        use std::sync::atomic::{AtomicUsize, Ordering};
2138        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2139
2140        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2141        let addr = listener.local_addr().unwrap();
2142        let accepts = Arc::new(AtomicUsize::new(0));
2143        let counter = accepts.clone();
2144        tokio::spawn(async move {
2145            loop {
2146                let (mut sock, _) = listener.accept().await.unwrap();
2147                counter.fetch_add(1, Ordering::SeqCst);
2148                tokio::spawn(async move {
2149                    let mut buf = [0u8; 4096];
2150                    loop {
2151                        match sock.read(&mut buf).await {
2152                            Ok(0) | Err(_) => return,
2153                            Ok(_) => {
2154                                let reply = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok";
2155                                if sock.write_all(reply).await.is_err() {
2156                                    return;
2157                                }
2158                            }
2159                        }
2160                    }
2161                });
2162            }
2163        });
2164
2165        let client = super::build_client_with_idle(true, std::time::Duration::from_millis(200))
2166            .expect("client builds");
2167        let url = format!("http://{addr}/");
2168        client.get(&url).send().await.unwrap().text().await.unwrap();
2169        client.get(&url).send().await.unwrap().text().await.unwrap();
2170        assert_eq!(
2171            accepts.load(Ordering::SeqCst),
2172            1,
2173            "a busy pool reuses its connection"
2174        );
2175
2176        tokio::time::sleep(std::time::Duration::from_millis(800)).await;
2177        client.get(&url).send().await.unwrap().text().await.unwrap();
2178        assert_eq!(
2179            accepts.load(Ordering::SeqCst),
2180            2,
2181            "a connection idle past the timeout is dropped, not reused"
2182        );
2183    }
2184
2185    /// Exercises `post_empty` end-to-end with the shape 02-04's
2186    /// set-logger-level route uses (query param + empty body): the
2187    /// verified restart shape is a 200 with literal body `true`,
2188    /// classified Ok — and the query param rides the request.
2189    #[tokio::test]
2190    async fn post_empty_sends_query_param_and_empty_body() {
2191        let server = wiremock::MockServer::start().await;
2192        let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
2193            .and(wiremock::matchers::path(
2194                "/data/api/v1/restart-tasks/restart",
2195            ))
2196            .and(wiremock::matchers::query_param("confirm", "true"))
2197            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("true"))
2198            .expect(1)
2199            .mount_as_scoped(&server)
2200            .await;
2201
2202        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
2203        let response = api
2204            .post_empty(
2205                "/data/api/v1/restart-tasks/restart",
2206                &[("confirm", "true".to_string())],
2207                true,
2208            )
2209            .await
2210            .expect("200 classifies Ok");
2211        assert_eq!(response.status(), reqwest::StatusCode::OK);
2212
2213        let requests = guard.received_requests().await;
2214        assert_eq!(requests.len(), 1);
2215        assert!(
2216            requests[0].body.is_empty(),
2217            "the POST carries NO body — params ride the query string"
2218        );
2219    }
2220
2221    /// The formatted thread dump rides its EXACT path
2222    /// (`/data/api/v1/diagnostics/threads/dump/formatted` — the
2223    /// Gateway web UI's Diagnostics→Threads endpoint, live-spec
2224    /// pinned) and parses into the tolerant model.
2225    #[tokio::test]
2226    async fn thread_dump_rides_the_formatted_dump_path() {
2227        let server = wiremock::MockServer::start().await;
2228        let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
2229            .and(wiremock::matchers::path(
2230                "/data/api/v1/diagnostics/threads/dump/formatted",
2231            ))
2232            .respond_with(
2233                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2234                    "version": "dump-version-1",
2235                    "threads": [
2236                        {"name": "Perspective-Worker-3", "id": 42, "state": "RUNNABLE",
2237                         "daemon": true, "cpuUsage": 3.75, "stacktrace": ["frame-0"]}
2238                    ]
2239                })),
2240            )
2241            .expect(1)
2242            .mount_as_scoped(&server)
2243            .await;
2244
2245        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
2246        let dump = api.thread_dump().await.expect("200 + spec shape parses");
2247        assert_eq!(dump.threads.len(), 1);
2248        assert_eq!(dump.threads[0].id, Some(42));
2249        assert_eq!(dump.threads[0].state, "RUNNABLE");
2250        assert_eq!(guard.received_requests().await.len(), 1);
2251    }
2252
2253    /// The deadlocked-id list rides its EXACT path
2254    /// (`/data/api/v1/diagnostics/threads/deadlocks`) and the healthy
2255    /// empty-list body parses.
2256    #[tokio::test]
2257    async fn thread_deadlocks_rides_the_deadlocks_path() {
2258        let server = wiremock::MockServer::start().await;
2259        let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
2260            .and(wiremock::matchers::path(
2261                "/data/api/v1/diagnostics/threads/deadlocks",
2262            ))
2263            .respond_with(
2264                wiremock::ResponseTemplate::new(200)
2265                    .set_body_json(serde_json::json!({"deadlocks": [7, 42]})),
2266            )
2267            .expect(1)
2268            .mount_as_scoped(&server)
2269            .await;
2270
2271        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
2272        let wire = api
2273            .thread_deadlocks()
2274            .await
2275            .expect("200 + the deadlocks shape parses");
2276        assert_eq!(wire.deadlocks, vec![7, 42]);
2277        assert_eq!(guard.received_requests().await.len(), 1);
2278    }
2279}