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
    "lookup-user-by-ssh-key", // global fingerprint -> user directory
26
    "list-ssh-keys",          // auth surface
27
    "remove-ssh-key",         // auth revocation
28
    "create-transaction",     // mutator
29
    "update-transaction",     // mutator
30
    "delete-transaction",     // mutator
31
    "create-account",         // mutator
32
    "create-commodity",       // mutator
33
    "set-account-tag",        // mutator
34
    "set-transaction-tag",    // mutator
35
    "set-split-tag",          // mutator
36
];
37

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

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

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

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

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

            
91
#[test]
92
1
fn template_calling_lookup_user_by_ssh_key_fails_to_compile() {
93
1
    compile_template("(lookup-user-by-ssh-key \"SHA256:abc\")")
94
1
        .expect_err("lookup-user-by-ssh-key must not compile in a template");
95
1
}
96

            
97
#[test]
98
1
fn template_calling_create_transaction_fails_to_compile() {
99
1
    compile_template("(create-transaction \"x\")")
100
1
        .expect_err("create-transaction must not compile in a template");
101
1
}
102

            
103
#[test]
104
1
fn template_calling_set_config_fails_to_compile() {
105
1
    compile_template("(set-config \"k\" \"v\")")
106
1
        .expect_err("set-config must not compile in a template");
107
1
}
108

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

            
121
#[test]
122
1
fn draft_natives_are_present_on_render_surface() {
123
1
    let render = nomi_names(&render_compiler_specs());
124
4
    for n in [
125
1
        "set-draft-note",
126
1
        "set-draft-date",
127
1
        "draft-split",
128
1
        "draft-tag",
129
1
    ] {
130
4
        assert!(render.contains(&n.to_string()), "render must expose '{n}'");
131
    }
132
1
}