mirror of
https://github.com/luxfi/opgen.git
synced 2026-10-30 15:23:29 +00:00
Every other check runs the client against a transport that answers from memory, which can only say the request is the one we meant to build. This starts the fixture on a socket and calls it from the generated Rust client through a hand-written HTTP transport, which is what says the service accepts it. It immediately found that zip did not percent-decode a path parameter: every client encodes, because a space or a slash has no other spelling between two slashes, so a value addressed at /v1/secrets/café arrived as caf%C3%A9 and no such address could round-trip. Fixed upstream in zip v1.36.39 and depended on here. Two more from generating node's ninety-four ops: a field-less struct's C++ writer never touched its parameter, which -Werror calls an error (node's admin EmptyReply answers eight of eighteen ops); and a Go service's v-prefixed version is not semver, so platformvm's v1.36.181 produced a crate cargo would not parse. A gap now carries the field whose value cannot cross, so a reader knows what to change. Co-authored-by: Hanzo Dev <[email protected]>
356 lines
12 KiB
Go
356 lines
12 KiB
Go
package opgen_test
|
|
|
|
// The programs the compile tests run.
|
|
//
|
|
// They are written here rather than generated, deliberately. A generator whose
|
|
// output is only ever checked by more generated code checks that it agrees with
|
|
// itself. These are hand-written against the fixture's ops, so they say what a
|
|
// person expects the client to do, and the compiler and the assertions say
|
|
// whether it does it.
|
|
|
|
const rustCheck = `use vault::*;
|
|
use std::cell::RefCell;
|
|
|
|
// A transport that answers from memory, so the call path is exercised end to
|
|
// end without a socket.
|
|
struct Recording {
|
|
seen: RefCell<(String, String, String)>,
|
|
answer: String,
|
|
}
|
|
|
|
impl Transport for Recording {
|
|
fn send(&self, method: &str, target: &str, body: Option<&[u8]>) -> Result<Reply, Error> {
|
|
*self.seen.borrow_mut() = (
|
|
method.to_string(),
|
|
target.to_string(),
|
|
String::from_utf8_lossy(body.unwrap_or(b"")).into_owned(),
|
|
);
|
|
Ok(Reply { status: 200, body: self.answer.clone().into_bytes() })
|
|
}
|
|
}
|
|
|
|
fn recording(answer: &str) -> Recording {
|
|
Recording { seen: RefCell::new(Default::default()), answer: answer.to_string() }
|
|
}
|
|
|
|
#[test]
|
|
fn a_path_parameter_is_escaped_into_the_address() {
|
|
let t = recording(r#"{"name":"k","owner":{"org":"acme","kind":"user"}}"#);
|
|
let got = {
|
|
let c = Client::new(&t);
|
|
c.vault_read(&VaultRead { name: "a b/c".into(), reveal: true }).unwrap()
|
|
};
|
|
let (method, target, body) = t.seen.into_inner();
|
|
assert_eq!(method, "GET");
|
|
assert_eq!(target, "/v1/secrets/a%20b%2Fc?reveal=true");
|
|
assert_eq!(body, "");
|
|
assert_eq!(got.name, "k");
|
|
assert_eq!(got.owner.org, "acme");
|
|
}
|
|
|
|
#[test]
|
|
fn a_query_parameter_rides_the_url() {
|
|
let t = recording(r#"{"secrets":[{"name":"one"}],"more":true}"#);
|
|
let got = {
|
|
let c = Client::new(&t);
|
|
c.vault_list(&VaultList { org: "acme".into(), limit: 10 }).unwrap()
|
|
};
|
|
assert_eq!(t.seen.into_inner().1, "/v1/secrets?org=acme&limit=10");
|
|
assert_eq!(got.secrets.len(), 1);
|
|
assert!(got.more);
|
|
}
|
|
|
|
#[test]
|
|
fn a_body_op_sends_the_whole_value() {
|
|
let t = recording(r#"{"ref":"r1","at":"2026-09-01T00:00:00Z"}"#);
|
|
let mut input = Seal::default();
|
|
input.name = "k".into();
|
|
input.bytes = 42;
|
|
input.weight = 1.5;
|
|
input.rotate = true;
|
|
input.tags = vec!["a".into(), "b".into()];
|
|
input.counts = vec![1, 2];
|
|
input.labels.insert("env".into(), "prod".into());
|
|
input.owner.org = "acme".into();
|
|
input.free = serde_json::json!({"x": 1});
|
|
|
|
let got = {
|
|
let c = Client::new(&t);
|
|
c.vault_seal(&input).unwrap()
|
|
};
|
|
let (method, target, body) = t.seen.into_inner();
|
|
assert_eq!(method, "POST");
|
|
assert_eq!(target, "/v1/seal");
|
|
let sent: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
assert_eq!(sent["name"], "k");
|
|
assert_eq!(sent["bytes"], 42);
|
|
assert_eq!(sent["labels"]["env"], "prod");
|
|
assert_eq!(sent["counts"][1], 2);
|
|
assert_eq!(sent["free"]["x"], 1);
|
|
assert_eq!(got.ref_, "r1");
|
|
}
|
|
|
|
#[test]
|
|
fn an_op_that_takes_nothing_takes_nothing() {
|
|
let t = recording(r#"{"ready":true,"name":"vault"}"#);
|
|
let got = {
|
|
let c = Client::new(&t);
|
|
c.vault_health().unwrap()
|
|
};
|
|
assert_eq!(t.seen.into_inner().1, "/v1/health");
|
|
assert!(got.ready);
|
|
}
|
|
|
|
// An answer that leaves fields out is still an answer: the service is written
|
|
// in Go, where an absent field is the zero value.
|
|
#[test]
|
|
fn a_short_answer_reads_as_zero_values() {
|
|
let t = recording(r#"{"name":"k"}"#);
|
|
let got = {
|
|
let c = Client::new(&t);
|
|
c.vault_read(&VaultRead::default()).unwrap()
|
|
};
|
|
assert_eq!(got.name, "k");
|
|
assert_eq!(got.owner.org, "");
|
|
assert_eq!(got.at, "");
|
|
}
|
|
|
|
struct Refusing;
|
|
|
|
impl Transport for Refusing {
|
|
fn send(&self, _: &str, _: &str, _: Option<&[u8]>) -> Result<Reply, Error> {
|
|
Ok(Reply { status: 403, body: b"denied".to_vec() })
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_refusal_is_an_error_carrying_the_status() {
|
|
match Client::new(Refusing).vault_health() {
|
|
Err(Error::Status { status, body }) => {
|
|
assert_eq!(status, 403);
|
|
assert_eq!(body, "denied");
|
|
}
|
|
other => panic!("want a refusal, got {other:?}"),
|
|
}
|
|
}
|
|
`
|
|
|
|
const cppCheck = `#include <vault/vault.hpp>
|
|
|
|
#include <cassert>
|
|
#include <string>
|
|
|
|
// A transport that answers from memory, so the call path is exercised end to
|
|
// end without a socket.
|
|
struct Recording : vault::Transport {
|
|
std::string method, target, sent, answer;
|
|
vault::Reply send(const std::string& m, const std::string& t,
|
|
const std::string* body) override {
|
|
method = m;
|
|
target = t;
|
|
sent = body ? *body : std::string();
|
|
return vault::Reply{200, answer};
|
|
}
|
|
};
|
|
|
|
int main() {
|
|
{ // A path parameter is escaped into the address.
|
|
Recording t;
|
|
t.answer = R"({"name":"k","owner":{"org":"acme","kind":"user"}})";
|
|
vault::VaultRead in;
|
|
in.name = "a b/c";
|
|
in.reveal = true;
|
|
const vault::Secret got = vault::Client(t).vault_read(in);
|
|
assert(t.method == "GET");
|
|
assert(t.target == "/v1/secrets/a%20b%2Fc?reveal=true");
|
|
assert(t.sent.empty());
|
|
assert(got.name == "k");
|
|
assert(got.owner.org == "acme");
|
|
}
|
|
{ // A query parameter rides the URL.
|
|
Recording t;
|
|
t.answer = R"({"secrets":[{"name":"one"}],"more":true})";
|
|
vault::VaultList in;
|
|
in.org = "acme";
|
|
in.limit = 10;
|
|
const vault::Held got = vault::Client(t).vault_list(in);
|
|
assert(t.target == "/v1/secrets?org=acme&limit=10");
|
|
assert(got.secrets.size() == 1);
|
|
assert(got.more);
|
|
}
|
|
{ // A body op sends the whole value.
|
|
Recording t;
|
|
t.answer = R"({"ref":"r1","at":"2026-09-01T00:00:00Z"})";
|
|
vault::Seal in;
|
|
in.name = "k";
|
|
in.bytes = 42;
|
|
in.weight = 1.5;
|
|
in.rotate = true;
|
|
in.tags = {"a", "b"};
|
|
in.counts = {1, 2};
|
|
in.labels["env"] = "prod";
|
|
in.owner.org = "acme";
|
|
in.free = nlohmann::json::object({{"x", 1}});
|
|
const vault::Sealed got = vault::Client(t).vault_seal(in);
|
|
assert(t.method == "POST");
|
|
assert(t.target == "/v1/seal");
|
|
const nlohmann::json sent = nlohmann::json::parse(t.sent);
|
|
assert(sent["name"] == "k");
|
|
assert(sent["bytes"] == 42);
|
|
assert(sent["labels"]["env"] == "prod");
|
|
assert(sent["counts"][1] == 2);
|
|
assert(sent["free"]["x"] == 1);
|
|
assert(got.ref == "r1");
|
|
}
|
|
{ // An op that takes nothing takes nothing.
|
|
Recording t;
|
|
t.answer = R"({"ready":true,"name":"vault"})";
|
|
const vault::Ready got = vault::Client(t).vault_health();
|
|
assert(t.target == "/v1/health");
|
|
assert(got.ready);
|
|
}
|
|
{ // A short answer reads as zero values.
|
|
Recording t;
|
|
t.answer = R"({"name":"k"})";
|
|
const vault::Secret got = vault::Client(t).vault_read(vault::VaultRead{});
|
|
assert(got.name == "k");
|
|
assert(got.owner.org.empty());
|
|
assert(got.at.empty());
|
|
}
|
|
{ // A refusal is an exception carrying the status.
|
|
struct Refusing : vault::Transport {
|
|
vault::Reply send(const std::string&, const std::string&,
|
|
const std::string*) override {
|
|
return vault::Reply{403, "denied"};
|
|
}
|
|
} refusing;
|
|
try {
|
|
vault::Client(refusing).vault_health();
|
|
return 1;
|
|
} catch (const vault::Refused& e) {
|
|
assert(e.status() == 403);
|
|
assert(e.body() == "denied");
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
`
|
|
|
|
const groveCheck = `#include <grove/grove.hpp>
|
|
|
|
#include <cassert>
|
|
#include <string>
|
|
|
|
struct Echo : grove::Transport {
|
|
std::string sent;
|
|
grove::Reply send(const std::string&, const std::string&,
|
|
const std::string* body) override {
|
|
sent = body ? *body : std::string();
|
|
return grove::Reply{200, sent};
|
|
}
|
|
};
|
|
|
|
int main() {
|
|
Echo t;
|
|
grove::Tree in;
|
|
in.name = "root";
|
|
in.parent = std::make_shared<grove::Tree>();
|
|
in.parent->name = "above";
|
|
in.children.push_back(grove::Tree{});
|
|
in.children[0].name = "below";
|
|
in.grove = std::make_shared<grove::Forest>();
|
|
|
|
const grove::Tree got = grove::Client(t).grove_plant(in);
|
|
assert(got.name == "root");
|
|
assert(got.parent && got.parent->name == "above");
|
|
assert(got.children.size() == 1 && got.children[0].name == "below");
|
|
// The mutual cycle is broken on one side; the other side is by value and
|
|
// round-trips as an empty tree.
|
|
assert(got.grove && got.grove->back.name.empty());
|
|
return 0;
|
|
}
|
|
`
|
|
|
|
// rustLive is the end-to-end check: a real socket, a real zip service, and a
|
|
// transport of about thirty lines that needs no crate at all. It is what tells
|
|
// us the address and the body the client builds are ones the SERVICE accepts —
|
|
// an in-memory transport can only say they are what we expected to build.
|
|
const rustLive = `use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
use vault::*;
|
|
|
|
// HTTP/1.1 over a socket, written by hand, because the point of the Transport
|
|
// seam is that the program supplies the bytes and this is the smallest program
|
|
// that can.
|
|
struct Http {
|
|
addr: String,
|
|
}
|
|
|
|
impl Transport for Http {
|
|
fn send(&self, method: &str, target: &str, body: Option<&[u8]>) -> Result<Reply, Error> {
|
|
let mut sock =
|
|
TcpStream::connect(&self.addr).map_err(|e| Error::Transport(e.to_string()))?;
|
|
let payload = body.unwrap_or(b"");
|
|
let mut head = format!(
|
|
"{method} {target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n",
|
|
payload.len()
|
|
);
|
|
if body.is_some() {
|
|
// A body is always JSON here, so the transport says so once.
|
|
head.push_str("Content-Type: application/json\r\n");
|
|
}
|
|
head.push_str("\r\n");
|
|
sock.write_all(head.as_bytes())
|
|
.map_err(|e| Error::Transport(e.to_string()))?;
|
|
sock.write_all(payload)
|
|
.map_err(|e| Error::Transport(e.to_string()))?;
|
|
|
|
let mut raw = Vec::new();
|
|
sock.read_to_end(&mut raw)
|
|
.map_err(|e| Error::Transport(e.to_string()))?;
|
|
let split = raw
|
|
.windows(4)
|
|
.position(|w| w == b"\r\n\r\n")
|
|
.ok_or_else(|| Error::Transport("no header end".into()))?;
|
|
let head = String::from_utf8_lossy(&raw[..split]).into_owned();
|
|
let status: u16 = head
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.and_then(|s| s.parse().ok())
|
|
.ok_or_else(|| Error::Transport(format!("no status in {head:?}")))?;
|
|
Ok(Reply { status, body: raw[split + 4..].to_vec() })
|
|
}
|
|
}
|
|
|
|
fn live() -> Client<Http> {
|
|
Client::new(Http { addr: std::env::var("VAULT_ADDR").expect("VAULT_ADDR") })
|
|
}
|
|
|
|
#[test]
|
|
fn health_answers_over_a_socket() {
|
|
let got = live().vault_health().unwrap();
|
|
assert!(got.ready);
|
|
assert_eq!(got.name, "vault");
|
|
}
|
|
|
|
#[test]
|
|
fn a_path_and_a_query_reach_the_handler() {
|
|
// The handler echoes the name it was addressed with, so a wrong URL is a
|
|
// wrong answer rather than a silent pass.
|
|
let got = live()
|
|
.vault_read(&VaultRead { name: "a b/c".into(), reveal: true })
|
|
.unwrap();
|
|
assert_eq!(got.name, "a b/c");
|
|
}
|
|
|
|
#[test]
|
|
fn a_body_reaches_the_handler() {
|
|
let mut input = Seal::default();
|
|
input.name = "kilo".into();
|
|
input.at = "2026-09-01T00:00:00Z".into();
|
|
let got = live().vault_seal(&input).unwrap();
|
|
assert_eq!(got.ref_, "kilo");
|
|
assert_eq!(got.at, "2026-09-01T00:00:00Z");
|
|
}
|
|
`
|