Coverage Report

Created: 2026-09-05 23:00

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