Coverage Report

Created: 2026-10-23 22:23

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-daemon/src/daemon/http/url_surface_contract.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Contract: the URL surface is exactly this, and its namespaces mean something.
3
//!
4
//! `openapi_contract` checks each operation is *described* well. This checks the
5
//! set of paths itself — what a client can call, and where.
6
//!
7
//! The inventory below is the point. A path is a promise: rename one and every
8
//! client 404s at the moment it is upgraded, with nothing in the daemon's own
9
//! logs to say why. Generating the document from the router means the two can
10
//! never disagree, but it also means a rename regenerates cleanly and passes
11
//! every check — the mistake becomes the new truth. So the surface is written
12
//! out once, by hand, and any change to it has to be made here too, in a diff a
13
//! reviewer can read.
14
//!
15
//! Adding an endpoint is meant to fail this test. Add its path to the list.
16
17
use std::collections::{BTreeMap, BTreeSet};
18
19
use serde_json::Value;
20
21
/// Every path the daemon serves, with the methods it answers on.
22
///
23
/// Sorted, and deliberately spelled out rather than derived: this is the list a
24
/// reviewer reads to see what the URL surface became.
25
const URL_SURFACE: &[(&str, &str)] = &[
26
    ("/v1/auth/request", "post"),
27
    ("/v1/auth/status", "get"),
28
    ("/v1/backend/list", "get"),
29
    ("/v1/backend/{backend_id}", "delete"),
30
    ("/v1/backend/{backend_id}/context", "delete,get,post"),
31
    ("/v1/backend/{backend_id}/option/list", "get"),
32
    ("/v1/backend/{backend_id}/option/{name}", "delete,get,post"),
33
    ("/v1/backend/{backend_id}/secret/list", "get"),
34
    ("/v1/backend/{backend_id}/secret/{name}", "delete,get,post"),
35
    ("/v1/context/active", "get,post"),
36
    ("/v1/context/list", "get"),
37
    ("/v1/context/{id}", "delete,get,post"),
38
    ("/v1/events", "get"),
39
    ("/v1/gpu_info", "get"),
40
    ("/v1/ping", "get"),
41
    ("/v1/pipeline", "get"),
42
    ("/v1/pipeline/{stage}", "delete,get,post"),
43
    ("/v1/pipeline/{stage}/backend/list", "get"),
44
    ("/v1/pipeline/{stage}/device/list", "get"),
45
    ("/v1/pipeline/{stage}/model", "delete,get,post"),
46
    ("/v1/pipeline/{stage}/model/cancel", "post"),
47
    ("/v1/pipeline/{stage}/model/list", "get"),
48
    ("/v1/pipeline/{stage}/model/reload", "post"),
49
    ("/v1/pipeline/{stage}/model/{model}/device", "get,post"),
50
    ("/v1/pipeline/{stage}/model/{model}/device/list", "get"),
51
    (
52
        "/v1/pipeline/{stage}/model/{model}/language",
53
        "delete,get,post",
54
    ),
55
    ("/v1/pipeline/{stage}/model/{model}/language/list", "get"),
56
    ("/v1/registry/backend/list", "get"),
57
    ("/v1/registry/backend/preview", "post"),
58
    ("/v1/registry/backend/install", "post"),
59
    ("/v1/registry/backend/refresh", "post"),
60
    ("/v1/registry/backend/update", "post"),
61
    ("/v1/settings/audio_theme", "get,post"),
62
    ("/v1/settings/audio_theme/list", "get"),
63
    ("/v1/settings/audio_theme/test", "post"),
64
    ("/v1/settings/custom_models_dir", "get,post"),
65
    ("/v1/settings/language", "delete,get,post"),
66
    ("/v1/settings/language/list", "get"),
67
    ("/v1/settings/notification_method", "get,post"),
68
    ("/v1/settings/preview_typing", "get,post"),
69
    ("/v1/settings/recording_stop_mode", "get,post"),
70
    ("/v1/settings/update_beta_optin", "get,post"),
71
    ("/v1/settings/update_check_enabled", "get,post"),
72
    ("/v1/settings/volume", "get,post"),
73
    ("/v1/settings/write_method", "get,post"),
74
    ("/v1/settings/write_method/test", "post"),
75
    ("/v1/status", "get"),
76
    ("/v1/transcribe", "post"),
77
    ("/v1/transcribe/realtime", "get"),
78
    ("/v1/transcribe/stop", "post"),
79
    ("/v1/update", "get"),
80
    ("/v1/update/check", "post"),
81
];
82
83
/// The generated document, as a consumer receives it.
84
5
fn document() -> Value {
85
5
    serde_json::to_value(super::openapi_document()).expect("the document serializes")
86
5
}
87
88
/// `path -> "delete,get,post"` for what the router actually serves.
89
2
fn served() -> BTreeMap<String, String> {
90
    const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"];
91
2
    let doc = document();
92
2
    let mut out = BTreeMap::new();
93
104
    for (path, item) in 
doc["paths"]2
.
as_object2
().
expect2
(
"paths is an object"2
) {
94
104
        let mut methods: Vec<String> = item
95
104
            .as_object()
96
104
            .expect("a path item is an object")
97
104
            .keys()
98
158
            .
filter104
(|m| METHODS.contains(&m.as_str()))
99
104
            .cloned()
100
104
            .collect();
101
104
        methods.sort();
102
104
        out.insert(path.clone(), methods.join(","));
103
    }
104
2
    out
105
2
}
106
107
/// The whole URL surface, spelled out.
108
///
109
/// Fails on any added, removed or renamed path, and on any method added to or
110
/// dropped from one. The message names what moved, because "the surface
111
/// changed" is not something a reviewer can act on.
112
#[test]
113
1
fn the_url_surface_is_exactly_this() {
114
1
    let served = served();
115
1
    let expected: BTreeMap<String, String> = URL_SURFACE
116
1
        .iter()
117
52
        .
map1
(|(p, m)| ((*p).to_string(), (*m).to_string()))
118
1
        .collect();
119
120
1
    assert_eq!(
121
1
        URL_SURFACE.len(),
122
1
        expected.len(),
123
        "URL_SURFACE lists a path twice"
124
    );
125
126
1
    let added: Vec<_> = served
127
1
        .keys()
128
52
        .
filter1
(|p| !expected.contains_key(*p))
129
1
        .collect();
130
1
    let removed: Vec<_> = expected
131
1
        .keys()
132
52
        .
filter1
(|p| !served.contains_key(*p))
133
1
        .collect();
134
1
    assert!(
135
1
        added.is_empty() && removed.is_empty(),
136
        "the URL surface moved.\n  served but not listed: {added:?}\n  listed but not served: {removed:?}\n\
137
         Both empty? Then a path was renamed — it will show as one of each."
138
    );
139
140
1
    let changed: Vec<String> = expected
141
1
        .iter()
142
52
        .
filter_map1
(|(p, want)| {
143
52
            let got = served.get(p)
?0
;
144
52
            (got != want).then(|| 
format!0
("{p}: listed {want}, serves {got}"))
145
52
        })
146
1
        .collect();
147
1
    assert!(
148
1
        changed.is_empty(),
149
        "methods changed:\n  {}",
150
0
        changed.join("\n  ")
151
    );
152
1
}
153
154
/// Everything under `/v1/settings/` is a setting, and every setting is there.
155
///
156
/// This is the invariant the namespace exists to carry. It is easy to lose in
157
/// the obvious way: `settings` is also a *scope*, and the scope guards far more
158
/// than the settings — `/backends`, `/pipeline` and `/registry` all sit behind
159
/// it. Tagging by scope rather than by subject is what once filed a live GPU
160
/// probe and a model catalog as "settings", which is how a reader ends up
161
/// looking for `/v1/settings/gpu_info`.
162
///
163
/// So: the namespace and the tag have to agree, in both directions.
164
#[test]
165
1
fn the_settings_namespace_and_the_settings_tag_agree() {
166
1
    let doc = document();
167
    const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"];
168
169
1
    let mut namespaced_but_untagged = Vec::new();
170
1
    let mut tagged_but_not_namespaced = Vec::new();
171
172
52
    for (path, item) in 
doc["paths"]1
.
as_object1
().
expect1
(
"paths is an object"1
) {
173
79
        for (method, op) in 
item52
.
as_object52
().
expect52
(
"a path item is an object"52
) {
174
79
            if !METHODS.contains(&method.as_str()) {
175
0
                continue;
176
79
            }
177
79
            let tagged = op["tags"]
178
79
                .as_array()
179
79
                .expect("every operation carries tags")
180
79
                .iter()
181
79
                .any(|t| t == "settings");
182
79
            let namespaced = path.starts_with("/v1/settings/");
183
79
            match (namespaced, tagged) {
184
0
                (true, false) => namespaced_but_untagged.push(format!("{method} {path}")),
185
0
                (false, true) => tagged_but_not_namespaced.push(format!("{method} {path}")),
186
79
                _ => {}
187
            }
188
        }
189
    }
190
191
1
    assert!(
192
1
        namespaced_but_untagged.is_empty(),
193
        "under /v1/settings/ but not tagged `settings`: {namespaced_but_untagged:?}"
194
    );
195
1
    assert!(
196
1
        tagged_but_not_namespaced.is_empty(),
197
        "tagged `settings` but not under /v1/settings/: {tagged_but_not_namespaced:?}\n\
198
         Either move it into the namespace, or give it the tag its subject deserves — \
199
         sharing the `settings` scope is not the same as being a setting."
200
    );
201
1
}
202
203
/// Every `/v1/settings/` path is guarded by the `settings` scope.
204
///
205
/// The namespace is a promise about access as much as about subject. A settings
206
/// path reachable with a `status` token — or with no token — would be a hole a
207
/// reader has no reason to go looking for, precisely because the prefix says
208
/// what it says.
209
#[test]
210
1
fn the_settings_namespace_is_settings_scoped() {
211
1
    let wrong: Vec<String> = super::v1::enforced_scopes()
212
1
        .into_iter()
213
52
        .
filter1
(|(path, _)| path.starts_with("/v1/settings/"))
214
14
        .
filter1
(|(_, scopes)| scopes.as_deref() != Some(&["settings"]))
215
1
        .map(|(path, scopes)| 
format!0
("{path}: guarded by {scopes:?}"))
216
1
        .collect();
217
1
    assert!(
218
1
        wrong.is_empty(),
219
        "settings paths not behind the `settings` scope:\n  {}",
220
0
        wrong.join("\n  ")
221
    );
222
1
}
223
224
/// No path is served under two spellings.
225
///
226
/// Registering a handler twice under different paths is silent: both answer,
227
/// the document lists both, and clients split between them until one is
228
/// removed. The `summary` is the cheapest per-operation identity available.
229
#[test]
230
1
fn no_operation_is_served_at_two_paths() {
231
1
    let doc = document();
232
    const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"];
233
1
    let mut seen: BTreeMap<String, String> = BTreeMap::new();
234
1
    let mut dupes = Vec::new();
235
236
52
    for (path, item) in 
doc["paths"]1
.
as_object1
().
expect1
(
"paths is an object"1
) {
237
79
        for (method, op) in 
item52
.
as_object52
().
expect52
(
"a path item is an object"52
) {
238
79
            if !METHODS.contains(&method.as_str()) {
239
0
                continue;
240
79
            }
241
79
            let summary = op["summary"]
242
79
                .as_str()
243
79
                .expect("every operation has a summary");
244
79
            if let Some(
first0
) = seen.insert(summary.to_string(), path.clone())
245
0
                && &first != path
246
0
            {
247
0
                dupes.push(format!("{summary:?}: {first} and {path}"));
248
79
            }
249
        }
250
    }
251
1
    assert!(
252
1
        dupes.is_empty(),
253
        "the same operation is served at two paths:\n  {}",
254
0
        dupes.join("\n  ")
255
    );
256
1
}
257
258
/// The declared tags and the tags in use are the same set.
259
///
260
/// Both directions drift silently. A tag declared and no longer carried by any
261
/// operation renders as an empty section in the published docs — which is how
262
/// `models` was left behind when `GET /models` became
263
/// `GET /pipeline/{stage}/model/list`. A tag used but never declared renders
264
/// with no description at all, under a heading the reader has to guess at.
265
#[test]
266
1
fn every_declared_tag_is_used_and_every_used_tag_is_declared() {
267
1
    let doc = document();
268
    const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"];
269
270
1
    let declared: BTreeSet<String> = doc["tags"]
271
1
        .as_array()
272
1
        .expect("the document declares tags")
273
1
        .iter()
274
11
        .
map1
(|t| t["name"].as_str().expect("a tag has a name").to_string())
275
1
        .collect();
276
277
1
    let mut used = BTreeSet::new();
278
52
    for (_, item) in 
doc["paths"]1
.
as_object1
().
expect1
(
"paths is an object"1
) {
279
79
        for (method, op) in 
item52
.
as_object52
().
expect52
(
"a path item is an object"52
) {
280
79
            if !METHODS.contains(&method.as_str()) {
281
0
                continue;
282
79
            }
283
79
            for t in op["tags"].as_array().expect("every operation carries tags") {
284
79
                used.insert(t.as_str().expect("a tag is a string").to_string());
285
79
            }
286
        }
287
    }
288
289
1
    let orphaned: Vec<_> = declared.difference(&used).collect();
290
1
    let undeclared: Vec<_> = used.difference(&declared).collect();
291
1
    assert!(
292
1
        orphaned.is_empty(),
293
        "declared but carried by no operation: {orphaned:?}"
294
    );
295
1
    assert!(
296
1
        undeclared.is_empty(),
297
        "used but never declared, so they publish with no description: {undeclared:?}"
298
    );
299
1
}
300
301
/// Every path the document lists is one the guards know about.
302
///
303
/// [`super::v1::enforced_scopes`] is built from the scope grouping, the document
304
/// from the route registrations. A path in one and not the other means a route
305
/// was registered outside every group — reachable, and guarded by nothing.
306
#[test]
307
1
fn every_path_sits_in_a_scope_group() {
308
1
    let guarded: BTreeSet<String> = super::v1::enforced_scopes().into_keys().collect();
309
1
    let served = served();
310
52
    let 
ungrouped1
:
Vec<&String>1
=
served1
.
keys1
().
filter1
(|p| !guarded.contains(*p)).
collect1
();
311
1
    assert!(
312
1
        ungrouped.is_empty(),
313
        "served but in no scope group, so behind no guard: {ungrouped:?}"
314
    );
315
1
}