Restore approved state (tree of 4effcc7 "Added LICENSE")
Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,120 +0,0 @@
|
||||
//! Cross-language pin: Rust `derive_cache_key` must be byte-identical to the
|
||||
//! Python reference (`cores/mizan-python/.../cache/keys.py`) and to the
|
||||
//! committed cross-language vectors that `tests/afi` and `mizan-ts` also pin.
|
||||
//!
|
||||
//! The Python reference is the oracle: a subprocess mints the key with fixed
|
||||
//! inputs and the Rust output must match exactly. `never if backend == X` —
|
||||
//! one spec, pinned both ways.
|
||||
|
||||
use mizan_core::derive_cache_key;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// The `tests/afi` dir, whose venv has `mizan_core` + PyJWT installed.
|
||||
fn afi_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/afi")
|
||||
.canonicalize()
|
||||
.expect("tests/afi exists")
|
||||
}
|
||||
|
||||
/// Run the Python reference via `uv run python -c <code>` in tests/afi and
|
||||
/// return its single stdout line, trimmed.
|
||||
fn py(code: &str) -> String {
|
||||
let out = Command::new("uv")
|
||||
.args(["run", "python", "-c", code])
|
||||
.current_dir(afi_dir())
|
||||
.output()
|
||||
.expect("invoke uv run python");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"python reference failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
);
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn tree(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_vectors_match() {
|
||||
// The exact pins committed in cores/mizan-python/tests/test_keys.py and
|
||||
// backends/mizan-ts/tests — the canonical cross-language anchor.
|
||||
let secret = "test-pin-secret-that-is-32bytes!";
|
||||
|
||||
let public = derive_cache_key(secret, "user", &tree(&[("user_id", json!("5"))]), None, 0);
|
||||
assert_eq!(
|
||||
public,
|
||||
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6"
|
||||
);
|
||||
|
||||
let scoped = derive_cache_key(
|
||||
secret,
|
||||
"user",
|
||||
&tree(&[("user_id", json!("5"))]),
|
||||
Some("5"),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
scoped,
|
||||
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_python_reference_across_inputs() {
|
||||
// A spread of shapes: multi-param (order-independence), numeric vs string,
|
||||
// bool/null normalization, user-scoped, nonzero rev.
|
||||
let cases: Vec<(&str, BTreeMap<String, Value>, Option<&str>, i64)> = vec![
|
||||
("user", tree(&[("user_id", json!("5"))]), None, 0),
|
||||
("user", tree(&[("user_id", json!("5"))]), Some("5"), 0),
|
||||
("user", tree(&[("user_id", json!("5"))]), Some("5"), 3),
|
||||
(
|
||||
"search",
|
||||
tree(&[("q", json!("hello world")), ("page", json!(2))]),
|
||||
None,
|
||||
0,
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
tree(&[("on", json!(true)), ("off", json!(false)), ("nil", json!(null))]),
|
||||
Some("42"),
|
||||
1,
|
||||
),
|
||||
("empty", tree(&[]), None, 0),
|
||||
(
|
||||
"unicode",
|
||||
tree(&[("name", json!("café—ñ"))]),
|
||||
None,
|
||||
0,
|
||||
),
|
||||
];
|
||||
|
||||
for (ctx, params, uid, rev) in cases {
|
||||
let rust = derive_cache_key("pin-secret-xyz", ctx, ¶ms, uid, rev);
|
||||
|
||||
// Build the Python call: derive_cache_key(secret, ctx, params, user_id, rev).
|
||||
let params_json = serde_json::to_string(
|
||||
¶ms.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<serde_json::Map<_, _>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let uid_arg = match uid {
|
||||
Some(u) => format!("'{u}'"),
|
||||
None => "None".to_string(),
|
||||
};
|
||||
let code = format!(
|
||||
"import json; from mizan_core.cache.keys import derive_cache_key; \
|
||||
print(derive_cache_key('pin-secret-xyz', {ctx:?}, json.loads(r'''{params_json}'''), {uid_arg}, {rev}))",
|
||||
);
|
||||
let expected = py(&code);
|
||||
assert_eq!(
|
||||
rust, expected,
|
||||
"cache-key mismatch for ctx={ctx} params={params_json} uid={uid:?} rev={rev}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Cross-language pin: Rust `format_invalidate_header` must be byte-identical
|
||||
//! to `cores/mizan-python/.../invalidation.py::format_invalidate_header`.
|
||||
//!
|
||||
//! The `X-Mizan-Invalidate` header is co-equal with the JSON body channel in
|
||||
//! the spec; Edge parses it to purge. The Python reference is the oracle.
|
||||
|
||||
use mizan_core::{format_invalidate_header, InvalidationTarget};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn afi_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/afi")
|
||||
.canonicalize()
|
||||
.expect("tests/afi exists")
|
||||
}
|
||||
|
||||
fn py_header(json_list: &str) -> String {
|
||||
let code = format!(
|
||||
"import json; from mizan_core.invalidation import format_invalidate_header; \
|
||||
print(format_invalidate_header(json.loads(r'''{json_list}''')))",
|
||||
);
|
||||
let out = Command::new("uv")
|
||||
.args(["run", "python", "-c", &code])
|
||||
.current_dir(afi_dir())
|
||||
.output()
|
||||
.expect("invoke uv run python");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"python reference failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
// Trim the trailing newline only — the header value itself may be empty.
|
||||
let s = String::from_utf8(out.stdout).unwrap();
|
||||
s.strip_suffix('\n').unwrap_or(&s).to_string()
|
||||
}
|
||||
|
||||
fn scoped(ctx: &str, params: &[(&str, serde_json::Value)]) -> InvalidationTarget {
|
||||
InvalidationTarget::ScopedContext {
|
||||
context: ctx.to_string(),
|
||||
params: params.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_python_reference() {
|
||||
let cases: Vec<(Vec<InvalidationTarget>, &str)> = vec![
|
||||
(vec![InvalidationTarget::Context("user".into())], r#"["user"]"#),
|
||||
(
|
||||
vec![
|
||||
InvalidationTarget::Context("user".into()),
|
||||
InvalidationTarget::Context("notifications".into()),
|
||||
],
|
||||
r#"["user", "notifications"]"#,
|
||||
),
|
||||
(
|
||||
vec![scoped("user", &[("user_id", json!(5))])],
|
||||
r#"[{"context": "user", "params": {"user_id": 5}}]"#,
|
||||
),
|
||||
(
|
||||
vec![scoped("search", &[("q", json!("hello world"))])],
|
||||
r#"[{"context": "search", "params": {"q": "hello world"}}]"#,
|
||||
),
|
||||
(
|
||||
// Multiple params → sorted by key, semicolon-joined.
|
||||
vec![scoped("u", &[("b", json!("2")), ("a", json!("1"))])],
|
||||
r#"[{"context": "u", "params": {"b": "2", "a": "1"}}]"#,
|
||||
),
|
||||
(
|
||||
// Special chars that must percent-encode: &, =, /, space, unicode.
|
||||
vec![scoped("c", &[("k", json!("a&b=c/d e—ñ"))])],
|
||||
r#"[{"context": "c", "params": {"k": "a&b=c/d e—ñ"}}]"#,
|
||||
),
|
||||
(
|
||||
// Mixed bare + scoped.
|
||||
vec![
|
||||
scoped("user", &[("user_id", json!(5))]),
|
||||
InvalidationTarget::Context("notifications".into()),
|
||||
],
|
||||
r#"[{"context": "user", "params": {"user_id": 5}}, "notifications"]"#,
|
||||
),
|
||||
];
|
||||
|
||||
for (targets, json_list) in cases {
|
||||
let rust = format_invalidate_header(&targets);
|
||||
let expected = py_header(json_list);
|
||||
assert_eq!(rust, expected, "header mismatch for {json_list}");
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//! Behavior tests for the Shapes projection + edge-manifest derivation,
|
||||
//! driven off a small registered fixture (same graph the AFI fixture uses:
|
||||
//! a nested struct, a user context with a shared `user_id` param, and an
|
||||
//! `affects` mutation).
|
||||
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::{generate_edge_manifest, shapes, RequestHandle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Address {
|
||||
pub city: String,
|
||||
pub zip: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Person {
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
pub address: Address,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Ok {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[mizan::context("people")]
|
||||
pub struct PeopleCtx;
|
||||
|
||||
#[mizan::client(context = PeopleCtx)]
|
||||
pub async fn person(_req: &RequestHandle<'_>, user_id: i64) -> Person {
|
||||
Person {
|
||||
user_id,
|
||||
name: "x".into(),
|
||||
address: Address {
|
||||
city: "c".into(),
|
||||
zip: "z".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(affects = PeopleCtx)]
|
||||
pub async fn rename_person(_req: &RequestHandle<'_>, user_id: i64, _name: String) -> Ok {
|
||||
let _ = user_id;
|
||||
Ok { ok: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_projection_descends_nested_structs() {
|
||||
let proj = shapes::project_function_output("person").expect("projects");
|
||||
assert_eq!(proj.type_name, "personOutput");
|
||||
// Scalar leaves at the top level.
|
||||
let leaves = proj.leaf_names();
|
||||
assert!(leaves.contains(&"user_id"));
|
||||
assert!(leaves.contains(&"name"));
|
||||
// `address` is a nested struct → a sub-projection, not a leaf.
|
||||
assert!(!leaves.contains(&"address"));
|
||||
let nested = proj.nested();
|
||||
assert_eq!(nested.len(), 1);
|
||||
let (name, sub) = nested[0];
|
||||
assert_eq!(name, "address");
|
||||
let sub_leaves = sub.leaf_names();
|
||||
assert!(sub_leaves.contains(&"city") && sub_leaves.contains(&"zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_manifest_has_context_render_strategy_and_mutation() {
|
||||
let m = generate_edge_manifest("/api/mizan");
|
||||
|
||||
// Context: user-scoped (has `user_id`) → render_strategy dynamic_cached.
|
||||
let people = &m["contexts"]["people"];
|
||||
assert_eq!(people["user_scoped"], serde_json::json!(true));
|
||||
assert_eq!(people["render_strategy"], serde_json::json!("dynamic_cached"));
|
||||
assert_eq!(
|
||||
people["endpoints"],
|
||||
serde_json::json!(["/api/mizan/ctx/people/"])
|
||||
);
|
||||
assert_eq!(people["params"], serde_json::json!(["user_id"]));
|
||||
|
||||
// Mutation: rename_person affects people, auto-scopes user_id.
|
||||
let mutation = &m["mutations"]["rename_person"];
|
||||
assert_eq!(mutation["affects"], serde_json::json!(["people"]));
|
||||
assert_eq!(
|
||||
mutation["auto_scoped_params"],
|
||||
serde_json::json!(["user_id"])
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
//! Behavior test for the SSR bridge's framing + request/response correlation.
|
||||
//!
|
||||
//! Bun isn't required (it isn't installed in CI): a stub worker speaking the
|
||||
//! exact same newline-delimited JSON-RPC protocol stands in. The stub emits
|
||||
//! the `{"id":0,"ready":true}` handshake, then for each `render` request
|
||||
//! echoes back `{"id":N,"html":"<rendered:FILE props=PROPS>"}` — exercising
|
||||
//! the ready-gate, the per-request id correlation, and the html extraction
|
||||
//! that the real Bun worker drives.
|
||||
|
||||
use mizan_core::{SsrBridge, WorkerCommand};
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A tiny Python stub that speaks the SSR worker protocol. Written to a temp
|
||||
/// file and launched via `python3 <file>`.
|
||||
const STUB: &str = r#"
|
||||
import sys, json
|
||||
# Handshake: announce readiness exactly as the Bun worker does.
|
||||
sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\n")
|
||||
sys.stdout.flush()
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
msg = json.loads(line)
|
||||
mid = msg.get("id")
|
||||
if msg.get("method") == "render":
|
||||
p = msg["params"]
|
||||
# A sentinel file name forces the worker-error branch.
|
||||
if p["file"] == "/boom.tsx":
|
||||
sys.stdout.write(json.dumps({"id": mid, "error": "render exploded"}) + "\n")
|
||||
else:
|
||||
html = "<rendered:%s props=%s>" % (p["file"], json.dumps(p["props"], sort_keys=True))
|
||||
sys.stdout.write(json.dumps({"id": mid, "html": html}) + "\n")
|
||||
else:
|
||||
sys.stdout.write(json.dumps({"id": mid, "error": "unknown method"}) + "\n")
|
||||
sys.stdout.flush()
|
||||
"#;
|
||||
|
||||
fn write_stub() -> std::path::PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("mizan_ssr_stub_{}.py", std::process::id()));
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(STUB.as_bytes()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_drives_worker_protocol() {
|
||||
let stub = write_stub();
|
||||
let bridge = SsrBridge::new(
|
||||
WorkerCommand {
|
||||
program: "python3".to_string(),
|
||||
args: vec![stub.to_string_lossy().to_string()],
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
// First render — spawns the worker, waits for the ready handshake.
|
||||
let html = bridge
|
||||
.render("/abs/Hello.tsx", json!({"name": "World"}))
|
||||
.expect("first render succeeds");
|
||||
assert_eq!(
|
||||
html,
|
||||
r#"<rendered:/abs/Hello.tsx props={"name": "World"}>"#
|
||||
);
|
||||
|
||||
// Second render reuses the same subprocess; id correlation must keep the
|
||||
// responses matched to their requests.
|
||||
let html2 = bridge
|
||||
.render("/abs/Other.tsx", json!({"a": 1, "b": 2}))
|
||||
.expect("second render succeeds");
|
||||
assert_eq!(
|
||||
html2,
|
||||
r#"<rendered:/abs/Other.tsx props={"a": 1, "b": 2}>"#
|
||||
);
|
||||
|
||||
bridge.shutdown();
|
||||
let _ = std::fs::remove_file(&stub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_propagates_worker_error() {
|
||||
let stub = write_stub();
|
||||
let bridge = SsrBridge::new(
|
||||
WorkerCommand {
|
||||
program: "python3".to_string(),
|
||||
args: vec![stub.to_string_lossy().to_string()],
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
// The sentinel file makes the stub return an `error` frame; the bridge
|
||||
// must surface it as `SsrError::Render`, not a successful empty render.
|
||||
let err = bridge
|
||||
.render("/boom.tsx", json!({}))
|
||||
.expect_err("worker error propagates");
|
||||
assert!(matches!(err, mizan_core::SsrError::Render(_)));
|
||||
assert!(err.to_string().contains("render exploded"));
|
||||
|
||||
// A subsequent good render on the same worker still succeeds.
|
||||
assert!(bridge.render("/ok.tsx", json!({})).is_ok());
|
||||
bridge.shutdown();
|
||||
let _ = std::fs::remove_file(&stub);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
//! Cross-language pin: Rust HS256 JWT + MWT must be byte-identical to the
|
||||
//! Python core (`auth/jwt.py`, `mwt.py`, both PyJWT-backed).
|
||||
//!
|
||||
//! Byte-identity is the whole point — Edge and the origin cache key on these
|
||||
//! tokens, so a one-byte divergence is a cache-key spoof surface. The Python
|
||||
//! reference is the oracle: it mints with fixed claims + a fixed `iat`/`exp`
|
||||
//! (we pin `now` on both sides) and the Rust token must match exactly. We also
|
||||
//! prove round-trip: Rust decodes a Python-minted token and vice-versa.
|
||||
|
||||
use mizan_core::{
|
||||
create_access_token, create_mwt, create_refresh_token, decode_jwt, decode_mwt,
|
||||
compute_permission_key, JwtConfig,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn afi_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/afi")
|
||||
.canonicalize()
|
||||
.expect("tests/afi exists")
|
||||
}
|
||||
|
||||
fn py(code: &str) -> String {
|
||||
let out = Command::new("uv")
|
||||
.args(["run", "python", "-c", code])
|
||||
.current_dir(afi_dir())
|
||||
.output()
|
||||
.expect("invoke uv run python");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"python reference failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
);
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_700_000_000;
|
||||
|
||||
#[test]
|
||||
fn jwt_access_token_matches_python() {
|
||||
let cfg = JwtConfig::new("jwt-pin-secret");
|
||||
let rust = create_access_token(&cfg, "42", "sess-abc", true, false, NOW);
|
||||
|
||||
// Python: freeze time to NOW, mint an access token with the same claims.
|
||||
let code = format!(
|
||||
"import time; from unittest import mock; \
|
||||
from mizan_core.auth.jwt import JWTConfig, create_access_token; \
|
||||
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
|
||||
orig = time.time; \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_access_token('42', 'sess-abc', cfg, is_staff=True, is_superuser=False)); \
|
||||
time.time = orig",
|
||||
);
|
||||
let expected = py(&code);
|
||||
assert_eq!(rust, expected, "JWT access-token byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_refresh_token_matches_python() {
|
||||
let cfg = JwtConfig::new("jwt-pin-secret");
|
||||
let rust = create_refresh_token(&cfg, "7", "sid-9", false, true, NOW);
|
||||
let code = format!(
|
||||
"import time; from mizan_core.auth.jwt import JWTConfig, create_refresh_token; \
|
||||
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_refresh_token('7', 'sid-9', cfg, is_staff=False, is_superuser=True))",
|
||||
);
|
||||
assert_eq!(rust, py(&code), "JWT refresh-token byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_roundtrip_decode_python_minted() {
|
||||
// A Python-minted access token must decode in Rust with matching claims.
|
||||
let code = format!(
|
||||
"import time; from mizan_core.auth.jwt import JWTConfig, create_access_token; \
|
||||
cfg = JWTConfig(private_key='rt-secret', public_key='rt-secret'); \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_access_token('99', 'sess-x', cfg, is_staff=False, is_superuser=True))",
|
||||
);
|
||||
let token = py(&code);
|
||||
let cfg = JwtConfig::new("rt-secret");
|
||||
let payload = decode_jwt(&token, &cfg, Some("access"), NOW + 10).expect("decodes");
|
||||
assert_eq!(payload.sub, "99");
|
||||
assert_eq!(payload.sid, "sess-x");
|
||||
assert!(payload.superuser);
|
||||
assert!(!payload.staff);
|
||||
// Wrong secret → None; expired → None.
|
||||
assert!(decode_jwt(&token, &JwtConfig::new("nope"), None, NOW + 10).is_none());
|
||||
assert!(decode_jwt(&token, &cfg, Some("access"), NOW + 10_000).is_none());
|
||||
// Type mismatch → None.
|
||||
assert!(decode_jwt(&token, &cfg, Some("refresh"), NOW + 10).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_key_matches_python() {
|
||||
let perms = vec!["app.add_thing".to_string(), "app.view_thing".to_string()];
|
||||
let rust = compute_permission_key(true, false, &perms);
|
||||
let code =
|
||||
"from mizan_core.mwt import compute_permission_key; \
|
||||
from unittest.mock import MagicMock; \
|
||||
u = MagicMock(); u.is_staff=True; u.is_superuser=False; \
|
||||
u.get_all_permissions = MagicMock(return_value={'app.view_thing','app.add_thing'}); \
|
||||
print(compute_permission_key(u))";
|
||||
assert_eq!(rust, py(code), "pkey byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mwt_matches_python() {
|
||||
// Build the same pkey on both sides, then mint with frozen time + fixed
|
||||
// kid/audience and compare bytes.
|
||||
let perms = vec!["app.view_thing".to_string()];
|
||||
let pkey = compute_permission_key(false, false, &perms);
|
||||
let rust = create_mwt("mwt-pin-secret", "5", false, false, &pkey, 300, "mizan", "v1", NOW);
|
||||
|
||||
let code = format!(
|
||||
"import time; from unittest.mock import MagicMock; \
|
||||
from mizan_core.mwt import create_mwt; \
|
||||
u = MagicMock(); u.pk=5; u.is_staff=False; u.is_superuser=False; \
|
||||
u.get_all_permissions = MagicMock(return_value={{'app.view_thing'}}); \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_mwt(u, 'mwt-pin-secret', ttl=300, audience='mizan', kid='v1'))",
|
||||
);
|
||||
assert_eq!(rust, py(&code), "MWT byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mwt_roundtrip_and_rejections() {
|
||||
let pkey = compute_permission_key(true, true, &[]);
|
||||
let token = create_mwt("rt-mwt", "13", true, true, &pkey, 300, "mizan", "v1", NOW);
|
||||
let p = decode_mwt(&token, "rt-mwt", "mizan", NOW + 5).expect("decodes");
|
||||
assert_eq!(p.sub, "13");
|
||||
assert!(p.staff && p.superuser);
|
||||
assert_eq!(p.kid, "v1");
|
||||
assert_eq!(p.pkey.len(), 64);
|
||||
// Wrong secret, wrong audience, expired → None.
|
||||
assert!(decode_mwt(&token, "wrong", "mizan", NOW + 5).is_none());
|
||||
assert!(decode_mwt(&token, "rt-mwt", "other", NOW + 5).is_none());
|
||||
assert!(decode_mwt(&token, "rt-mwt", "mizan", NOW + 10_000).is_none());
|
||||
|
||||
// And a Python-minted MWT decodes in Rust.
|
||||
let code = format!(
|
||||
"import time; from unittest.mock import MagicMock; from mizan_core.mwt import create_mwt; \
|
||||
u = MagicMock(); u.pk=21; u.is_staff=True; u.is_superuser=False; \
|
||||
u.get_all_permissions = MagicMock(return_value=set()); \
|
||||
time.time = lambda: {NOW}; print(create_mwt(u, 'rt-mwt', ttl=300, audience='mizan', kid='v1'))",
|
||||
);
|
||||
let py_token = py(&code);
|
||||
let pp = decode_mwt(&py_token, "rt-mwt", "mizan", NOW + 5).expect("py mwt decodes in rust");
|
||||
assert_eq!(pp.sub, "21");
|
||||
assert!(pp.staff && !pp.superuser);
|
||||
}
|
||||
Reference in New Issue
Block a user