rusty-fez 0.5.0

Agent-native management CLI for Fedora/RHEL (drives cockpit-bridge)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;

mod common;
// Audit off so firewall tests do not depend on a journal socket being present.
use common::fez_fake_quiet as fez_fake;

// status reports the default zone, panic flag, and the seeded drift (runtime
// public carries 9090/tcp that permanent public lacks).
#[test]
fn status_reports_default_zone_and_drift() {
    fez_fake()
        .args(["firewall", "status", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallStatus\""))
        .stdout(contains("\"default_zone\":\"public\""))
        .stdout(contains("\"panic_mode\":false"))
        .stdout(contains("+port 9090/tcp"));
}

// list shows all three seeded zones and marks the default.
#[test]
fn list_shows_all_zones() {
    fez_fake()
        .args(["firewall", "list", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallZoneList\""))
        .stdout(contains("public"))
        .stdout(contains("internal"))
        .stdout(contains("drop"));
}

// show <zone> lists the zone's services and ports.
#[test]
fn show_public_lists_services_and_ports() {
    fez_fake()
        .args(["firewall", "show", "public", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallZone\""))
        .stdout(contains("ssh"))
        .stdout(contains("9090/tcp"));
}

// show <unknown zone> exits 4.
#[test]
fn show_unknown_zone_exits_4() {
    fez_fake()
        .args(["firewall", "show", "bogus"])
        .assert()
        .code(4);
}

// services lists the catalog.
#[test]
fn services_lists_catalog() {
    fez_fake()
        .args(["firewall", "services", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallServiceCatalog\""))
        .stdout(contains("http"))
        .stdout(contains("https"));
}

// add-service succeeds runtime-only with the confirm hint.
#[test]
fn add_service_runtime_only_with_hint() {
    fez_fake()
        .args(["firewall", "add-service", "http", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""))
        .stdout(contains("\"persisted\":false"))
        .stdout(contains("fez firewall confirm"));
}

// add-port with a timeout succeeds and echoes the timeout.
#[test]
fn add_port_with_timeout() {
    fez_fake()
        .args([
            "firewall",
            "add-port",
            "8080/tcp",
            "--timeout",
            "60",
            "--json",
        ])
        .assert()
        .success()
        .stdout(contains("\"timeout\":60"));
}

// add-port with a malformed spec exits 4 (parse error before any call).
#[test]
fn add_port_bad_spec_exits_4() {
    fez_fake()
        .args(["firewall", "add-port", "nope"])
        .assert()
        .code(4);
}

// A protected op (removing the session-critical ssh service) is refused without
// --force: exit 8 and the envelope carries the stable protected-unit code. This
// pins the without-force half of the guard contract on its own.
#[test]
fn protected_op_refused_without_force() {
    fez_fake()
        .args(["firewall", "remove-service", "ssh", "--json"])
        .assert()
        .code(8)
        .stdout(contains("\"code\":\"protected-unit\""));
}

// The same protected op succeeds with --force: exit 0 and the mutation envelope.
// This pins the with-force half independently of the refusal case.
#[test]
fn protected_op_allowed_with_force() {
    fez_fake()
        .args(["firewall", "remove-service", "ssh", "--force", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""));
}

// remove-port on a non-session-critical port succeeds runtime-only with the
// confirm hint (no SSH_CONNECTION in the test env, so 9090/tcp is not gated).
#[test]
fn remove_port_succeeds() {
    fez_fake()
        .args(["firewall", "remove-port", "9090/tcp", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""))
        .stdout(contains("\"persisted\":false"))
        .stdout(contains("fez firewall confirm"));
}

// After a removal, a follow-up runtime read no longer lists the port. The fake
// bridge models the post-removal runtime state via FEZ_FAKE_PORT_REMOVED, so the
// drift port 9090/tcp is gone from the public zone.
#[test]
fn remove_port_gone_from_runtime_after_removal() {
    fez_fake()
        .env("FEZ_FAKE_PORT_REMOVED", "1")
        .args(["firewall", "show", "public", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallZone\""))
        .stdout(contains("ssh"))
        .stdout(contains("9090/tcp").not());
}

// set-default-zone without --force is gated (exit 8); with --force it succeeds.
#[test]
fn set_default_zone_requires_force() {
    fez_fake()
        .args(["firewall", "set-default-zone", "internal"])
        .assert()
        .code(8);
    fez_fake()
        .args([
            "firewall",
            "set-default-zone",
            "internal",
            "--force",
            "--json",
        ])
        .assert()
        .success();
}

// panic on without --force is gated (exit 8); panic off succeeds.
#[test]
fn panic_on_gated_off_allowed() {
    fez_fake()
        .args(["firewall", "panic", "on"])
        .assert()
        .code(8);
    fez_fake()
        .args(["firewall", "panic", "off", "--json"])
        .assert()
        .success()
        .stdout(contains("\"panic_mode\":false"));
}

// confirm calls runtimeToPermanent and reports success.
#[test]
fn confirm_persists() {
    fez_fake()
        .args(["firewall", "confirm", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallConfirm\""))
        .stdout(contains("\"persisted\":true"));
}

// reload with drift present is refused without --force (exit 8); --force reloads.
#[test]
fn reload_with_drift_requires_force() {
    fez_fake().args(["firewall", "reload"]).assert().code(8);
    fez_fake()
        .args(["firewall", "reload", "--force", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""));
}

// Regression for #51 on the reload guard: if permanent config cannot be read,
// a reload may discard unknown runtime drift. Refuse clearly unless --force is
// supplied, rather than surfacing firewalld's raw config.info D-Bus error.
#[test]
fn reload_with_config_info_denied_requires_force() {
    fez_fake()
        .env("FEZ_FAKE_CONFIG_INFO_DENIED", "1")
        .args(["firewall", "reload", "--json"])
        .assert()
        .code(8)
        .stdout(contains("\"code\":\"protected-unit\""))
        .stdout(contains("firewall reload"));
    fez_fake()
        .env("FEZ_FAKE_CONFIG_INFO_DENIED", "1")
        .args(["firewall", "reload", "--force", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""));
}

// Only the real-host config.info authorization failure is converted into an
// unsafe reload guard. Other permanent-config errors still pass through.
#[test]
fn reload_with_other_permanent_config_error_passes_through() {
    fez_fake()
        .env("FEZ_FAKE_CONFIG_UNKNOWN_METHOD", "1")
        .args(["firewall", "reload", "--force", "--json"])
        .assert()
        .code(12)
        .stdout(contains("\"code\":\"unsupported-api\""));
}

// firewalld absent -> exit 9 with remediation.
#[test]
fn firewalld_absent_exits_9() {
    fez_fake()
        .env("FEZ_FAKE_NO_FIREWALLD", "1")
        .args(["firewall", "status", "--json"])
        .assert()
        .code(9)
        .stdout(contains("\"code\":\"dependency-missing\""))
        .stdout(contains("firewalld"));
}

// firewalld absent on `list`: the first getZones call (on the zone interface)
// hits ServiceUnknown, exercising the error-propagation arm of that read and
// mapping to exit 9.
#[test]
fn list_firewalld_absent_exits_9() {
    fez_fake()
        .env("FEZ_FAKE_NO_FIREWALLD", "1")
        .args(["firewall", "list", "--json"])
        .assert()
        .code(9)
        .stdout(contains("\"code\":\"dependency-missing\""));
}

// firewalld absent on `show`: same getZones error path as `list`.
#[test]
fn show_firewalld_absent_exits_9() {
    fez_fake()
        .env("FEZ_FAKE_NO_FIREWALLD", "1")
        .args(["firewall", "show", "public", "--json"])
        .assert()
        .code(9)
        .stdout(contains("\"code\":\"dependency-missing\""));
}

// A mutating call on a host advertising no escalation mechanism -> exit 11.
#[test]
fn mutation_without_escalation_exits_11() {
    fez_fake()
        .env("FEZ_FAKE_BRIDGES", "")
        .args(["firewall", "add-service", "http"])
        .assert()
        .code(11);
}

// Regression for #33: `getZones` lives on the zone interface, not the root
// `org.fedoraproject.FirewallD1` interface. The realistic fake answers it only
// on the zone interface, so `list` succeeding proves fez calls the right one.
// (A path-only fake masked this; real firewalld returns UnknownMethod.)
#[test]
fn list_calls_getzones_on_zone_interface() {
    fez_fake()
        .args(["firewall", "list", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallZoneList\""))
        .stdout(contains("internal"));
}

// Regression for #33: `show` looks the zone up via the zone-interface
// `getZones`, so it resolves a real zone instead of erroring on the wrong
// interface.
#[test]
fn show_resolves_zone_via_zone_interface() {
    fez_fake()
        .args(["firewall", "show", "internal", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallZone\""))
        .stdout(contains("\"zone\":\"internal\""));
}

// Regression for #34: the permanent-config read for drift is polkit-gated
// (PK_ACTION_CONFIG, auth_admin_keep on server and desktop), so `status`
// escalates for it. On a host advertising no escalation mechanism the
// privileged read is denied and status fails with exit 11, rather than
// silently reporting empty drift. This is the e2e symptom from #32/#34.
#[test]
fn status_without_escalation_exits_11() {
    fez_fake()
        .env("FEZ_FAKE_BRIDGES", "")
        .args(["firewall", "status", "--json"])
        .assert()
        .code(11);
}

// Regression for #34: with a working escalation mechanism, `status` reads the
// permanent config over the privileged channel and reports the seeded drift.
// (Covered by status_reports_default_zone_and_drift; this asserts the drift
// specifically came from a privileged permanent read by also checking the
// confirm hint.)
#[test]
fn status_with_escalation_reports_drift() {
    fez_fake()
        .args(["firewall", "status", "--json"])
        .assert()
        .success()
        .stdout(contains("+port 9090/tcp"))
        .stdout(contains("fez firewall confirm"));
}

// Regression for #51: real firewalld can still reject the permanent config
// `config.info` read after cockpit superuser routing. `status` should keep the
// read-only runtime status usable and make the missing drift explicit.
#[test]
fn status_with_config_info_denied_reports_runtime_status() {
    fez_fake()
        .env("FEZ_FAKE_CONFIG_INFO_DENIED", "1")
        .args(["firewall", "status", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallStatus\""))
        .stdout(contains("\"pending_changes_available\":false"))
        .stdout(contains("permanent firewall config was not readable"));
}

// panic off when the host starts in panic mode succeeds (FEZ_FAKE_PANIC).
#[test]
fn panic_off_when_panic_on() {
    fez_fake()
        .env("FEZ_FAKE_PANIC", "1")
        .args(["firewall", "panic", "off", "--json"])
        .assert()
        .success();
}

// show <zone> reports the seeded runtime masquerade state (public is on).
#[test]
fn show_public_reports_masquerade_on() {
    fez_fake()
        .args(["firewall", "show", "public", "--json"])
        .assert()
        .success()
        .stdout(contains("\"masquerade\":true"));
}

// status lists the seeded masquerade drift (runtime public on, permanent off).
#[test]
fn status_reports_masquerade_drift() {
    fez_fake()
        .args(["firewall", "status", "--json"])
        .assert()
        .success()
        .stdout(contains("+masquerade"));
}

// masquerade off without --force is protected (exit 8). Default escalation is
// available (FEZ_FAKE_BRIDGES unset = sudo:ok), so the guard, not escalation,
// is what trips.
#[test]
fn masquerade_off_without_force_exits_8() {
    fez_fake()
        .args(["firewall", "masquerade", "off"])
        .assert()
        .code(8);
}

// masquerade off with --force succeeds.
#[test]
fn masquerade_off_with_force_succeeds() {
    fez_fake()
        .args(["firewall", "masquerade", "off", "--force", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""))
        .stdout(contains("\"masquerade\":false"));
}

// masquerade on is unguarded and succeeds without --force.
#[test]
fn masquerade_on_succeeds() {
    fez_fake()
        .args(["firewall", "masquerade", "on"])
        .assert()
        .success();
}

// masquerade on --json emits a compact FirewallChange envelope mentioning masquerade.
#[test]
fn masquerade_on_json_reports_change() {
    fez_fake()
        .args(["firewall", "masquerade", "on", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""))
        .stdout(contains("\"operation\":\"masquerade\""))
        .stdout(contains("\"masquerade\":true"));
}

// masquerade on with a timeout succeeds and echoes the timeout.
#[test]
fn masquerade_on_with_timeout_echoes_timeout() {
    fez_fake()
        .args(["firewall", "masquerade", "on", "--timeout", "60", "--json"])
        .assert()
        .success()
        .stdout(contains("\"kind\":\"FirewallChange\""))
        .stdout(contains("\"timeout\":60"));
}

// masquerade on a host advertising no escalation mechanism -> exit 11.
#[test]
fn masquerade_on_without_escalation_exits_11() {
    fez_fake()
        .env("FEZ_FAKE_BRIDGES", "")
        .args(["firewall", "masquerade", "on"])
        .assert()
        .code(11);
}

// ---- issue #60: actionable firewall dependency and API errors ----

// On a real host firewalld's D-Bus name may be unreachable (absent or its unit
// failed): cockpit closes the channel with `not-found`, which previously
// surfaced as the vague `channel problem: not-found`. It must instead map to a
// stable dependency-missing error (exit 9) with remediation, not exit 4.
#[test]
fn unreachable_firewalld_maps_to_dependency_missing() {
    fez_fake()
        .env("FEZ_FAKE_FIREWALLD_UNREACHABLE", "1")
        .args(["firewall", "list", "--json"])
        .assert()
        .code(9)
        .stdout(contains("\"code\":\"dependency-missing\""))
        .stdout(contains("firewalld"))
        // The vague channel-problem wording must not leak through.
        .stdout(contains("channel problem").not());
}

// The dependency-missing envelope carries a remediation hint for safe
// read-only follow-up (checking the service), not just a bare message.
#[test]
fn unreachable_firewalld_includes_remediation_detail() {
    fez_fake()
        .env("FEZ_FAKE_FIREWALLD_UNREACHABLE", "1")
        .args(["firewall", "status", "--json"])
        .assert()
        .code(9)
        .stdout(contains("\"code\":\"dependency-missing\""))
        .stdout(contains("firewalld.service"))
        // A safe read-only follow-up hint carries the remediation text, which
        // includes a fez service-status check command (from FezError::hints).
        .stdout(contains("\"hints\""))
        .stdout(contains("\"remediation\""))
        .stdout(contains("fez services status firewalld.service"));
}

// An older firewalld without getMasquerade returns UnknownMethod; that used to
// leak as a raw dbus-error. It must map to the unsupported-api code (exit 12)
// with the method name, so an LLM treats the feature as unsupported rather than
// retrying or recommending an install.
#[test]
fn missing_masquerade_method_maps_to_unsupported_api() {
    fez_fake()
        .env("FEZ_FAKE_NO_MASQUERADE", "1")
        .args(["firewall", "status", "--json"])
        .assert()
        .code(12)
        .stdout(contains("\"code\":\"unsupported-api\""))
        .stdout(contains("getMasquerade"))
        // The hint tells the caller to treat the feature as unsupported.
        .stdout(contains("unsupported"));
}

// Plain-text (no --json) still renders a single actionable error line, not the
// raw dbus/channel internals.
#[test]
fn unreachable_firewalld_plain_text_is_actionable() {
    fez_fake()
        .env("FEZ_FAKE_FIREWALLD_UNREACHABLE", "1")
        .args(["firewall", "list"])
        .assert()
        .code(9)
        .stderr(contains("firewalld"))
        .stderr(contains("channel problem").not());
}