Skip to content

Commit 30d8eeb

Browse files
committed
Merge branch 'ci/openapi-shape-tests' into main
Adds tests/openapi_conformance.rs: the committed openapi.json is now the authority for every wire shape src/client.rs carries (key sets, optionality proven behaviourally, round-trip, endpoint wiring, the error-code enum, and the untyped /config/apply body). Also adds a lib target so integration tests can import the real types, moves the two inline #[cfg(test)] modules out to tests/, and records three real drift findings as documented `unmodelled` allowlist entries (PluginView, PluginInstallView, HookView).
2 parents 01f958c + c03dd55 commit 30d8eeb

9 files changed

Lines changed: 1044 additions & 234 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ jobs:
2323
run: cargo clippy --all-targets -- -D warnings
2424
- name: build
2525
run: cargo build --locked --verbose
26+
# Includes tests/openapi_conformance.rs, the SHAPE gate: it loads the committed openapi.json
27+
# and checks every serde type in src/client.rs against its schema (field set, required vs
28+
# nullable, round trip, endpoint wiring). The spec-drift job below only compares a version
29+
# STRING, so this is the check that fails when a struct drifts from the spec in the same
30+
# commit. Keep `cargo test` in this workflow: it is the only thing running that gate.
2631
- name: test
2732
run: cargo test --locked --verbose
2833

@@ -54,6 +59,8 @@ jobs:
5459

5560
spec-drift:
5661
# The admin client is hand-rolled against openapi.json (committed at the repo root).
62+
# This job answers "is the committed spec CURRENT?" only. Whether src/client.rs still MATCHES
63+
# that spec is a separate question, answered by tests/openapi_conformance.rs in the build job.
5764
# Fail when the latest busbar release ships a NEWER spec version than the one committed,
5865
# so drift is visible instead of silent. Degrades to a warning if the GitHub API is
5966
# unavailable or rate-limited (keeps the check non-flaky).

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ A human-facing CLI for the [busbar](https://github.com/GetBusbar) gateway's **ad
44
(`/api/v1/admin`). It speaks the frozen v1 contract over HTTP/HTTPS with a thin, hand-rolled
55
client (no OpenAPI generator), so it's small and easy to extend.
66

7-
The contract it targets is committed at [`openapi.json`](openapi.json) (busbar **1.5.2**);
7+
The contract it targets is committed at [`openapi.json`](openapi.json) (busbar **1.5.3**);
88
CI compares that spec's version against the latest busbar release so drift is visible.
99

10+
Because the client is hand-rolled, a version match is not a shape match:
11+
[`tests/openapi_conformance.rs`](tests/openapi_conformance.rs) loads that same committed
12+
`openapi.json` and checks every request/response type in `src/client.rs` against its schema, field
13+
set, required/nullable-ness, and endpoint wiring. Renaming a Rust field, making a required field
14+
optional, or resyncing a spec that grew a property all fail `cargo test`.
15+
1016
## Install
1117

1218
From source (published later on crates.io / as a GitHub release + Homebrew tap):

src/argmap.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Pure CLI-argument mappings shared by the binary and its tests.
4+
//!
5+
//! These carry contract meaning (the tri-state `allowed_pools`, the first-`=`-wins label split),
6+
//! so they live in the library rather than in `main.rs`: a test that imports the REAL function
7+
//! catches a regression, a test against a copy of it cannot.
8+
9+
use anyhow::Result;
10+
11+
/// Resolve the three distinct `allowed_pools` states the server understands: omitted (`None`) =
12+
/// ALL pools; an explicit empty list (`--no-pools`) = NO pools; a non-empty list = exactly those.
13+
/// A shared function so `cmd_keys_create` and its test exercise the SAME mapping. Collapsing
14+
/// `--no-pools` into `None` would mint an all-pools key when no-pools was asked for (fail-open on
15+
/// privilege).
16+
pub fn resolve_allowed_pools(no_pools: bool, pools: &[String]) -> Option<Vec<String>> {
17+
if no_pools {
18+
Some(Vec::new())
19+
} else if pools.is_empty() {
20+
None
21+
} else {
22+
Some(pools.to_vec())
23+
}
24+
}
25+
26+
/// Parse repeated `--label KEY=VALUE` arguments into the map the mint body carries. The split is
27+
/// on the FIRST `=` only, so a value that itself contains `=` (a URL query string, base64 padding)
28+
/// survives intact.
29+
pub fn parse_labels(pairs: &[String]) -> Result<std::collections::BTreeMap<String, String>> {
30+
pairs
31+
.iter()
32+
.map(|p| {
33+
p.split_once('=')
34+
.map(|(k, v)| (k.to_string(), v.to_string()))
35+
.ok_or_else(|| anyhow::anyhow!("--label must be KEY=VALUE, got {p:?}"))
36+
})
37+
.collect()
38+
}

src/client.rs

Lines changed: 11 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -297,15 +297,19 @@ pub struct ConfigApplyView {
297297

298298
// ── Contract-mirroring request/response types ────────────────────────────────────────────────
299299

300-
#[derive(Deserialize)]
301-
struct ErrorEnvelope {
302-
error: ErrorDetail,
300+
/// The gateway's error envelope (`Error` in the spec): `{"error":{"code","message"}}`. Public so
301+
/// the conformance tests can assert the decoded shape against the committed schema; the CLI itself
302+
/// only renders `code`/`message` into a message.
303+
#[derive(Debug, Deserialize, Serialize)]
304+
pub struct ErrorEnvelope {
305+
pub error: ErrorDetail,
303306
}
304307

305-
#[derive(Deserialize)]
306-
struct ErrorDetail {
307-
code: String,
308-
message: String,
308+
/// One error detail: a `code` drawn from the spec's closed vocabulary plus a human `message`.
309+
#[derive(Debug, Deserialize, Serialize)]
310+
pub struct ErrorDetail {
311+
pub code: String,
312+
pub message: String,
309313
}
310314

311315
/// `GET /info` — mirrors `contract::InfoView`.
@@ -610,151 +614,3 @@ pub struct HookPage {
610614
#[serde(default)]
611615
pub next_cursor: Option<String>,
612616
}
613-
614-
#[cfg(test)]
615-
mod spec_shape_tests {
616-
// These lock the 1.5.2 wire shapes this crate was realigned to (commit that repaired the
617-
// 1.4.x drift). The spec-drift CI job only checks the committed openapi.json's VERSION string;
618-
// it does NOT check that these structs still match the spec's schemas. Without these, a
619-
// rebase reintroducing a 1.4.x field, or flipping `allowed_pools` back to a non-Option Vec,
620-
// compiles clean and ships — exactly the regression class the realignment fixed.
621-
use super::*;
622-
623-
#[test]
624-
fn key_view_allowed_pools_null_is_all_pools() {
625-
// null (or omitted) allowed_pools => None => "all pools". A regression to `Vec<String>`
626-
// would fail to deserialize null, or silently decode it as an empty (== NO pools) list.
627-
let k: KeyView = serde_json::from_str(
628-
r#"{"id":"vk_1","name":"n","allowed_pools":null,"state":"active","enabled":true}"#,
629-
)
630-
.expect("null allowed_pools must decode");
631-
assert_eq!(
632-
k.allowed_pools, None,
633-
"null must mean all-pools (None), not []"
634-
);
635-
}
636-
637-
#[test]
638-
fn key_view_allowed_pools_empty_is_no_pools() {
639-
let k: KeyView =
640-
serde_json::from_str(r#"{"id":"vk_1","name":"n","allowed_pools":[]}"#).unwrap();
641-
assert_eq!(
642-
k.allowed_pools,
643-
Some(Vec::new()),
644-
"explicit [] must stay a distinct empty list (NO pools), never collapse to None"
645-
);
646-
}
647-
648-
#[test]
649-
fn created_key_view_carries_signed_token_not_secret() {
650-
// 1.5.0 credential is `token` (+ expires_at), NOT the 1.4.x `secret`. A revert to a
651-
// required `secret` field would fail to decode this real-shaped response.
652-
let c: CreatedKeyView = serde_json::from_str(
653-
r#"{"id":"vk_1","name":"n","token":"bbk_abc","expires_at":1785772871,"state":"active"}"#,
654-
)
655-
.expect("token-shaped CreatedKeyView must decode");
656-
assert_eq!(c.token, "bbk_abc");
657-
assert_eq!(c.expires_at, 1785772871_u64);
658-
}
659-
660-
#[test]
661-
fn create_key_req_omits_none_fields_and_never_sends_legacy_budget() {
662-
// deny_unknown_fields on the server rejects any stray field. This pins the exact minimal
663-
// body and fails if a 1.4.x budget/rpm/tpm field is ever reintroduced onto the struct.
664-
let req = CreateKeyReq {
665-
name: "svc".into(),
666-
allowed_pools: None,
667-
group: None,
668-
parent: None,
669-
expires_in: None,
670-
expires_at: None,
671-
labels: Default::default(),
672-
issue_aws_credential: false,
673-
};
674-
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
675-
let obj = v.as_object().unwrap();
676-
assert_eq!(
677-
obj.keys().collect::<Vec<_>>(),
678-
vec!["name"],
679-
"an all-defaults CreateKeyReq must serialize to exactly {{name}} — any extra key is \
680-
either a leaked None or a reintroduced legacy field the server will 400 on"
681-
);
682-
for banned in [
683-
"budget",
684-
"budget_period",
685-
"rpm_limit",
686-
"tpm_limit",
687-
"max_budget_cents",
688-
] {
689-
assert!(
690-
!obj.contains_key(banned),
691-
"legacy field {banned} must never serialize"
692-
);
693-
}
694-
}
695-
696-
#[test]
697-
fn create_key_req_empty_allowed_pools_serializes_as_empty_array() {
698-
// The --no-pools path sets Some(vec![]); it must reach the wire as `[]`, not be dropped.
699-
let req = CreateKeyReq {
700-
name: "svc".into(),
701-
allowed_pools: Some(Vec::new()),
702-
group: None,
703-
parent: None,
704-
expires_in: None,
705-
expires_at: None,
706-
labels: Default::default(),
707-
issue_aws_credential: false,
708-
};
709-
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
710-
assert_eq!(v["allowed_pools"], serde_json::json!([]));
711-
}
712-
713-
#[test]
714-
fn rotated_key_view_token_and_secret_are_both_optional() {
715-
// The doc invariant is "exactly one of token or secret"; the type models both as optional
716-
// so a token-only rotate decodes without a phantom `secret`.
717-
let r: RotatedKeyView =
718-
serde_json::from_str(r#"{"id":"vk_1","name":"n","token":"bbk_new"}"#).unwrap();
719-
assert_eq!(r.token.as_deref(), Some("bbk_new"));
720-
assert_eq!(r.secret, None);
721-
}
722-
723-
#[test]
724-
fn inspect_view_carries_manifest_preview_shape() {
725-
// 1.5.2 added POST /plugins/inspect — a stateless preview whose body is the PluginSchemaView
726-
// shape plus version/kind. This pins the fields busbar-admin renders so a future spec resync
727-
// that drops/renames one (e.g. `trust`, `schema_error`, or the new `version`) fails loudly.
728-
let v: InspectView = serde_json::from_str(
729-
r#"{"name":"acme-store","version":"1.2.3","kind":"store","trust":"unverified",
730-
"source":"manifest","restart_required_default":true,
731-
"schema":{"type":"object"},"schema_error":null}"#,
732-
)
733-
.expect("inspect preview must decode");
734-
assert_eq!(v.name, "acme-store");
735-
assert_eq!(v.version.as_deref(), Some("1.2.3"));
736-
assert_eq!(v.kind.as_deref(), Some("store"));
737-
assert_eq!(v.trust, "unverified");
738-
assert_eq!(v.restart_required_default, Some(true));
739-
assert!(v.schema.is_some());
740-
assert_eq!(v.schema_error, None);
741-
}
742-
743-
#[test]
744-
fn inspect_view_tolerates_null_kind_and_absent_version() {
745-
// An unresolvable candidate reports kind/version/restart_required_default as null; the CLI
746-
// must still decode (never refuse) so it can render the `trust`/`schema_error` verdict.
747-
let v: InspectView = serde_json::from_str(
748-
r#"{"name":"bad","kind":null,"trust":"rejected","source":"manifest","schema":null,
749-
"schema_error":"settings_schema is not valid JSON"}"#,
750-
)
751-
.expect("a rejected/unresolvable candidate must still decode");
752-
assert_eq!(v.kind, None);
753-
assert_eq!(v.version, None);
754-
assert_eq!(v.trust, "rejected");
755-
assert_eq!(
756-
v.schema_error.as_deref(),
757-
Some("settings_schema is not valid JSON")
758-
);
759-
}
760-
}

src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
#![forbid(unsafe_code)]
3+
4+
//! The busbar-admin library surface.
5+
//!
6+
//! The binary (`src/main.rs`) is the clap surface + rendering; everything it needs to talk to a
7+
//! gateway, and every pure mapping it applies to CLI arguments, lives here so integration tests
8+
//! under `tests/` exercise the SAME code the binary runs (a `[[bin]]`-only crate cannot be
9+
//! imported by a test, which is why the wire types could previously only be tested from an
10+
//! inline `#[cfg(test)]` module).
11+
12+
pub mod argmap;
13+
pub mod client;

src/main.rs

Lines changed: 5 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,15 @@
44
//! busbar-admin — a human-facing CLI for the busbar gateway's admin API (`/api/v1/admin`).
55
//!
66
//! Config resolution is CLI flag > env var > a clear error. The thin admin client lives in
7-
//! [`client`]; this module is the clap surface + human/JSON rendering.
8-
9-
mod client;
7+
//! `busbar_admin::client`; this module is the clap surface + human/JSON rendering.
108
119
use anyhow::{Context, Result};
1210
use clap::{Args, Parser, Subcommand};
1311

14-
use client::{Client, CreateKeyReq, InspectPluginReq, InstallPluginReq, KeyView, PluginView, Tls};
15-
16-
/// Resolve the three distinct `allowed_pools` states the server understands: omitted (`None`) =
17-
/// ALL pools; an explicit empty list (`--no-pools`) = NO pools; a non-empty list = exactly those.
18-
/// A shared function so `cmd_keys_create` and its test exercise the SAME mapping — collapsing
19-
/// `--no-pools` into `None` would mint an all-pools key when no-pools was asked for (fail-open on
20-
/// privilege).
21-
fn resolve_allowed_pools(no_pools: bool, pools: &[String]) -> Option<Vec<String>> {
22-
if no_pools {
23-
Some(Vec::new())
24-
} else if pools.is_empty() {
25-
None
26-
} else {
27-
Some(pools.to_vec())
28-
}
29-
}
12+
use busbar_admin::argmap::{parse_labels, resolve_allowed_pools};
13+
use busbar_admin::client::{
14+
Client, CreateKeyReq, InspectPluginReq, InstallPluginReq, KeyView, PluginView, Tls,
15+
};
3016

3117
/// busbar-admin — talk to a busbar gateway's admin API.
3218
#[derive(Parser)]
@@ -338,17 +324,6 @@ fn cmd_keys_list(c: &Client, json: bool) -> Result<()> {
338324
Ok(())
339325
}
340326

341-
fn parse_labels(pairs: &[String]) -> Result<std::collections::BTreeMap<String, String>> {
342-
pairs
343-
.iter()
344-
.map(|p| {
345-
p.split_once('=')
346-
.map(|(k, v)| (k.to_string(), v.to_string()))
347-
.ok_or_else(|| anyhow::anyhow!("--label must be KEY=VALUE, got {p:?}"))
348-
})
349-
.collect()
350-
}
351-
352327
fn pools_summary(pools: &Option<Vec<String>>) -> String {
353328
match pools {
354329
None => "(all)".into(),
@@ -737,51 +712,3 @@ fn human_duration(secs: u64) -> String {
737712
parts.push(format!("{s}s"));
738713
parts.join(" ")
739714
}
740-
741-
#[cfg(test)]
742-
mod cli_logic_tests {
743-
use super::*;
744-
745-
#[test]
746-
fn parse_labels_splits_on_first_equals_only() {
747-
// A value containing '=' (a URL query string, a base64 pad) must survive intact — the
748-
// split is on the FIRST '=', not all of them.
749-
let m = parse_labels(&["url=http://x?a=b".into(), "team=platform".into()]).unwrap();
750-
assert_eq!(m.get("url").map(String::as_str), Some("http://x?a=b"));
751-
assert_eq!(m.get("team").map(String::as_str), Some("platform"));
752-
}
753-
754-
#[test]
755-
fn parse_labels_rejects_a_pair_with_no_equals() {
756-
assert!(parse_labels(&["novalue".into()]).is_err());
757-
}
758-
759-
#[test]
760-
fn parse_labels_allows_empty_value() {
761-
let m = parse_labels(&["k=".into()]).unwrap();
762-
assert_eq!(m.get("k").map(String::as_str), Some(""));
763-
}
764-
765-
// Calls the REAL `resolve_allowed_pools` that `cmd_keys_create` uses — not a copy — so a
766-
// regression in the actual fail-open-on-privilege mapping fails this test. (The round-1
767-
// version tested a duplicated helper and was tautological: it passed even if the real code
768-
// regressed.)
769-
#[test]
770-
fn allowed_pools_tristate_no_pools_is_empty_not_none() {
771-
assert_eq!(
772-
resolve_allowed_pools(true, &[]),
773-
Some(Vec::new()),
774-
"--no-pools => NO pools"
775-
);
776-
assert_eq!(
777-
resolve_allowed_pools(false, &[]),
778-
None,
779-
"omitted => ALL pools"
780-
);
781-
assert_eq!(
782-
resolve_allowed_pools(false, &["p1".into()]),
783-
Some(vec!["p1".into()]),
784-
"a list => exactly those pools"
785-
);
786-
}
787-
}

0 commit comments

Comments
 (0)