1
//! Security gate for the template render surface (Slice D).
2
//!
3
//! Templates are per-user nomiscript that must reach NO mutating and NO
4
//! secret/credential/auth native. Because Slice B keeps the per-user JWT
5
//! private key in the same per-user DB the eval channel reads, an escape here
6
//! would let a template exfiltrate the signing key — so these compile-failure
7
//! tests are a security gate, not mere coverage.
8
//!
9
//! The proof is structural and at compile time: `compile_template` validates
10
//! against `render_compiler_specs`, so a template naming any non-allowlisted
11
//! native fails to compile (the compiler doesn't know the native exists). Each
12
//! native asserted-excluded here IS present on the full eval surface
13
//! (`all_compiler_specs`), so the test proves exclusion, not absence.
14

            
15
use rpc::compile_template;
16
use rpc::natives::{RENDER_NATIVE_ALLOWLIST, all_compiler_specs, render_compiler_specs};
17

            
18
/// Every native that templates MUST NOT reach. Each is on the full eval surface
19
/// (verified by `dangerous_natives_are_on_the_full_surface`), so a
20
/// compile-failure proves the render whitelist excludes a real, callable
21
/// native — not a typo or a never-registered name.
22
const DANGEROUS_NATIVES: &[&str] = &[
23
    "get-config",             // returns config rows incl. the JWT private key
24
    "set-config",             // arbitrary per-user config write
25
    "verify-user-password",   // credential brute force
26
    "lookup-user-by-ssh-key", // global fingerprint -> user directory
27
    "list-ssh-keys",          // auth surface
28
    "remove-ssh-key",         // auth revocation
29
    "create-transaction",     // mutator
30
    "update-transaction",     // mutator
31
    "delete-transaction",     // mutator
32
    "create-account",         // mutator
33
    "create-commodity",       // mutator
34
    "set-account-tag",        // mutator
35
    "set-transaction-tag",    // mutator
36
    "set-split-tag",          // mutator
37
];
38

            
39
4
fn nomi_names(specs: &[nomiscript::HostFnSpec]) -> Vec<String> {
40
4
    specs
41
4
        .iter()
42
125
        .map(|s| s.nomi_name.to_ascii_lowercase())
43
4
        .collect()
44
4
}
45

            
46
#[test]
47
1
fn dangerous_natives_are_on_the_full_surface() {
48
1
    let full = nomi_names(&all_compiler_specs());
49
14
    for name in DANGEROUS_NATIVES {
50
14
        assert!(
51
14
            full.contains(&name.to_string()),
52
            "test premise broken: '{name}' is not on the full eval surface, \
53
             so excluding it from render proves nothing"
54
        );
55
    }
56
1
}
57

            
58
#[test]
59
1
fn render_specs_exclude_every_dangerous_native() {
60
1
    let render = nomi_names(&render_compiler_specs());
61
14
    for name in DANGEROUS_NATIVES {
62
14
        assert!(
63
14
            !render.contains(&name.to_string()),
64
            "render surface must not expose '{name}'"
65
        );
66
    }
67
1
}
68

            
69
#[test]
70
1
fn render_specs_are_exactly_the_allowlist() {
71
1
    let mut render = nomi_names(&render_compiler_specs());
72
1
    render.sort();
73
1
    let mut allow: Vec<String> = RENDER_NATIVE_ALLOWLIST
74
1
        .iter()
75
27
        .map(|s| s.to_string())
76
1
        .collect();
77
1
    allow.sort();
78
1
    assert_eq!(
79
        render, allow,
80
        "render specs must equal the allowlist — a drift means a native \
81
         leaked in or an expected one dropped out"
82
    );
83
1
}
84

            
85
#[test]
86
1
fn template_calling_get_config_fails_to_compile() {
87
1
    let err = compile_template("(get-config \"access_token_private_key\")")
88
1
        .expect_err("get-config must not compile in a template");
89
1
    assert!(format!("{err}").to_lowercase().contains("compile"));
90
1
}
91

            
92
#[test]
93
1
fn template_calling_verify_user_password_fails_to_compile() {
94
1
    compile_template("(verify-user-password \"a@b.c\" \"pw\")")
95
1
        .expect_err("verify-user-password must not compile in a template");
96
1
}
97

            
98
#[test]
99
1
fn template_calling_lookup_user_by_ssh_key_fails_to_compile() {
100
1
    compile_template("(lookup-user-by-ssh-key \"SHA256:abc\")")
101
1
        .expect_err("lookup-user-by-ssh-key must not compile in a template");
102
1
}
103

            
104
#[test]
105
1
fn template_calling_create_transaction_fails_to_compile() {
106
1
    compile_template("(create-transaction \"x\")")
107
1
        .expect_err("create-transaction must not compile in a template");
108
1
}
109

            
110
#[test]
111
1
fn template_calling_set_config_fails_to_compile() {
112
1
    compile_template("(set-config \"k\" \"v\")")
113
1
        .expect_err("set-config must not compile in a template");
114
1
}
115

            
116
#[test]
117
1
fn template_with_only_draft_and_reads_compiles() {
118
    // Pure-shape template: a draft note plus a read native. No DB is touched
119
    // (compile only), so this proves the allowlisted surface is usable.
120
1
    compile_template(
121
1
        "(set-draft-note \"Groceries\")\n\
122
1
         (set-draft-date \"2026-06-15\")\n\
123
1
         (draft-tag \"category\" \"food\")",
124
    )
125
1
    .expect("a template using only draft natives must compile");
126
1
}
127

            
128
#[test]
129
1
fn draft_natives_are_present_on_render_surface() {
130
1
    let render = nomi_names(&render_compiler_specs());
131
4
    for n in [
132
1
        "set-draft-note",
133
1
        "set-draft-date",
134
1
        "draft-split",
135
1
        "draft-tag",
136
1
    ] {
137
4
        assert!(render.contains(&n.to_string()), "render must expose '{n}'");
138
    }
139
1
}