/mandos/release

To get this branch, use:
bzr branch http://bzr.recompile.se/loggerhead/mandos/release

« back to all changes in this revision

Viewing changes to mandos-ctl

  • Committer: Teddy Hogeborn
  • Date: 2019-03-16 00:23:20 UTC
  • mto: This revision was merged to the branch mainline in revision 382.
  • Revision ID: teddy@recompile.se-20190316002320-ajpmbdl4jup156en
mandos-ctl: Refactor

* mandos-ctl (get_mandos_dbus_object, get_managed_objects): Factor out
                                             D-Bus exception catching.
                       
  (if_dbus_exception_log_with_exception_and_exit): New.

Show diffs side-by-side

added added

removed removed

Lines of Context:
64
64
 
65
65
locale.setlocale(locale.LC_ALL, "")
66
66
 
67
 
domain = "se.recompile"
68
 
busname = domain + ".Mandos"
69
 
server_path = "/"
70
 
server_interface = domain + ".Mandos"
71
 
client_interface = domain + ".Mandos.Client"
 
67
dbus_busname_domain = "se.recompile"
 
68
dbus_busname = dbus_busname_domain + ".Mandos"
 
69
server_dbus_path = "/"
 
70
server_dbus_interface = dbus_busname_domain + ".Mandos"
 
71
client_dbus_interface = dbus_busname_domain + ".Mandos.Client"
 
72
del dbus_busname_domain
72
73
version = "1.8.3"
73
74
 
74
75
 
78
79
    dbus.OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
79
80
 
80
81
 
81
 
def milliseconds_to_string(ms):
82
 
    td = datetime.timedelta(0, 0, 0, ms)
83
 
    return ("{days}{hours:02}:{minutes:02}:{seconds:02}"
84
 
            .format(days="{}T".format(td.days) if td.days else "",
85
 
                    hours=td.seconds // 3600,
86
 
                    minutes=(td.seconds % 3600) // 60,
87
 
                    seconds=td.seconds % 60))
 
82
def main():
 
83
    parser = argparse.ArgumentParser()
 
84
 
 
85
    add_command_line_options(parser)
 
86
 
 
87
    options = parser.parse_args()
 
88
 
 
89
    check_option_syntax(parser, options)
 
90
 
 
91
    clientnames = options.client
 
92
 
 
93
    if options.debug:
 
94
        log.setLevel(logging.DEBUG)
 
95
 
 
96
    bus = dbus.SystemBus()
 
97
 
 
98
    mandos_dbus_object = get_mandos_dbus_object(bus)
 
99
 
 
100
    mandos_serv = dbus.Interface(
 
101
        mandos_dbus_object, dbus_interface=server_dbus_interface)
 
102
    mandos_serv_object_manager = dbus.Interface(
 
103
        mandos_dbus_object, dbus_interface=dbus.OBJECT_MANAGER_IFACE)
 
104
 
 
105
    managed_objects = get_managed_objects(mandos_serv_object_manager)
 
106
 
 
107
    all_clients = {}
 
108
    for path, ifs_and_props in managed_objects.items():
 
109
        try:
 
110
            all_clients[path] = ifs_and_props[client_dbus_interface]
 
111
        except KeyError:
 
112
            pass
 
113
 
 
114
    # Compile dict of (clientpath: properties) to process
 
115
    if not clientnames:
 
116
        clients = all_clients
 
117
    else:
 
118
        clients = {}
 
119
        for name in clientnames:
 
120
            for objpath, properties in all_clients.items():
 
121
                if properties["Name"] == name:
 
122
                    clients[objpath] = properties
 
123
                    break
 
124
            else:
 
125
                log.critical("Client not found on server: %r", name)
 
126
                sys.exit(1)
 
127
 
 
128
    # Run all commands on clients
 
129
    commands = commands_from_options(options)
 
130
    for command in commands:
 
131
        command.run(clients, bus, mandos_serv)
 
132
 
 
133
 
 
134
def add_command_line_options(parser):
 
135
    parser.add_argument("--version", action="version",
 
136
                        version="%(prog)s {}".format(version),
 
137
                        help="show version number and exit")
 
138
    parser.add_argument("-a", "--all", action="store_true",
 
139
                        help="Select all clients")
 
140
    parser.add_argument("-v", "--verbose", action="store_true",
 
141
                        help="Print all fields")
 
142
    parser.add_argument("-j", "--dump-json", action="store_true",
 
143
                        help="Dump client data in JSON format")
 
144
    enable_disable = parser.add_mutually_exclusive_group()
 
145
    enable_disable.add_argument("-e", "--enable", action="store_true",
 
146
                                help="Enable client")
 
147
    enable_disable.add_argument("-d", "--disable",
 
148
                                action="store_true",
 
149
                                help="disable client")
 
150
    parser.add_argument("-b", "--bump-timeout", action="store_true",
 
151
                        help="Bump timeout for client")
 
152
    start_stop_checker = parser.add_mutually_exclusive_group()
 
153
    start_stop_checker.add_argument("--start-checker",
 
154
                                    action="store_true",
 
155
                                    help="Start checker for client")
 
156
    start_stop_checker.add_argument("--stop-checker",
 
157
                                    action="store_true",
 
158
                                    help="Stop checker for client")
 
159
    parser.add_argument("-V", "--is-enabled", action="store_true",
 
160
                        help="Check if client is enabled")
 
161
    parser.add_argument("-r", "--remove", action="store_true",
 
162
                        help="Remove client")
 
163
    parser.add_argument("-c", "--checker",
 
164
                        help="Set checker command for client")
 
165
    parser.add_argument("-t", "--timeout", type=string_to_delta,
 
166
                        help="Set timeout for client")
 
167
    parser.add_argument("--extended-timeout", type=string_to_delta,
 
168
                        help="Set extended timeout for client")
 
169
    parser.add_argument("-i", "--interval", type=string_to_delta,
 
170
                        help="Set checker interval for client")
 
171
    approve_deny_default = parser.add_mutually_exclusive_group()
 
172
    approve_deny_default.add_argument(
 
173
        "--approve-by-default", action="store_true",
 
174
        default=None, dest="approved_by_default",
 
175
        help="Set client to be approved by default")
 
176
    approve_deny_default.add_argument(
 
177
        "--deny-by-default", action="store_false",
 
178
        dest="approved_by_default",
 
179
        help="Set client to be denied by default")
 
180
    parser.add_argument("--approval-delay", type=string_to_delta,
 
181
                        help="Set delay before client approve/deny")
 
182
    parser.add_argument("--approval-duration", type=string_to_delta,
 
183
                        help="Set duration of one client approval")
 
184
    parser.add_argument("-H", "--host", help="Set host for client")
 
185
    parser.add_argument("-s", "--secret",
 
186
                        type=argparse.FileType(mode="rb"),
 
187
                        help="Set password blob (file) for client")
 
188
    approve_deny = parser.add_mutually_exclusive_group()
 
189
    approve_deny.add_argument(
 
190
        "-A", "--approve", action="store_true",
 
191
        help="Approve any current client request")
 
192
    approve_deny.add_argument("-D", "--deny", action="store_true",
 
193
                              help="Deny any current client request")
 
194
    parser.add_argument("--debug", action="store_true",
 
195
                        help="Debug mode (show D-Bus commands)")
 
196
    parser.add_argument("--check", action="store_true",
 
197
                        help="Run self-test")
 
198
    parser.add_argument("client", nargs="*", help="Client name")
 
199
 
 
200
 
 
201
def string_to_delta(interval):
 
202
    """Parse a string and return a datetime.timedelta"""
 
203
 
 
204
    try:
 
205
        return rfc3339_duration_to_delta(interval)
 
206
    except ValueError as e:
 
207
        log.warning("%s - Parsing as pre-1.6.1 interval instead",
 
208
                    ' '.join(e.args))
 
209
    return parse_pre_1_6_1_interval(interval)
88
210
 
89
211
 
90
212
def rfc3339_duration_to_delta(duration):
217
339
    return value
218
340
 
219
341
 
220
 
def string_to_delta(interval):
221
 
    """Parse a string and return a datetime.timedelta"""
222
 
 
223
 
    try:
224
 
        return rfc3339_duration_to_delta(interval)
225
 
    except ValueError as e:
226
 
        log.warning("%s - Parsing as pre-1.6.1 interval instead",
227
 
                    ' '.join(e.args))
228
 
    return parse_pre_1_6_1_interval(interval)
229
 
 
230
 
 
231
342
def parse_pre_1_6_1_interval(interval):
232
343
    """Parse an interval string as documented by Mandos before 1.6.1,
233
344
    and return a datetime.timedelta
271
382
    return value
272
383
 
273
384
 
274
 
## Classes for commands.
275
 
 
276
 
# Abstract classes first
 
385
def check_option_syntax(parser, options):
 
386
    """Apply additional restrictions on options, not expressible in
 
387
argparse"""
 
388
 
 
389
    def has_actions(options):
 
390
        return any((options.enable,
 
391
                    options.disable,
 
392
                    options.bump_timeout,
 
393
                    options.start_checker,
 
394
                    options.stop_checker,
 
395
                    options.is_enabled,
 
396
                    options.remove,
 
397
                    options.checker is not None,
 
398
                    options.timeout is not None,
 
399
                    options.extended_timeout is not None,
 
400
                    options.interval is not None,
 
401
                    options.approved_by_default is not None,
 
402
                    options.approval_delay is not None,
 
403
                    options.approval_duration is not None,
 
404
                    options.host is not None,
 
405
                    options.secret is not None,
 
406
                    options.approve,
 
407
                    options.deny))
 
408
 
 
409
    if has_actions(options) and not (options.client or options.all):
 
410
        parser.error("Options require clients names or --all.")
 
411
    if options.verbose and has_actions(options):
 
412
        parser.error("--verbose can only be used alone.")
 
413
    if options.dump_json and (options.verbose
 
414
                              or has_actions(options)):
 
415
        parser.error("--dump-json can only be used alone.")
 
416
    if options.all and not has_actions(options):
 
417
        parser.error("--all requires an action.")
 
418
    if options.is_enabled and len(options.client) > 1:
 
419
        parser.error("--is-enabled requires exactly one client")
 
420
    if options.remove:
 
421
        options.remove = False
 
422
        if has_actions(options) and not options.deny:
 
423
            parser.error("--remove can only be combined with --deny")
 
424
        options.remove = True
 
425
 
 
426
 
 
427
def get_mandos_dbus_object(bus):
 
428
    log.debug("D-Bus: Connect to: (busname=%r, path=%r)",
 
429
              dbus_busname, server_dbus_path)
 
430
    with if_dbus_exception_log_with_exception_and_exit(
 
431
            "Could not connect to Mandos server: %s"):
 
432
        mandos_dbus_object = bus.get_object(dbus_busname,
 
433
                                            server_dbus_path)
 
434
    return mandos_dbus_object
 
435
 
 
436
 
 
437
@contextlib.contextmanager
 
438
def if_dbus_exception_log_with_exception_and_exit(*args, **kwargs):
 
439
    try:
 
440
        yield
 
441
    except dbus.exceptions.DBusException as e:
 
442
        log.critical(*(args + (e,)), **kwargs)
 
443
        sys.exit(1)
 
444
 
 
445
 
 
446
def get_managed_objects(object_manager):
 
447
    log.debug("D-Bus: %s:%s:%s.GetManagedObjects()", dbus_busname,
 
448
              server_dbus_path, dbus.OBJECT_MANAGER_IFACE)
 
449
    with if_dbus_exception_log_with_exception_and_exit(
 
450
            "Failed to access Mandos server through D-Bus:\n%s"):
 
451
        with SilenceLogger("dbus.proxies"):
 
452
            managed_objects = object_manager.GetManagedObjects()
 
453
    return managed_objects
 
454
 
 
455
 
 
456
class SilenceLogger(object):
 
457
    "Simple context manager to silence a particular logger"
 
458
    def __init__(self, loggername):
 
459
        self.logger = logging.getLogger(loggername)
 
460
 
 
461
    def __enter__(self):
 
462
        self.logger.addFilter(self.nullfilter)
 
463
        return self
 
464
 
 
465
    class NullFilter(logging.Filter):
 
466
        def filter(self, record):
 
467
            return False
 
468
 
 
469
    nullfilter = NullFilter()
 
470
 
 
471
    def __exit__(self, exc_type, exc_val, exc_tb):
 
472
        self.logger.removeFilter(self.nullfilter)
 
473
 
 
474
 
 
475
def commands_from_options(options):
 
476
 
 
477
    commands = []
 
478
 
 
479
    if options.is_enabled:
 
480
        commands.append(IsEnabledCmd())
 
481
 
 
482
    if options.approve:
 
483
        commands.append(ApproveCmd())
 
484
 
 
485
    if options.deny:
 
486
        commands.append(DenyCmd())
 
487
 
 
488
    if options.remove:
 
489
        commands.append(RemoveCmd())
 
490
 
 
491
    if options.dump_json:
 
492
        commands.append(DumpJSONCmd())
 
493
 
 
494
    if options.enable:
 
495
        commands.append(EnableCmd())
 
496
 
 
497
    if options.disable:
 
498
        commands.append(DisableCmd())
 
499
 
 
500
    if options.bump_timeout:
 
501
        commands.append(BumpTimeoutCmd())
 
502
 
 
503
    if options.start_checker:
 
504
        commands.append(StartCheckerCmd())
 
505
 
 
506
    if options.stop_checker:
 
507
        commands.append(StopCheckerCmd())
 
508
 
 
509
    if options.approved_by_default is not None:
 
510
        if options.approved_by_default:
 
511
            commands.append(ApproveByDefaultCmd())
 
512
        else:
 
513
            commands.append(DenyByDefaultCmd())
 
514
 
 
515
    if options.checker is not None:
 
516
        commands.append(SetCheckerCmd(options.checker))
 
517
 
 
518
    if options.host is not None:
 
519
        commands.append(SetHostCmd(options.host))
 
520
 
 
521
    if options.secret is not None:
 
522
        commands.append(SetSecretCmd(options.secret))
 
523
 
 
524
    if options.timeout is not None:
 
525
        commands.append(SetTimeoutCmd(options.timeout))
 
526
 
 
527
    if options.extended_timeout:
 
528
        commands.append(
 
529
            SetExtendedTimeoutCmd(options.extended_timeout))
 
530
 
 
531
    if options.interval is not None:
 
532
        commands.append(SetIntervalCmd(options.interval))
 
533
 
 
534
    if options.approval_delay is not None:
 
535
        commands.append(SetApprovalDelayCmd(options.approval_delay))
 
536
 
 
537
    if options.approval_duration is not None:
 
538
        commands.append(
 
539
            SetApprovalDurationCmd(options.approval_duration))
 
540
 
 
541
    # If no command option has been given, show table of clients,
 
542
    # optionally verbosely
 
543
    if not commands:
 
544
        commands.append(PrintTableCmd(verbose=options.verbose))
 
545
 
 
546
    return commands
 
547
 
 
548
 
277
549
class Command(object):
278
550
    """Abstract class for commands"""
279
 
    def run(self, mandos, clients):
 
551
    def run(self, clients, bus=None, mandos=None):
280
552
        """Normal commands should implement run_on_one_client(), but
281
553
        commands which want to operate on all clients at the same time
282
554
        can override this run() method instead."""
283
555
        self.mandos = mandos
284
 
        for client, properties in clients.items():
 
556
        for clientpath, properties in clients.items():
 
557
            log.debug("D-Bus: Connect to: (busname=%r, path=%r)",
 
558
                      dbus_busname, str(clientpath))
 
559
            client = bus.get_object(dbus_busname, clientpath)
285
560
            self.run_on_one_client(client, properties)
286
561
 
287
 
class PrintCmd(Command):
288
 
    """Abstract class for commands printing client details"""
 
562
 
 
563
class IsEnabledCmd(Command):
 
564
    def run(self, clients, bus=None, mandos=None):
 
565
        client, properties = next(iter(clients.items()))
 
566
        if self.is_enabled(client, properties):
 
567
            sys.exit(0)
 
568
        sys.exit(1)
 
569
    def is_enabled(self, client, properties):
 
570
        return properties["Enabled"]
 
571
 
 
572
 
 
573
class ApproveCmd(Command):
 
574
    def run_on_one_client(self, client, properties):
 
575
        log.debug("D-Bus: %s:%s:%s.Approve(True)", dbus_busname,
 
576
                  client.__dbus_object_path__, client_dbus_interface)
 
577
        client.Approve(dbus.Boolean(True),
 
578
                       dbus_interface=client_dbus_interface)
 
579
 
 
580
 
 
581
class DenyCmd(Command):
 
582
    def run_on_one_client(self, client, properties):
 
583
        log.debug("D-Bus: %s:%s:%s.Approve(False)", dbus_busname,
 
584
                  client.__dbus_object_path__, client_dbus_interface)
 
585
        client.Approve(dbus.Boolean(False),
 
586
                       dbus_interface=client_dbus_interface)
 
587
 
 
588
 
 
589
class RemoveCmd(Command):
 
590
    def run_on_one_client(self, client, properties):
 
591
        log.debug("D-Bus: %s:%s:%s.RemoveClient(%r)", dbus_busname,
 
592
                  server_dbus_path, server_dbus_interface,
 
593
                  str(client.__dbus_object_path__))
 
594
        self.mandos.RemoveClient(client.__dbus_object_path__)
 
595
 
 
596
 
 
597
class OutputCmd(Command):
 
598
    """Abstract class for commands outputting client details"""
289
599
    all_keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK",
290
600
                    "Created", "Interval", "Host", "KeyID",
291
601
                    "Fingerprint", "CheckerRunning", "LastEnabled",
293
603
                    "LastApprovalRequest", "ApprovalDelay",
294
604
                    "ApprovalDuration", "Checker", "ExtendedTimeout",
295
605
                    "Expires", "LastCheckerStatus")
296
 
    def run(self, mandos, clients):
 
606
 
 
607
    def run(self, clients, bus=None, mandos=None):
297
608
        print(self.output(clients.values()))
298
 
    def output(self, clients):
299
 
        raise NotImplementedError()
300
 
 
301
 
class PropertyCmd(Command):
302
 
    """Abstract class for Actions for setting one client property"""
303
 
    def run_on_one_client(self, client, properties):
304
 
        """Set the Client's D-Bus property"""
305
 
        log.debug("D-Bus: %s:%s:%s.Set(%r, %r, %r)", busname,
306
 
                  client.__dbus_object_path__,
307
 
                  dbus.PROPERTIES_IFACE, client_interface,
308
 
                  self.propname, self.value_to_set
309
 
                  if not isinstance(self.value_to_set, dbus.Boolean)
310
 
                  else bool(self.value_to_set))
311
 
        client.Set(client_interface, self.propname, self.value_to_set,
312
 
                   dbus_interface=dbus.PROPERTIES_IFACE)
313
 
    @property
314
 
    def propname(self):
315
 
        raise NotImplementedError()
316
 
 
317
 
class ValueArgumentMixIn(object):
318
 
    """Mixin class for commands taking a value as argument"""
319
 
    def __init__(self, value):
320
 
        self.value_to_set = value
321
 
 
322
 
class MillisecondsValueArgumentMixIn(ValueArgumentMixIn):
323
 
    """Mixin class for commands taking a value argument as
324
 
    milliseconds."""
325
 
    @property
326
 
    def value_to_set(self):
327
 
        return self._vts
328
 
    @value_to_set.setter
329
 
    def value_to_set(self, value):
330
 
        """When setting, convert value to a datetime.timedelta"""
331
 
        self._vts = int(round(value.total_seconds() * 1000))
332
 
 
333
 
# Actual (non-abstract) command classes
334
 
 
335
 
class PrintTableCmd(PrintCmd):
 
609
 
 
610
    def output(self, clients):
 
611
        raise NotImplementedError()
 
612
 
 
613
 
 
614
class DumpJSONCmd(OutputCmd):
 
615
    def output(self, clients):
 
616
        data = {client["Name"]:
 
617
                {key: self.dbus_boolean_to_bool(client[key])
 
618
                 for key in self.all_keywords}
 
619
                for client in clients}
 
620
        return json.dumps(data, indent=4, separators=(',', ': '))
 
621
 
 
622
    @staticmethod
 
623
    def dbus_boolean_to_bool(value):
 
624
        if isinstance(value, dbus.Boolean):
 
625
            value = bool(value)
 
626
        return value
 
627
 
 
628
 
 
629
class PrintTableCmd(OutputCmd):
336
630
    def __init__(self, verbose=False):
337
631
        self.verbose = verbose
338
632
 
339
633
    def output(self, clients):
340
 
        default_keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK")
 
634
        default_keywords = ("Name", "Enabled", "Timeout",
 
635
                            "LastCheckedOK")
341
636
        keywords = default_keywords
342
637
        if self.verbose:
343
638
            keywords = self.all_keywords
367
662
            "LastCheckerStatus": "Last Checker Status",
368
663
        }
369
664
 
370
 
        def __init__(self, clients, keywords, tableheaders=None):
 
665
        def __init__(self, clients, keywords):
371
666
            self.clients = clients
372
667
            self.keywords = keywords
373
 
            if tableheaders is not None:
374
 
                self.tableheaders = tableheaders
375
668
 
376
669
        def __str__(self):
377
670
            return "\n".join(self.rows())
400
693
        def string_from_client(self, client, key):
401
694
            return self.valuetostring(client[key], key)
402
695
 
403
 
        @staticmethod
404
 
        def valuetostring(value, keyword):
 
696
        @classmethod
 
697
        def valuetostring(cls, value, keyword):
405
698
            if isinstance(value, dbus.Boolean):
406
699
                return "Yes" if value else "No"
407
700
            if keyword in ("Timeout", "Interval", "ApprovalDelay",
408
701
                           "ApprovalDuration", "ExtendedTimeout"):
409
 
                return milliseconds_to_string(value)
 
702
                return cls.milliseconds_to_string(value)
410
703
            return str(value)
411
704
 
412
705
        def header_line(self, format_string):
417
710
                **{key: self.string_from_client(client, key)
418
711
                   for key in self.keywords})
419
712
 
420
 
 
421
 
 
422
 
class DumpJSONCmd(PrintCmd):
423
 
    def output(self, clients):
424
 
        data = {client["Name"]:
425
 
                {key: self.dbus_boolean_to_bool(client[key])
426
 
                 for key in self.all_keywords}
427
 
                for client in clients.values()}
428
 
        return json.dumps(data, indent=4, separators=(',', ': '))
429
 
    @staticmethod
430
 
    def dbus_boolean_to_bool(value):
431
 
        if isinstance(value, dbus.Boolean):
432
 
            value = bool(value)
433
 
        return value
434
 
 
435
 
class IsEnabledCmd(Command):
 
713
        @staticmethod
 
714
        def milliseconds_to_string(ms):
 
715
            td = datetime.timedelta(0, 0, 0, ms)
 
716
            return ("{days}{hours:02}:{minutes:02}:{seconds:02}"
 
717
                    .format(days="{}T".format(td.days)
 
718
                            if td.days else "",
 
719
                            hours=td.seconds // 3600,
 
720
                            minutes=(td.seconds % 3600) // 60,
 
721
                            seconds=td.seconds % 60))
 
722
 
 
723
 
 
724
class PropertyCmd(Command):
 
725
    """Abstract class for Actions for setting one client property"""
 
726
 
436
727
    def run_on_one_client(self, client, properties):
437
 
        if self.is_enabled(client, properties):
438
 
            sys.exit(0)
439
 
        sys.exit(1)
440
 
    def is_enabled(self, client, properties):
441
 
        log.debug("D-Bus: %s:%s:%s.Get(%r, %r)", busname,
 
728
        """Set the Client's D-Bus property"""
 
729
        log.debug("D-Bus: %s:%s:%s.Set(%r, %r, %r)", dbus_busname,
442
730
                  client.__dbus_object_path__,
443
 
                  dbus.PROPERTIES_IFACE, client_interface,
444
 
                  "Enabled")
445
 
        return bool(client.Get(client_interface, "Enabled",
446
 
                               dbus_interface=dbus.PROPERTIES_IFACE))
447
 
 
448
 
class RemoveCmd(Command):
449
 
    def run_on_one_client(self, client, properties):
450
 
        log.debug("D-Bus: %s:%s:%s.RemoveClient(%r)", busname,
451
 
                  server_path, server_interface,
452
 
                  str(client.__dbus_object_path__))
453
 
        self.mandos.RemoveClient(client.__dbus_object_path__)
454
 
 
455
 
class ApproveCmd(Command):
456
 
    def run_on_one_client(self, client, properties):
457
 
        log.debug("D-Bus: %s:%s.Approve(True)",
458
 
                  client.__dbus_object_path__, client_interface)
459
 
        client.Approve(dbus.Boolean(True),
460
 
                       dbus_interface=client_interface)
461
 
 
462
 
class DenyCmd(Command):
463
 
    def run_on_one_client(self, client, properties):
464
 
        log.debug("D-Bus: %s:%s.Approve(False)",
465
 
                  client.__dbus_object_path__, client_interface)
466
 
        client.Approve(dbus.Boolean(False),
467
 
                       dbus_interface=client_interface)
 
731
                  dbus.PROPERTIES_IFACE, client_dbus_interface,
 
732
                  self.propname, self.value_to_set
 
733
                  if not isinstance(self.value_to_set, dbus.Boolean)
 
734
                  else bool(self.value_to_set))
 
735
        client.Set(client_dbus_interface, self.propname,
 
736
                   self.value_to_set,
 
737
                   dbus_interface=dbus.PROPERTIES_IFACE)
 
738
 
 
739
    @property
 
740
    def propname(self):
 
741
        raise NotImplementedError()
 
742
 
468
743
 
469
744
class EnableCmd(PropertyCmd):
470
745
    propname = "Enabled"
471
746
    value_to_set = dbus.Boolean(True)
472
747
 
 
748
 
473
749
class DisableCmd(PropertyCmd):
474
750
    propname = "Enabled"
475
751
    value_to_set = dbus.Boolean(False)
476
752
 
 
753
 
477
754
class BumpTimeoutCmd(PropertyCmd):
478
755
    propname = "LastCheckedOK"
479
756
    value_to_set = ""
480
757
 
 
758
 
481
759
class StartCheckerCmd(PropertyCmd):
482
760
    propname = "CheckerRunning"
483
761
    value_to_set = dbus.Boolean(True)
484
762
 
 
763
 
485
764
class StopCheckerCmd(PropertyCmd):
486
765
    propname = "CheckerRunning"
487
766
    value_to_set = dbus.Boolean(False)
488
767
 
 
768
 
489
769
class ApproveByDefaultCmd(PropertyCmd):
490
770
    propname = "ApprovedByDefault"
491
771
    value_to_set = dbus.Boolean(True)
492
772
 
 
773
 
493
774
class DenyByDefaultCmd(PropertyCmd):
494
775
    propname = "ApprovedByDefault"
495
776
    value_to_set = dbus.Boolean(False)
496
777
 
497
 
class SetCheckerCmd(PropertyCmd, ValueArgumentMixIn):
 
778
 
 
779
class PropertyValueCmd(PropertyCmd):
 
780
    """Abstract class for PropertyCmd recieving a value as argument"""
 
781
    def __init__(self, value):
 
782
        self.value_to_set = value
 
783
 
 
784
 
 
785
class SetCheckerCmd(PropertyValueCmd):
498
786
    propname = "Checker"
499
787
 
500
 
class SetHostCmd(PropertyCmd, ValueArgumentMixIn):
 
788
 
 
789
class SetHostCmd(PropertyValueCmd):
501
790
    propname = "Host"
502
791
 
503
 
class SetSecretCmd(PropertyCmd, ValueArgumentMixIn):
 
792
 
 
793
class SetSecretCmd(PropertyValueCmd):
504
794
    propname = "Secret"
 
795
 
505
796
    @property
506
797
    def value_to_set(self):
507
798
        return self._vts
 
799
 
508
800
    @value_to_set.setter
509
801
    def value_to_set(self, value):
510
802
        """When setting, read data from supplied file object"""
511
803
        self._vts = value.read()
512
804
        value.close()
513
805
 
514
 
class SetTimeoutCmd(PropertyCmd, MillisecondsValueArgumentMixIn):
 
806
 
 
807
class MillisecondsPropertyValueArgumentCmd(PropertyValueCmd):
 
808
    """Abstract class for PropertyValueCmd taking a value argument as
 
809
a datetime.timedelta() but should store it as milliseconds."""
 
810
 
 
811
    @property
 
812
    def value_to_set(self):
 
813
        return self._vts
 
814
 
 
815
    @value_to_set.setter
 
816
    def value_to_set(self, value):
 
817
        """When setting, convert value from a datetime.timedelta"""
 
818
        self._vts = int(round(value.total_seconds() * 1000))
 
819
 
 
820
 
 
821
class SetTimeoutCmd(MillisecondsPropertyValueArgumentCmd):
515
822
    propname = "Timeout"
516
823
 
517
 
class SetExtendedTimeoutCmd(PropertyCmd,
518
 
                            MillisecondsValueArgumentMixIn):
 
824
 
 
825
class SetExtendedTimeoutCmd(MillisecondsPropertyValueArgumentCmd):
519
826
    propname = "ExtendedTimeout"
520
827
 
521
 
class SetIntervalCmd(PropertyCmd, MillisecondsValueArgumentMixIn):
 
828
 
 
829
class SetIntervalCmd(MillisecondsPropertyValueArgumentCmd):
522
830
    propname = "Interval"
523
831
 
524
 
class SetApprovalDelayCmd(PropertyCmd,
525
 
                          MillisecondsValueArgumentMixIn):
 
832
 
 
833
class SetApprovalDelayCmd(MillisecondsPropertyValueArgumentCmd):
526
834
    propname = "ApprovalDelay"
527
835
 
528
 
class SetApprovalDurationCmd(PropertyCmd,
529
 
                             MillisecondsValueArgumentMixIn):
 
836
 
 
837
class SetApprovalDurationCmd(MillisecondsPropertyValueArgumentCmd):
530
838
    propname = "ApprovalDuration"
531
839
 
532
 
def add_command_line_options(parser):
533
 
    parser.add_argument("--version", action="version",
534
 
                        version="%(prog)s {}".format(version),
535
 
                        help="show version number and exit")
536
 
    parser.add_argument("-a", "--all", action="store_true",
537
 
                        help="Select all clients")
538
 
    parser.add_argument("-v", "--verbose", action="store_true",
539
 
                        help="Print all fields")
540
 
    parser.add_argument("-j", "--dump-json", action="store_true",
541
 
                        help="Dump client data in JSON format")
542
 
    enable_disable = parser.add_mutually_exclusive_group()
543
 
    enable_disable.add_argument("-e", "--enable", action="store_true",
544
 
                                help="Enable client")
545
 
    enable_disable.add_argument("-d", "--disable",
546
 
                                action="store_true",
547
 
                                help="disable client")
548
 
    parser.add_argument("-b", "--bump-timeout", action="store_true",
549
 
                        help="Bump timeout for client")
550
 
    start_stop_checker = parser.add_mutually_exclusive_group()
551
 
    start_stop_checker.add_argument("--start-checker",
552
 
                                    action="store_true",
553
 
                                    help="Start checker for client")
554
 
    start_stop_checker.add_argument("--stop-checker",
555
 
                                    action="store_true",
556
 
                                    help="Stop checker for client")
557
 
    parser.add_argument("-V", "--is-enabled", action="store_true",
558
 
                        help="Check if client is enabled")
559
 
    parser.add_argument("-r", "--remove", action="store_true",
560
 
                        help="Remove client")
561
 
    parser.add_argument("-c", "--checker",
562
 
                        help="Set checker command for client")
563
 
    parser.add_argument("-t", "--timeout", type=string_to_delta,
564
 
                        help="Set timeout for client")
565
 
    parser.add_argument("--extended-timeout", type=string_to_delta,
566
 
                        help="Set extended timeout for client")
567
 
    parser.add_argument("-i", "--interval", type=string_to_delta,
568
 
                        help="Set checker interval for client")
569
 
    approve_deny_default = parser.add_mutually_exclusive_group()
570
 
    approve_deny_default.add_argument(
571
 
        "--approve-by-default", action="store_true",
572
 
        default=None, dest="approved_by_default",
573
 
        help="Set client to be approved by default")
574
 
    approve_deny_default.add_argument(
575
 
        "--deny-by-default", action="store_false",
576
 
        dest="approved_by_default",
577
 
        help="Set client to be denied by default")
578
 
    parser.add_argument("--approval-delay", type=string_to_delta,
579
 
                        help="Set delay before client approve/deny")
580
 
    parser.add_argument("--approval-duration", type=string_to_delta,
581
 
                        help="Set duration of one client approval")
582
 
    parser.add_argument("-H", "--host", help="Set host for client")
583
 
    parser.add_argument("-s", "--secret",
584
 
                        type=argparse.FileType(mode="rb"),
585
 
                        help="Set password blob (file) for client")
586
 
    approve_deny = parser.add_mutually_exclusive_group()
587
 
    approve_deny.add_argument(
588
 
        "-A", "--approve", action="store_true",
589
 
        help="Approve any current client request")
590
 
    approve_deny.add_argument("-D", "--deny", action="store_true",
591
 
                              help="Deny any current client request")
592
 
    parser.add_argument("--debug", action="store_true",
593
 
                        help="Debug mode (show D-Bus commands)")
594
 
    parser.add_argument("--check", action="store_true",
595
 
                        help="Run self-test")
596
 
    parser.add_argument("client", nargs="*", help="Client name")
597
 
 
598
 
 
599
 
def commands_from_options(options):
600
 
 
601
 
    commands = []
602
 
 
603
 
    if options.dump_json:
604
 
        commands.append(DumpJSONCmd())
605
 
 
606
 
    if options.enable:
607
 
        commands.append(EnableCmd())
608
 
 
609
 
    if options.disable:
610
 
        commands.append(DisableCmd())
611
 
 
612
 
    if options.bump_timeout:
613
 
        commands.append(BumpTimeoutCmd())
614
 
 
615
 
    if options.start_checker:
616
 
        commands.append(StartCheckerCmd())
617
 
 
618
 
    if options.stop_checker:
619
 
        commands.append(StopCheckerCmd())
620
 
 
621
 
    if options.is_enabled:
622
 
        commands.append(IsEnabledCmd())
623
 
 
624
 
    if options.checker is not None:
625
 
        commands.append(SetCheckerCmd(options.checker))
626
 
 
627
 
    if options.timeout is not None:
628
 
        commands.append(SetTimeoutCmd(options.timeout))
629
 
 
630
 
    if options.extended_timeout:
631
 
        commands.append(
632
 
            SetExtendedTimeoutCmd(options.extended_timeout))
633
 
 
634
 
    if options.interval is not None:
635
 
        commands.append(SetIntervalCmd(options.interval))
636
 
 
637
 
    if options.approved_by_default is not None:
638
 
        if options.approved_by_default:
639
 
            commands.append(ApproveByDefaultCmd())
640
 
        else:
641
 
            commands.append(DenyByDefaultCmd())
642
 
 
643
 
    if options.approval_delay is not None:
644
 
        commands.append(SetApprovalDelayCmd(options.approval_delay))
645
 
 
646
 
    if options.approval_duration is not None:
647
 
        commands.append(
648
 
            SetApprovalDurationCmd(options.approval_duration))
649
 
 
650
 
    if options.host is not None:
651
 
        commands.append(SetHostCmd(options.host))
652
 
 
653
 
    if options.secret is not None:
654
 
        commands.append(SetSecretCmd(options.secret))
655
 
 
656
 
    if options.approve:
657
 
        commands.append(ApproveCmd())
658
 
 
659
 
    if options.deny:
660
 
        commands.append(DenyCmd())
661
 
 
662
 
    if options.remove:
663
 
        commands.append(RemoveCmd())
664
 
 
665
 
    # If no command option has been given, show table of clients,
666
 
    # optionally verbosely
667
 
    if not commands:
668
 
        commands.append(PrintTableCmd(verbose=options.verbose))
669
 
 
670
 
    return commands
671
 
 
672
 
 
673
 
def check_option_syntax(parser, options):
674
 
    """Apply additional restrictions on options, not expressible in
675
 
argparse"""
676
 
 
677
 
    def has_actions(options):
678
 
        return any((options.enable,
679
 
                    options.disable,
680
 
                    options.bump_timeout,
681
 
                    options.start_checker,
682
 
                    options.stop_checker,
683
 
                    options.is_enabled,
684
 
                    options.remove,
685
 
                    options.checker is not None,
686
 
                    options.timeout is not None,
687
 
                    options.extended_timeout is not None,
688
 
                    options.interval is not None,
689
 
                    options.approved_by_default is not None,
690
 
                    options.approval_delay is not None,
691
 
                    options.approval_duration is not None,
692
 
                    options.host is not None,
693
 
                    options.secret is not None,
694
 
                    options.approve,
695
 
                    options.deny))
696
 
 
697
 
    if has_actions(options) and not (options.client or options.all):
698
 
        parser.error("Options require clients names or --all.")
699
 
    if options.verbose and has_actions(options):
700
 
        parser.error("--verbose can only be used alone.")
701
 
    if options.dump_json and (options.verbose
702
 
                              or has_actions(options)):
703
 
        parser.error("--dump-json can only be used alone.")
704
 
    if options.all and not has_actions(options):
705
 
        parser.error("--all requires an action.")
706
 
    if options.is_enabled and len(options.client) > 1:
707
 
        parser.error("--is-enabled requires exactly one client")
708
 
    if options.remove:
709
 
        options.remove = False
710
 
        if has_actions(options) and not options.deny:
711
 
            parser.error("--remove can only be combined with --deny")
712
 
        options.remove = True
713
 
 
714
 
 
715
 
def main():
716
 
    parser = argparse.ArgumentParser()
717
 
 
718
 
    add_command_line_options(parser)
719
 
 
720
 
    options = parser.parse_args()
721
 
 
722
 
    check_option_syntax(parser, options)
723
 
 
724
 
    clientnames = options.client
725
 
 
726
 
    if options.debug:
727
 
        log.setLevel(logging.DEBUG)
728
 
 
729
 
    try:
730
 
        bus = dbus.SystemBus()
731
 
        log.debug("D-Bus: Connect to: (name=%r, path=%r)", busname,
732
 
                  server_path)
733
 
        mandos_dbus_objc = bus.get_object(busname, server_path)
734
 
    except dbus.exceptions.DBusException:
735
 
        log.critical("Could not connect to Mandos server")
736
 
        sys.exit(1)
737
 
 
738
 
    mandos_serv = dbus.Interface(mandos_dbus_objc,
739
 
                                 dbus_interface=server_interface)
740
 
    mandos_serv_object_manager = dbus.Interface(
741
 
        mandos_dbus_objc, dbus_interface=dbus.OBJECT_MANAGER_IFACE)
742
 
 
743
 
    # Filter out log message from dbus module
744
 
    dbus_logger = logging.getLogger("dbus.proxies")
745
 
    class NullFilter(logging.Filter):
746
 
        def filter(self, record):
747
 
            return False
748
 
    dbus_filter = NullFilter()
749
 
    try:
750
 
        dbus_logger.addFilter(dbus_filter)
751
 
        log.debug("D-Bus: %s:%s:%s.GetManagedObjects()", busname,
752
 
                  server_path, dbus.OBJECT_MANAGER_IFACE)
753
 
        mandos_clients = {path: ifs_and_props[client_interface]
754
 
                          for path, ifs_and_props in
755
 
                          mandos_serv_object_manager
756
 
                          .GetManagedObjects().items()
757
 
                          if client_interface in ifs_and_props}
758
 
    except dbus.exceptions.DBusException as e:
759
 
        log.critical("Failed to access Mandos server through D-Bus:"
760
 
                     "\n%s", e)
761
 
        sys.exit(1)
762
 
    finally:
763
 
        # restore dbus logger
764
 
        dbus_logger.removeFilter(dbus_filter)
765
 
 
766
 
    # Compile dict of (clients: properties) to process
767
 
    clients = {}
768
 
 
769
 
    if not clientnames:
770
 
        clients = {(log.debug("D-Bus: Connect to: (name=%r, path=%r)",
771
 
                              busname, str(path)) and False) or
772
 
                   bus.get_object(busname, path): properties
773
 
                   for path, properties in mandos_clients.items()}
774
 
    else:
775
 
        for name in clientnames:
776
 
            for path, client in mandos_clients.items():
777
 
                if client["Name"] == name:
778
 
                    log.debug("D-Bus: Connect to: (name=%r, path=%r)",
779
 
                              busname, str(path))
780
 
                    client_objc = bus.get_object(busname, path)
781
 
                    clients[client_objc] = client
782
 
                    break
783
 
            else:
784
 
                log.critical("Client not found on server: %r", name)
785
 
                sys.exit(1)
786
 
 
787
 
    # Run all commands on clients
788
 
    commands = commands_from_options(options)
789
 
    for command in commands:
790
 
        command.run(mandos_serv, clients)
791
840
 
792
841
 
793
 
class Test_milliseconds_to_string(unittest.TestCase):
794
 
    def test_all(self):
795
 
        self.assertEqual(milliseconds_to_string(93785000),
796
 
                         "1T02:03:05")
797
 
    def test_no_days(self):
798
 
        self.assertEqual(milliseconds_to_string(7385000), "02:03:05")
799
 
    def test_all_zero(self):
800
 
        self.assertEqual(milliseconds_to_string(0), "00:00:00")
801
 
    def test_no_fractional_seconds(self):
802
 
        self.assertEqual(milliseconds_to_string(400), "00:00:00")
803
 
        self.assertEqual(milliseconds_to_string(900), "00:00:00")
804
 
        self.assertEqual(milliseconds_to_string(1900), "00:00:01")
805
 
 
806
842
class Test_string_to_delta(unittest.TestCase):
807
843
    def test_handles_basic_rfc3339(self):
808
844
        self.assertEqual(string_to_delta("PT0S"),
813
849
                         datetime.timedelta(0, 1))
814
850
        self.assertEqual(string_to_delta("PT2H"),
815
851
                         datetime.timedelta(0, 7200))
 
852
 
816
853
    def test_falls_back_to_pre_1_6_1_with_warning(self):
817
854
        # assertLogs only exists in Python 3.4
818
855
        if hasattr(self, "assertLogs"):
836
873
        self.assertEqual(value, datetime.timedelta(0, 7200))
837
874
 
838
875
 
 
876
class Test_check_option_syntax(unittest.TestCase):
 
877
    def setUp(self):
 
878
        self.parser = argparse.ArgumentParser()
 
879
        add_command_line_options(self.parser)
 
880
 
 
881
    def test_actions_requires_client_or_all(self):
 
882
        for action, value in self.actions.items():
 
883
            options = self.parser.parse_args()
 
884
            setattr(options, action, value)
 
885
            with self.assertParseError():
 
886
                self.check_option_syntax(options)
 
887
 
 
888
    # This mostly corresponds to the definition from has_actions() in
 
889
    # check_option_syntax()
 
890
    actions = {
 
891
        # The actual values set here are not that important, but we do
 
892
        # at least stick to the correct types, even though they are
 
893
        # never used
 
894
        "enable": True,
 
895
        "disable": True,
 
896
        "bump_timeout": True,
 
897
        "start_checker": True,
 
898
        "stop_checker": True,
 
899
        "is_enabled": True,
 
900
        "remove": True,
 
901
        "checker": "x",
 
902
        "timeout": datetime.timedelta(),
 
903
        "extended_timeout": datetime.timedelta(),
 
904
        "interval": datetime.timedelta(),
 
905
        "approved_by_default": True,
 
906
        "approval_delay": datetime.timedelta(),
 
907
        "approval_duration": datetime.timedelta(),
 
908
        "host": "x",
 
909
        "secret": io.BytesIO(b"x"),
 
910
        "approve": True,
 
911
        "deny": True,
 
912
    }
 
913
 
 
914
    @contextlib.contextmanager
 
915
    def assertParseError(self):
 
916
        with self.assertRaises(SystemExit) as e:
 
917
            with self.temporarily_suppress_stderr():
 
918
                yield
 
919
        # Exit code from argparse is guaranteed to be "2".  Reference:
 
920
        # https://docs.python.org/3/library
 
921
        # /argparse.html#exiting-methods
 
922
        self.assertEqual(e.exception.code, 2)
 
923
 
 
924
    @staticmethod
 
925
    @contextlib.contextmanager
 
926
    def temporarily_suppress_stderr():
 
927
        null = os.open(os.path.devnull, os.O_RDWR)
 
928
        stderrcopy = os.dup(sys.stderr.fileno())
 
929
        os.dup2(null, sys.stderr.fileno())
 
930
        os.close(null)
 
931
        try:
 
932
            yield
 
933
        finally:
 
934
            # restore stderr
 
935
            os.dup2(stderrcopy, sys.stderr.fileno())
 
936
            os.close(stderrcopy)
 
937
 
 
938
    def check_option_syntax(self, options):
 
939
        check_option_syntax(self.parser, options)
 
940
 
 
941
    def test_actions_conflicts_with_verbose(self):
 
942
        for action, value in self.actions.items():
 
943
            options = self.parser.parse_args()
 
944
            setattr(options, action, value)
 
945
            options.verbose = True
 
946
            with self.assertParseError():
 
947
                self.check_option_syntax(options)
 
948
 
 
949
    def test_dump_json_conflicts_with_verbose(self):
 
950
        options = self.parser.parse_args()
 
951
        options.dump_json = True
 
952
        options.verbose = True
 
953
        with self.assertParseError():
 
954
            self.check_option_syntax(options)
 
955
 
 
956
    def test_dump_json_conflicts_with_action(self):
 
957
        for action, value in self.actions.items():
 
958
            options = self.parser.parse_args()
 
959
            setattr(options, action, value)
 
960
            options.dump_json = True
 
961
            with self.assertParseError():
 
962
                self.check_option_syntax(options)
 
963
 
 
964
    def test_all_can_not_be_alone(self):
 
965
        options = self.parser.parse_args()
 
966
        options.all = True
 
967
        with self.assertParseError():
 
968
            self.check_option_syntax(options)
 
969
 
 
970
    def test_all_is_ok_with_any_action(self):
 
971
        for action, value in self.actions.items():
 
972
            options = self.parser.parse_args()
 
973
            setattr(options, action, value)
 
974
            options.all = True
 
975
            self.check_option_syntax(options)
 
976
 
 
977
    def test_is_enabled_fails_without_client(self):
 
978
        options = self.parser.parse_args()
 
979
        options.is_enabled = True
 
980
        with self.assertParseError():
 
981
            self.check_option_syntax(options)
 
982
 
 
983
    def test_is_enabled_works_with_one_client(self):
 
984
        options = self.parser.parse_args()
 
985
        options.is_enabled = True
 
986
        options.client = ["foo"]
 
987
        self.check_option_syntax(options)
 
988
 
 
989
    def test_is_enabled_fails_with_two_clients(self):
 
990
        options = self.parser.parse_args()
 
991
        options.is_enabled = True
 
992
        options.client = ["foo", "barbar"]
 
993
        with self.assertParseError():
 
994
            self.check_option_syntax(options)
 
995
 
 
996
    def test_remove_can_only_be_combined_with_action_deny(self):
 
997
        for action, value in self.actions.items():
 
998
            if action in {"remove", "deny"}:
 
999
                continue
 
1000
            options = self.parser.parse_args()
 
1001
            setattr(options, action, value)
 
1002
            options.all = True
 
1003
            options.remove = True
 
1004
            with self.assertParseError():
 
1005
                self.check_option_syntax(options)
 
1006
 
 
1007
 
 
1008
class Test_get_mandos_dbus_object(unittest.TestCase):
 
1009
    def test_calls_and_returns_get_object_on_bus(self):
 
1010
        class MockBus(object):
 
1011
            called = False
 
1012
            def get_object(mockbus_self, busname, dbus_path):
 
1013
                # Note that "self" is still the testcase instance,
 
1014
                # this MockBus instance is in "mockbus_self".
 
1015
                self.assertEqual(busname, dbus_busname)
 
1016
                self.assertEqual(dbus_path, server_dbus_path)
 
1017
                mockbus_self.called = True
 
1018
                return mockbus_self
 
1019
 
 
1020
        mockbus = get_mandos_dbus_object(bus=MockBus())
 
1021
        self.assertIsInstance(mockbus, MockBus)
 
1022
        self.assertTrue(mockbus.called)
 
1023
 
 
1024
    def test_logs_and_exits_on_dbus_error(self):
 
1025
        class MockBusFailing(object):
 
1026
            def get_object(self, busname, dbus_path):
 
1027
                raise dbus.exceptions.DBusException("Test")
 
1028
 
 
1029
        # assertLogs only exists in Python 3.4
 
1030
        if hasattr(self, "assertLogs"):
 
1031
            with self.assertLogs(log, logging.CRITICAL):
 
1032
                with self.assertRaises(SystemExit) as e:
 
1033
                    bus = get_mandos_dbus_object(bus=MockBus())
 
1034
        else:
 
1035
            critical_filter = self.CriticalFilter()
 
1036
            log.addFilter(critical_filter)
 
1037
            try:
 
1038
                with self.assertRaises(SystemExit) as e:
 
1039
                    get_mandos_dbus_object(bus=MockBusFailing())
 
1040
            finally:
 
1041
                log.removeFilter(critical_filter)
 
1042
            self.assertTrue(critical_filter.found)
 
1043
        if isinstance(e.exception.code, int):
 
1044
            self.assertNotEqual(e.exception.code, 0)
 
1045
        else:
 
1046
            self.assertIsNotNone(e.exception.code)
 
1047
 
 
1048
    class CriticalFilter(logging.Filter):
 
1049
        """Don't show, but register, critical messages"""
 
1050
        found = False
 
1051
        def filter(self, record):
 
1052
            is_critical = record.levelno >= logging.CRITICAL
 
1053
            self.found = is_critical or self.found
 
1054
            return not is_critical
 
1055
 
 
1056
 
 
1057
class Test_get_managed_objects(unittest.TestCase):
 
1058
    def test_calls_and_returns_GetManagedObjects(self):
 
1059
        managed_objects = {"/clients/foo": { "Name": "foo"}}
 
1060
        class MockObjectManager(object):
 
1061
            @staticmethod
 
1062
            def GetManagedObjects():
 
1063
                return managed_objects
 
1064
        retval = get_managed_objects(MockObjectManager())
 
1065
        self.assertDictEqual(managed_objects, retval)
 
1066
 
 
1067
    def test_logs_and_exits_on_dbus_error(self):
 
1068
        class MockObjectManagerFailing(object):
 
1069
            @staticmethod
 
1070
            def GetManagedObjects():
 
1071
                raise dbus.exceptions.DBusException("Test")
 
1072
 
 
1073
        if hasattr(self, "assertLogs"):
 
1074
            with self.assertLogs(log, logging.CRITICAL):
 
1075
                with self.assertRaises(SystemExit):
 
1076
                    get_managed_objects(MockObjectManagerFailing())
 
1077
        else:
 
1078
            critical_filter = self.CriticalFilter()
 
1079
            log.addFilter(critical_filter)
 
1080
            try:
 
1081
                with self.assertRaises(SystemExit) as e:
 
1082
                    get_managed_objects(MockObjectManagerFailing())
 
1083
            finally:
 
1084
                log.removeFilter(critical_filter)
 
1085
            self.assertTrue(critical_filter.found)
 
1086
        if isinstance(e.exception.code, int):
 
1087
            self.assertNotEqual(e.exception.code, 0)
 
1088
        else:
 
1089
            self.assertIsNotNone(e.exception.code)
 
1090
 
 
1091
    class CriticalFilter(logging.Filter):
 
1092
        """Don't show, but register, critical messages"""
 
1093
        found = False
 
1094
        def filter(self, record):
 
1095
            is_critical = record.levelno >= logging.CRITICAL
 
1096
            self.found = is_critical or self.found
 
1097
            return not is_critical
 
1098
 
 
1099
 
 
1100
class Test_SilenceLogger(unittest.TestCase):
 
1101
    loggername = "mandos-ctl.Test_SilenceLogger"
 
1102
    log = logging.getLogger(loggername)
 
1103
    log.propagate = False
 
1104
    log.addHandler(logging.NullHandler())
 
1105
 
 
1106
    def setUp(self):
 
1107
        self.counting_filter = self.CountingFilter()
 
1108
 
 
1109
    class CountingFilter(logging.Filter):
 
1110
        "Count number of records"
 
1111
        count = 0
 
1112
        def filter(self, record):
 
1113
            self.count += 1
 
1114
            return True
 
1115
 
 
1116
    def test_should_filter_records_only_when_active(self):
 
1117
        try:
 
1118
            with SilenceLogger(self.loggername):
 
1119
                self.log.addFilter(self.counting_filter)
 
1120
                self.log.info("Filtered log message 1")
 
1121
            self.log.info("Non-filtered message 2")
 
1122
            self.log.info("Non-filtered message 3")
 
1123
        finally:
 
1124
            self.log.removeFilter(self.counting_filter)
 
1125
        self.assertEqual(self.counting_filter.count, 2)
 
1126
 
 
1127
 
 
1128
class Test_commands_from_options(unittest.TestCase):
 
1129
    def setUp(self):
 
1130
        self.parser = argparse.ArgumentParser()
 
1131
        add_command_line_options(self.parser)
 
1132
 
 
1133
    def test_is_enabled(self):
 
1134
        self.assert_command_from_args(["--is-enabled", "foo"],
 
1135
                                      IsEnabledCmd)
 
1136
 
 
1137
    def assert_command_from_args(self, args, command_cls,
 
1138
                                 **cmd_attrs):
 
1139
        """Assert that parsing ARGS should result in an instance of
 
1140
COMMAND_CLS with (optionally) all supplied attributes (CMD_ATTRS)."""
 
1141
        options = self.parser.parse_args(args)
 
1142
        check_option_syntax(self.parser, options)
 
1143
        commands = commands_from_options(options)
 
1144
        self.assertEqual(len(commands), 1)
 
1145
        command = commands[0]
 
1146
        self.assertIsInstance(command, command_cls)
 
1147
        for key, value in cmd_attrs.items():
 
1148
            self.assertEqual(getattr(command, key), value)
 
1149
 
 
1150
    def test_is_enabled_short(self):
 
1151
        self.assert_command_from_args(["-V", "foo"], IsEnabledCmd)
 
1152
 
 
1153
    def test_approve(self):
 
1154
        self.assert_command_from_args(["--approve", "foo"],
 
1155
                                      ApproveCmd)
 
1156
 
 
1157
    def test_approve_short(self):
 
1158
        self.assert_command_from_args(["-A", "foo"], ApproveCmd)
 
1159
 
 
1160
    def test_deny(self):
 
1161
        self.assert_command_from_args(["--deny", "foo"], DenyCmd)
 
1162
 
 
1163
    def test_deny_short(self):
 
1164
        self.assert_command_from_args(["-D", "foo"], DenyCmd)
 
1165
 
 
1166
    def test_remove(self):
 
1167
        self.assert_command_from_args(["--remove", "foo"],
 
1168
                                      RemoveCmd)
 
1169
 
 
1170
    def test_deny_before_remove(self):
 
1171
        options = self.parser.parse_args(["--deny", "--remove",
 
1172
                                          "foo"])
 
1173
        check_option_syntax(self.parser, options)
 
1174
        commands = commands_from_options(options)
 
1175
        self.assertEqual(len(commands), 2)
 
1176
        self.assertIsInstance(commands[0], DenyCmd)
 
1177
        self.assertIsInstance(commands[1], RemoveCmd)
 
1178
 
 
1179
    def test_deny_before_remove_reversed(self):
 
1180
        options = self.parser.parse_args(["--remove", "--deny",
 
1181
                                          "--all"])
 
1182
        check_option_syntax(self.parser, options)
 
1183
        commands = commands_from_options(options)
 
1184
        self.assertEqual(len(commands), 2)
 
1185
        self.assertIsInstance(commands[0], DenyCmd)
 
1186
        self.assertIsInstance(commands[1], RemoveCmd)
 
1187
 
 
1188
    def test_remove_short(self):
 
1189
        self.assert_command_from_args(["-r", "foo"], RemoveCmd)
 
1190
 
 
1191
    def test_dump_json(self):
 
1192
        self.assert_command_from_args(["--dump-json"], DumpJSONCmd)
 
1193
 
 
1194
    def test_enable(self):
 
1195
        self.assert_command_from_args(["--enable", "foo"], EnableCmd)
 
1196
 
 
1197
    def test_enable_short(self):
 
1198
        self.assert_command_from_args(["-e", "foo"], EnableCmd)
 
1199
 
 
1200
    def test_disable(self):
 
1201
        self.assert_command_from_args(["--disable", "foo"],
 
1202
                                      DisableCmd)
 
1203
 
 
1204
    def test_disable_short(self):
 
1205
        self.assert_command_from_args(["-d", "foo"], DisableCmd)
 
1206
 
 
1207
    def test_bump_timeout(self):
 
1208
        self.assert_command_from_args(["--bump-timeout", "foo"],
 
1209
                                      BumpTimeoutCmd)
 
1210
 
 
1211
    def test_bump_timeout_short(self):
 
1212
        self.assert_command_from_args(["-b", "foo"], BumpTimeoutCmd)
 
1213
 
 
1214
    def test_start_checker(self):
 
1215
        self.assert_command_from_args(["--start-checker", "foo"],
 
1216
                                      StartCheckerCmd)
 
1217
 
 
1218
    def test_stop_checker(self):
 
1219
        self.assert_command_from_args(["--stop-checker", "foo"],
 
1220
                                      StopCheckerCmd)
 
1221
 
 
1222
    def test_approve_by_default(self):
 
1223
        self.assert_command_from_args(["--approve-by-default", "foo"],
 
1224
                                      ApproveByDefaultCmd)
 
1225
 
 
1226
    def test_deny_by_default(self):
 
1227
        self.assert_command_from_args(["--deny-by-default", "foo"],
 
1228
                                      DenyByDefaultCmd)
 
1229
 
 
1230
    def test_checker(self):
 
1231
        self.assert_command_from_args(["--checker", ":", "foo"],
 
1232
                                      SetCheckerCmd, value_to_set=":")
 
1233
 
 
1234
    def test_checker_empty(self):
 
1235
        self.assert_command_from_args(["--checker", "", "foo"],
 
1236
                                      SetCheckerCmd, value_to_set="")
 
1237
 
 
1238
    def test_checker_short(self):
 
1239
        self.assert_command_from_args(["-c", ":", "foo"],
 
1240
                                      SetCheckerCmd, value_to_set=":")
 
1241
 
 
1242
    def test_host(self):
 
1243
        self.assert_command_from_args(["--host", "foo.example.org",
 
1244
                                       "foo"], SetHostCmd,
 
1245
                                      value_to_set="foo.example.org")
 
1246
 
 
1247
    def test_host_short(self):
 
1248
        self.assert_command_from_args(["-H", "foo.example.org",
 
1249
                                       "foo"], SetHostCmd,
 
1250
                                      value_to_set="foo.example.org")
 
1251
 
 
1252
    def test_secret_devnull(self):
 
1253
        self.assert_command_from_args(["--secret", os.path.devnull,
 
1254
                                       "foo"], SetSecretCmd,
 
1255
                                      value_to_set=b"")
 
1256
 
 
1257
    def test_secret_tempfile(self):
 
1258
        with tempfile.NamedTemporaryFile(mode="r+b") as f:
 
1259
            value = b"secret\0xyzzy\nbar"
 
1260
            f.write(value)
 
1261
            f.seek(0)
 
1262
            self.assert_command_from_args(["--secret", f.name,
 
1263
                                           "foo"], SetSecretCmd,
 
1264
                                          value_to_set=value)
 
1265
 
 
1266
    def test_secret_devnull_short(self):
 
1267
        self.assert_command_from_args(["-s", os.path.devnull, "foo"],
 
1268
                                      SetSecretCmd, value_to_set=b"")
 
1269
 
 
1270
    def test_secret_tempfile_short(self):
 
1271
        with tempfile.NamedTemporaryFile(mode="r+b") as f:
 
1272
            value = b"secret\0xyzzy\nbar"
 
1273
            f.write(value)
 
1274
            f.seek(0)
 
1275
            self.assert_command_from_args(["-s", f.name, "foo"],
 
1276
                                          SetSecretCmd,
 
1277
                                          value_to_set=value)
 
1278
 
 
1279
    def test_timeout(self):
 
1280
        self.assert_command_from_args(["--timeout", "PT5M", "foo"],
 
1281
                                      SetTimeoutCmd,
 
1282
                                      value_to_set=300000)
 
1283
 
 
1284
    def test_timeout_short(self):
 
1285
        self.assert_command_from_args(["-t", "PT5M", "foo"],
 
1286
                                      SetTimeoutCmd,
 
1287
                                      value_to_set=300000)
 
1288
 
 
1289
    def test_extended_timeout(self):
 
1290
        self.assert_command_from_args(["--extended-timeout", "PT15M",
 
1291
                                       "foo"],
 
1292
                                      SetExtendedTimeoutCmd,
 
1293
                                      value_to_set=900000)
 
1294
 
 
1295
    def test_interval(self):
 
1296
        self.assert_command_from_args(["--interval", "PT2M", "foo"],
 
1297
                                      SetIntervalCmd,
 
1298
                                      value_to_set=120000)
 
1299
 
 
1300
    def test_interval_short(self):
 
1301
        self.assert_command_from_args(["-i", "PT2M", "foo"],
 
1302
                                      SetIntervalCmd,
 
1303
                                      value_to_set=120000)
 
1304
 
 
1305
    def test_approval_delay(self):
 
1306
        self.assert_command_from_args(["--approval-delay", "PT30S",
 
1307
                                       "foo"], SetApprovalDelayCmd,
 
1308
                                      value_to_set=30000)
 
1309
 
 
1310
    def test_approval_duration(self):
 
1311
        self.assert_command_from_args(["--approval-duration", "PT1S",
 
1312
                                       "foo"], SetApprovalDurationCmd,
 
1313
                                      value_to_set=1000)
 
1314
 
 
1315
    def test_print_table(self):
 
1316
        self.assert_command_from_args([], PrintTableCmd,
 
1317
                                      verbose=False)
 
1318
 
 
1319
    def test_print_table_verbose(self):
 
1320
        self.assert_command_from_args(["--verbose"], PrintTableCmd,
 
1321
                                      verbose=True)
 
1322
 
 
1323
    def test_print_table_verbose_short(self):
 
1324
        self.assert_command_from_args(["-v"], PrintTableCmd,
 
1325
                                      verbose=True)
 
1326
 
 
1327
 
839
1328
class TestCmd(unittest.TestCase):
840
1329
    """Abstract class for tests of command classes"""
 
1330
 
841
1331
    def setUp(self):
842
1332
        testcase = self
843
1333
        class MockClient(object):
844
1334
            def __init__(self, name, **attributes):
845
 
                self.__dbus_object_path__ = "objpath_{}".format(name)
 
1335
                self.__dbus_object_path__ = "/clients/{}".format(name)
846
1336
                self.attributes = attributes
847
1337
                self.attributes["Name"] = name
848
1338
                self.calls = []
849
1339
            def Set(self, interface, propname, value, dbus_interface):
850
 
                testcase.assertEqual(interface, client_interface)
 
1340
                testcase.assertEqual(interface, client_dbus_interface)
851
1341
                testcase.assertEqual(dbus_interface,
852
1342
                                     dbus.PROPERTIES_IFACE)
853
1343
                self.attributes[propname] = value
854
1344
            def Get(self, interface, propname, dbus_interface):
855
 
                testcase.assertEqual(interface, client_interface)
 
1345
                testcase.assertEqual(interface, client_dbus_interface)
856
1346
                testcase.assertEqual(dbus_interface,
857
1347
                                     dbus.PROPERTIES_IFACE)
858
1348
                return self.attributes[propname]
859
1349
            def Approve(self, approve, dbus_interface):
860
 
                testcase.assertEqual(dbus_interface, client_interface)
 
1350
                testcase.assertEqual(dbus_interface,
 
1351
                                     client_dbus_interface)
861
1352
                self.calls.append(("Approve", (approve,
862
1353
                                               dbus_interface)))
863
1354
        self.client = MockClient(
903
1394
            ApprovedByDefault=dbus.Boolean(False),
904
1395
            LastApprovalRequest="2019-01-03T00:00:00",
905
1396
            ApprovalDelay=30000,
906
 
            ApprovalDuration=1000,
 
1397
            ApprovalDuration=93785000,
907
1398
            Checker=":",
908
1399
            ExtendedTimeout=900000,
909
1400
            Expires="2019-02-05T00:00:00",
910
1401
            LastCheckerStatus=-2)
911
1402
        self.clients =  collections.OrderedDict(
912
1403
            [
913
 
                (self.client, self.client.attributes),
914
 
                (self.other_client, self.other_client.attributes),
 
1404
                ("/clients/foo", self.client.attributes),
 
1405
                ("/clients/barbar", self.other_client.attributes),
915
1406
            ])
916
 
        self.one_client = {self.client: self.client.attributes}
917
 
 
918
 
class TestPrintTableCmd(TestCmd):
919
 
    def test_normal(self):
920
 
        output = PrintTableCmd().output(self.clients.values())
921
 
        expected_output = """
922
 
Name   Enabled Timeout  Last Successful Check
923
 
foo    Yes     00:05:00 2019-02-03T00:00:00  
924
 
barbar Yes     00:05:00 2019-02-04T00:00:00  
925
 
"""[1:-1]
926
 
        self.assertEqual(output, expected_output)
927
 
    def test_verbose(self):
928
 
        output = PrintTableCmd(verbose=True).output(
929
 
            self.clients.values())
930
 
        expected_output = """
931
 
Name   Enabled Timeout  Last Successful Check Created             Interval Host            Key ID                                                           Fingerprint                              Check Is Running Last Enabled        Approval Is Pending Approved By Default Last Approval Request Approval Delay Approval Duration Checker              Extended Timeout Expires             Last Checker Status
932
 
foo    Yes     00:05:00 2019-02-03T00:00:00   2019-01-02T00:00:00 00:02:00 foo.example.org 92ed150794387c03ce684574b1139a6594a34f895daaaf09fd8ea90a27cddb12 778827225BA7DE539C5A7CFA59CFF7CDBD9A5920 No               2019-01-03T00:00:00 No                  Yes                                       00:00:00       00:00:01          fping -q -- %(host)s 00:15:00         2019-02-04T00:00:00 0                  
933
 
barbar Yes     00:05:00 2019-02-04T00:00:00   2019-01-03T00:00:00 00:02:00 192.0.2.3       0558568eedd67d622f5c83b35a115f796ab612cff5ad227247e46c2b020f441c 3E393AEAEFB84C7E89E2F547B3A107558FCA3A27 Yes              2019-01-04T00:00:00 No                  No                  2019-01-03T00:00:00   00:00:30       00:00:01          :                    00:15:00         2019-02-05T00:00:00 -2                 
934
 
"""[1:-1]
935
 
        self.assertEqual(output, expected_output)
936
 
    def test_one_client(self):
937
 
        output = PrintTableCmd().output(self.one_client.values())
938
 
        expected_output = """
939
 
Name Enabled Timeout  Last Successful Check
940
 
foo  Yes     00:05:00 2019-02-03T00:00:00  
941
 
"""[1:-1]
942
 
        self.assertEqual(output, expected_output)
 
1407
        self.one_client = {"/clients/foo": self.client.attributes}
 
1408
 
 
1409
    @property
 
1410
    def bus(self):
 
1411
        class Bus(object):
 
1412
            @staticmethod
 
1413
            def get_object(client_bus_name, path):
 
1414
                self.assertEqual(client_bus_name, dbus_busname)
 
1415
                return {
 
1416
                    # Note: "self" here is the TestCmd instance, not
 
1417
                    # the Bus instance, since this is a static method!
 
1418
                    "/clients/foo": self.client,
 
1419
                    "/clients/barbar": self.other_client,
 
1420
                }[path]
 
1421
        return Bus()
 
1422
 
 
1423
 
 
1424
class TestIsEnabledCmd(TestCmd):
 
1425
    def test_is_enabled(self):
 
1426
        self.assertTrue(all(IsEnabledCmd().is_enabled(client,
 
1427
                                                      properties)
 
1428
                            for client, properties
 
1429
                            in self.clients.items()))
 
1430
 
 
1431
    def test_is_enabled_run_exits_successfully(self):
 
1432
        with self.assertRaises(SystemExit) as e:
 
1433
            IsEnabledCmd().run(self.one_client)
 
1434
        if e.exception.code is not None:
 
1435
            self.assertEqual(e.exception.code, 0)
 
1436
        else:
 
1437
            self.assertIsNone(e.exception.code)
 
1438
 
 
1439
    def test_is_enabled_run_exits_with_failure(self):
 
1440
        self.client.attributes["Enabled"] = dbus.Boolean(False)
 
1441
        with self.assertRaises(SystemExit) as e:
 
1442
            IsEnabledCmd().run(self.one_client)
 
1443
        if isinstance(e.exception.code, int):
 
1444
            self.assertNotEqual(e.exception.code, 0)
 
1445
        else:
 
1446
            self.assertIsNotNone(e.exception.code)
 
1447
 
 
1448
 
 
1449
class TestApproveCmd(TestCmd):
 
1450
    def test_approve(self):
 
1451
        ApproveCmd().run(self.clients, self.bus)
 
1452
        for clientpath in self.clients:
 
1453
            client = self.bus.get_object(dbus_busname, clientpath)
 
1454
            self.assertIn(("Approve", (True, client_dbus_interface)),
 
1455
                          client.calls)
 
1456
 
 
1457
 
 
1458
class TestDenyCmd(TestCmd):
 
1459
    def test_deny(self):
 
1460
        DenyCmd().run(self.clients, self.bus)
 
1461
        for clientpath in self.clients:
 
1462
            client = self.bus.get_object(dbus_busname, clientpath)
 
1463
            self.assertIn(("Approve", (False, client_dbus_interface)),
 
1464
                          client.calls)
 
1465
 
 
1466
 
 
1467
class TestRemoveCmd(TestCmd):
 
1468
    def test_remove(self):
 
1469
        class MockMandos(object):
 
1470
            def __init__(self):
 
1471
                self.calls = []
 
1472
            def RemoveClient(self, dbus_path):
 
1473
                self.calls.append(("RemoveClient", (dbus_path,)))
 
1474
        mandos = MockMandos()
 
1475
        super(TestRemoveCmd, self).setUp()
 
1476
        RemoveCmd().run(self.clients, self.bus, mandos)
 
1477
        self.assertEqual(len(mandos.calls), 2)
 
1478
        for clientpath in self.clients:
 
1479
            self.assertIn(("RemoveClient", (clientpath,)),
 
1480
                          mandos.calls)
 
1481
 
943
1482
 
944
1483
class TestDumpJSONCmd(TestCmd):
945
1484
    def setUp(self):
986
1525
                "ApprovedByDefault": False,
987
1526
                "LastApprovalRequest": "2019-01-03T00:00:00",
988
1527
                "ApprovalDelay": 30000,
989
 
                "ApprovalDuration": 1000,
 
1528
                "ApprovalDuration": 93785000,
990
1529
                "Checker": ":",
991
1530
                "ExtendedTimeout": 900000,
992
1531
                "Expires": "2019-02-05T00:00:00",
994
1533
            },
995
1534
        }
996
1535
        return super(TestDumpJSONCmd, self).setUp()
 
1536
 
997
1537
    def test_normal(self):
998
 
        json_data = json.loads(DumpJSONCmd().output(self.clients))
 
1538
        output = DumpJSONCmd().output(self.clients.values())
 
1539
        json_data = json.loads(output)
999
1540
        self.assertDictEqual(json_data, self.expected_json)
 
1541
 
1000
1542
    def test_one_client(self):
1001
 
        clients = self.one_client
1002
 
        json_data = json.loads(DumpJSONCmd().output(clients))
 
1543
        output = DumpJSONCmd().output(self.one_client.values())
 
1544
        json_data = json.loads(output)
1003
1545
        expected_json = {"foo": self.expected_json["foo"]}
1004
1546
        self.assertDictEqual(json_data, expected_json)
1005
1547
 
1006
 
class TestIsEnabledCmd(TestCmd):
1007
 
    def test_is_enabled(self):
1008
 
        self.assertTrue(all(IsEnabledCmd().is_enabled(client, properties)
1009
 
                            for client, properties in self.clients.items()))
1010
 
    def test_is_enabled_run_exits_successfully(self):
1011
 
        with self.assertRaises(SystemExit) as e:
1012
 
            IsEnabledCmd().run(None, self.one_client)
1013
 
        if e.exception.code is not None:
1014
 
            self.assertEqual(e.exception.code, 0)
1015
 
        else:
1016
 
            self.assertIsNone(e.exception.code)
1017
 
    def test_is_enabled_run_exits_with_failure(self):
1018
 
        self.client.attributes["Enabled"] = dbus.Boolean(False)
1019
 
        with self.assertRaises(SystemExit) as e:
1020
 
            IsEnabledCmd().run(None, self.one_client)
1021
 
        if isinstance(e.exception.code, int):
1022
 
            self.assertNotEqual(e.exception.code, 0)
1023
 
        else:
1024
 
            self.assertIsNotNone(e.exception.code)
1025
 
 
1026
 
class TestRemoveCmd(TestCmd):
1027
 
    def test_remove(self):
1028
 
        class MockMandos(object):
1029
 
            def __init__(self):
1030
 
                self.calls = []
1031
 
            def RemoveClient(self, dbus_path):
1032
 
                self.calls.append(("RemoveClient", (dbus_path,)))
1033
 
        mandos = MockMandos()
1034
 
        super(TestRemoveCmd, self).setUp()
1035
 
        RemoveCmd().run(mandos, self.clients)
1036
 
        self.assertEqual(len(mandos.calls), 2)
1037
 
        for client in self.clients:
1038
 
            self.assertIn(("RemoveClient",
1039
 
                           (client.__dbus_object_path__,)),
1040
 
                          mandos.calls)
1041
 
 
1042
 
class TestApproveCmd(TestCmd):
1043
 
    def test_approve(self):
1044
 
        ApproveCmd().run(None, self.clients)
1045
 
        for client in self.clients:
1046
 
            self.assertIn(("Approve", (True, client_interface)),
1047
 
                          client.calls)
1048
 
 
1049
 
class TestDenyCmd(TestCmd):
1050
 
    def test_deny(self):
1051
 
        DenyCmd().run(None, self.clients)
1052
 
        for client in self.clients:
1053
 
            self.assertIn(("Approve", (False, client_interface)),
1054
 
                          client.calls)
1055
 
 
1056
 
class TestEnableCmd(TestCmd):
1057
 
    def test_enable(self):
1058
 
        for client in self.clients:
1059
 
            client.attributes["Enabled"] = False
1060
 
 
1061
 
        EnableCmd().run(None, self.clients)
1062
 
 
1063
 
        for client in self.clients:
1064
 
            self.assertTrue(client.attributes["Enabled"])
1065
 
 
1066
 
class TestDisableCmd(TestCmd):
1067
 
    def test_disable(self):
1068
 
        DisableCmd().run(None, self.clients)
1069
 
 
1070
 
        for client in self.clients:
1071
 
            self.assertFalse(client.attributes["Enabled"])
1072
 
 
1073
 
class Unique(object):
1074
 
    """Class for objects which exist only to be unique objects, since
1075
 
unittest.mock.sentinel only exists in Python 3.3"""
 
1548
 
 
1549
class TestPrintTableCmd(TestCmd):
 
1550
    def test_normal(self):
 
1551
        output = PrintTableCmd().output(self.clients.values())
 
1552
        expected_output = "\n".join((
 
1553
            "Name   Enabled Timeout  Last Successful Check",
 
1554
            "foo    Yes     00:05:00 2019-02-03T00:00:00  ",
 
1555
            "barbar Yes     00:05:00 2019-02-04T00:00:00  ",
 
1556
        ))
 
1557
        self.assertEqual(output, expected_output)
 
1558
 
 
1559
    def test_verbose(self):
 
1560
        output = PrintTableCmd(verbose=True).output(
 
1561
            self.clients.values())
 
1562
        columns = (
 
1563
            (
 
1564
                "Name   ",
 
1565
                "foo    ",
 
1566
                "barbar ",
 
1567
            ),(
 
1568
                "Enabled ",
 
1569
                "Yes     ",
 
1570
                "Yes     ",
 
1571
            ),(
 
1572
                "Timeout  ",
 
1573
                "00:05:00 ",
 
1574
                "00:05:00 ",
 
1575
            ),(
 
1576
                "Last Successful Check ",
 
1577
                "2019-02-03T00:00:00   ",
 
1578
                "2019-02-04T00:00:00   ",
 
1579
            ),(
 
1580
                "Created             ",
 
1581
                "2019-01-02T00:00:00 ",
 
1582
                "2019-01-03T00:00:00 ",
 
1583
            ),(
 
1584
                "Interval ",
 
1585
                "00:02:00 ",
 
1586
                "00:02:00 ",
 
1587
            ),(
 
1588
                "Host            ",
 
1589
                "foo.example.org ",
 
1590
                "192.0.2.3       ",
 
1591
            ),(
 
1592
                ("Key ID                                             "
 
1593
                 "              "),
 
1594
                ("92ed150794387c03ce684574b1139a6594a34f895daaaf09fd8"
 
1595
                 "ea90a27cddb12 "),
 
1596
                ("0558568eedd67d622f5c83b35a115f796ab612cff5ad227247e"
 
1597
                 "46c2b020f441c "),
 
1598
            ),(
 
1599
                "Fingerprint                              ",
 
1600
                "778827225BA7DE539C5A7CFA59CFF7CDBD9A5920 ",
 
1601
                "3E393AEAEFB84C7E89E2F547B3A107558FCA3A27 ",
 
1602
            ),(
 
1603
                "Check Is Running ",
 
1604
                "No               ",
 
1605
                "Yes              ",
 
1606
            ),(
 
1607
                "Last Enabled        ",
 
1608
                "2019-01-03T00:00:00 ",
 
1609
                "2019-01-04T00:00:00 ",
 
1610
            ),(
 
1611
                "Approval Is Pending ",
 
1612
                "No                  ",
 
1613
                "No                  ",
 
1614
            ),(
 
1615
                "Approved By Default ",
 
1616
                "Yes                 ",
 
1617
                "No                  ",
 
1618
            ),(
 
1619
                "Last Approval Request ",
 
1620
                "                      ",
 
1621
                "2019-01-03T00:00:00   ",
 
1622
            ),(
 
1623
                "Approval Delay ",
 
1624
                "00:00:00       ",
 
1625
                "00:00:30       ",
 
1626
            ),(
 
1627
                "Approval Duration ",
 
1628
                "00:00:01          ",
 
1629
                "1T02:03:05        ",
 
1630
            ),(
 
1631
                "Checker              ",
 
1632
                "fping -q -- %(host)s ",
 
1633
                ":                    ",
 
1634
            ),(
 
1635
                "Extended Timeout ",
 
1636
                "00:15:00         ",
 
1637
                "00:15:00         ",
 
1638
            ),(
 
1639
                "Expires             ",
 
1640
                "2019-02-04T00:00:00 ",
 
1641
                "2019-02-05T00:00:00 ",
 
1642
            ),(
 
1643
                "Last Checker Status",
 
1644
                "0                  ",
 
1645
                "-2                 ",
 
1646
            )
 
1647
        )
 
1648
        num_lines = max(len(rows) for rows in columns)
 
1649
        expected_output = "\n".join("".join(rows[line]
 
1650
                                            for rows in columns)
 
1651
                                    for line in range(num_lines))
 
1652
        self.assertEqual(output, expected_output)
 
1653
 
 
1654
    def test_one_client(self):
 
1655
        output = PrintTableCmd().output(self.one_client.values())
 
1656
        expected_output = "\n".join((
 
1657
            "Name Enabled Timeout  Last Successful Check",
 
1658
            "foo  Yes     00:05:00 2019-02-03T00:00:00  ",
 
1659
        ))
 
1660
        self.assertEqual(output, expected_output)
 
1661
 
1076
1662
 
1077
1663
class TestPropertyCmd(TestCmd):
1078
1664
    """Abstract class for tests of PropertyCmd classes"""
1083
1669
                                self.values_to_set)
1084
1670
        for value_to_set, value_to_get in zip(self.values_to_set,
1085
1671
                                              values_to_get):
1086
 
            for client in self.clients:
 
1672
            for clientpath in self.clients:
 
1673
                client = self.bus.get_object(dbus_busname, clientpath)
1087
1674
                old_value = client.attributes[self.propname]
1088
 
                self.assertNotIsInstance(old_value, Unique)
1089
 
                client.attributes[self.propname] = Unique()
 
1675
                self.assertNotIsInstance(old_value, self.Unique)
 
1676
                client.attributes[self.propname] = self.Unique()
1090
1677
            self.run_command(value_to_set, self.clients)
1091
 
            for client in self.clients:
 
1678
            for clientpath in self.clients:
 
1679
                client = self.bus.get_object(dbus_busname, clientpath)
1092
1680
                value = client.attributes[self.propname]
1093
 
                self.assertNotIsInstance(value, Unique)
 
1681
                self.assertNotIsInstance(value, self.Unique)
1094
1682
                self.assertEqual(value, value_to_get)
 
1683
 
 
1684
    class Unique(object):
 
1685
        """Class for objects which exist only to be unique objects,
 
1686
since unittest.mock.sentinel only exists in Python 3.3"""
 
1687
 
1095
1688
    def run_command(self, value, clients):
1096
 
        self.command().run(None, clients)
 
1689
        self.command().run(clients, self.bus)
 
1690
 
 
1691
 
 
1692
class TestEnableCmd(TestPropertyCmd):
 
1693
    command = EnableCmd
 
1694
    propname = "Enabled"
 
1695
    values_to_set = [dbus.Boolean(True)]
 
1696
 
 
1697
 
 
1698
class TestDisableCmd(TestPropertyCmd):
 
1699
    command = DisableCmd
 
1700
    propname = "Enabled"
 
1701
    values_to_set = [dbus.Boolean(False)]
 
1702
 
1097
1703
 
1098
1704
class TestBumpTimeoutCmd(TestPropertyCmd):
1099
1705
    command = BumpTimeoutCmd
1100
1706
    propname = "LastCheckedOK"
1101
1707
    values_to_set = [""]
1102
1708
 
 
1709
 
1103
1710
class TestStartCheckerCmd(TestPropertyCmd):
1104
1711
    command = StartCheckerCmd
1105
1712
    propname = "CheckerRunning"
1106
1713
    values_to_set = [dbus.Boolean(True)]
1107
1714
 
 
1715
 
1108
1716
class TestStopCheckerCmd(TestPropertyCmd):
1109
1717
    command = StopCheckerCmd
1110
1718
    propname = "CheckerRunning"
1111
1719
    values_to_set = [dbus.Boolean(False)]
1112
1720
 
 
1721
 
1113
1722
class TestApproveByDefaultCmd(TestPropertyCmd):
1114
1723
    command = ApproveByDefaultCmd
1115
1724
    propname = "ApprovedByDefault"
1116
1725
    values_to_set = [dbus.Boolean(True)]
1117
1726
 
 
1727
 
1118
1728
class TestDenyByDefaultCmd(TestPropertyCmd):
1119
1729
    command = DenyByDefaultCmd
1120
1730
    propname = "ApprovedByDefault"
1121
1731
    values_to_set = [dbus.Boolean(False)]
1122
1732
 
1123
 
class TestValueArgumentPropertyCmd(TestPropertyCmd):
1124
 
    """Abstract class for tests of PropertyCmd classes using the
1125
 
ValueArgumentMixIn"""
 
1733
 
 
1734
class TestPropertyValueCmd(TestPropertyCmd):
 
1735
    """Abstract class for tests of PropertyValueCmd classes"""
 
1736
 
1126
1737
    def runTest(self):
1127
 
        if type(self) is TestValueArgumentPropertyCmd:
 
1738
        if type(self) is TestPropertyValueCmd:
1128
1739
            return
1129
 
        return super(TestValueArgumentPropertyCmd, self).runTest()
 
1740
        return super(TestPropertyValueCmd, self).runTest()
 
1741
 
1130
1742
    def run_command(self, value, clients):
1131
 
        self.command(value).run(None, clients)
1132
 
 
1133
 
class TestSetCheckerCmd(TestValueArgumentPropertyCmd):
 
1743
        self.command(value).run(clients, self.bus)
 
1744
 
 
1745
 
 
1746
class TestSetCheckerCmd(TestPropertyValueCmd):
1134
1747
    command = SetCheckerCmd
1135
1748
    propname = "Checker"
1136
1749
    values_to_set = ["", ":", "fping -q -- %s"]
1137
1750
 
1138
 
class TestSetHostCmd(TestValueArgumentPropertyCmd):
 
1751
 
 
1752
class TestSetHostCmd(TestPropertyValueCmd):
1139
1753
    command = SetHostCmd
1140
1754
    propname = "Host"
1141
1755
    values_to_set = ["192.0.2.3", "foo.example.org"]
1142
1756
 
1143
 
class TestSetSecretCmd(TestValueArgumentPropertyCmd):
 
1757
 
 
1758
class TestSetSecretCmd(TestPropertyValueCmd):
1144
1759
    command = SetSecretCmd
1145
1760
    propname = "Secret"
1146
1761
    values_to_set = [io.BytesIO(b""),
1147
1762
                     io.BytesIO(b"secret\0xyzzy\nbar")]
1148
1763
    values_to_get = [b"", b"secret\0xyzzy\nbar"]
1149
1764
 
1150
 
class TestSetTimeoutCmd(TestValueArgumentPropertyCmd):
 
1765
 
 
1766
class TestSetTimeoutCmd(TestPropertyValueCmd):
1151
1767
    command = SetTimeoutCmd
1152
1768
    propname = "Timeout"
1153
1769
    values_to_set = [datetime.timedelta(),
1157
1773
                     datetime.timedelta(weeks=52)]
1158
1774
    values_to_get = [0, 300000, 1000, 604800000, 31449600000]
1159
1775
 
1160
 
class TestSetExtendedTimeoutCmd(TestValueArgumentPropertyCmd):
 
1776
 
 
1777
class TestSetExtendedTimeoutCmd(TestPropertyValueCmd):
1161
1778
    command = SetExtendedTimeoutCmd
1162
1779
    propname = "ExtendedTimeout"
1163
1780
    values_to_set = [datetime.timedelta(),
1167
1784
                     datetime.timedelta(weeks=52)]
1168
1785
    values_to_get = [0, 300000, 1000, 604800000, 31449600000]
1169
1786
 
1170
 
class TestSetIntervalCmd(TestValueArgumentPropertyCmd):
 
1787
 
 
1788
class TestSetIntervalCmd(TestPropertyValueCmd):
1171
1789
    command = SetIntervalCmd
1172
1790
    propname = "Interval"
1173
1791
    values_to_set = [datetime.timedelta(),
1177
1795
                     datetime.timedelta(weeks=52)]
1178
1796
    values_to_get = [0, 300000, 1000, 604800000, 31449600000]
1179
1797
 
1180
 
class TestSetApprovalDelayCmd(TestValueArgumentPropertyCmd):
 
1798
 
 
1799
class TestSetApprovalDelayCmd(TestPropertyValueCmd):
1181
1800
    command = SetApprovalDelayCmd
1182
1801
    propname = "ApprovalDelay"
1183
1802
    values_to_set = [datetime.timedelta(),
1187
1806
                     datetime.timedelta(weeks=52)]
1188
1807
    values_to_get = [0, 300000, 1000, 604800000, 31449600000]
1189
1808
 
1190
 
class TestSetApprovalDurationCmd(TestValueArgumentPropertyCmd):
 
1809
 
 
1810
class TestSetApprovalDurationCmd(TestPropertyValueCmd):
1191
1811
    command = SetApprovalDurationCmd
1192
1812
    propname = "ApprovalDuration"
1193
1813
    values_to_set = [datetime.timedelta(),
1197
1817
                     datetime.timedelta(weeks=52)]
1198
1818
    values_to_get = [0, 300000, 1000, 604800000, 31449600000]
1199
1819
 
1200
 
class Test_command_from_options(unittest.TestCase):
1201
 
    def setUp(self):
1202
 
        self.parser = argparse.ArgumentParser()
1203
 
        add_command_line_options(self.parser)
1204
 
    def assert_command_from_args(self, args, command_cls, **cmd_attrs):
1205
 
        """Assert that parsing ARGS should result in an instance of
1206
 
COMMAND_CLS with (optionally) all supplied attributes (CMD_ATTRS)."""
1207
 
        options = self.parser.parse_args(args)
1208
 
        check_option_syntax(self.parser, options)
1209
 
        commands = commands_from_options(options)
1210
 
        self.assertEqual(len(commands), 1)
1211
 
        command = commands[0]
1212
 
        self.assertIsInstance(command, command_cls)
1213
 
        for key, value in cmd_attrs.items():
1214
 
            self.assertEqual(getattr(command, key), value)
1215
 
    def test_print_table(self):
1216
 
        self.assert_command_from_args([], PrintTableCmd,
1217
 
                                      verbose=False)
1218
 
 
1219
 
    def test_print_table_verbose(self):
1220
 
        self.assert_command_from_args(["--verbose"], PrintTableCmd,
1221
 
                                      verbose=True)
1222
 
 
1223
 
    def test_print_table_verbose_short(self):
1224
 
        self.assert_command_from_args(["-v"], PrintTableCmd,
1225
 
                                      verbose=True)
1226
 
 
1227
 
    def test_enable(self):
1228
 
        self.assert_command_from_args(["--enable", "foo"], EnableCmd)
1229
 
 
1230
 
    def test_enable_short(self):
1231
 
        self.assert_command_from_args(["-e", "foo"], EnableCmd)
1232
 
 
1233
 
    def test_disable(self):
1234
 
        self.assert_command_from_args(["--disable", "foo"],
1235
 
                                      DisableCmd)
1236
 
 
1237
 
    def test_disable_short(self):
1238
 
        self.assert_command_from_args(["-d", "foo"], DisableCmd)
1239
 
 
1240
 
    def test_bump_timeout(self):
1241
 
        self.assert_command_from_args(["--bump-timeout", "foo"],
1242
 
                                      BumpTimeoutCmd)
1243
 
 
1244
 
    def test_bump_timeout_short(self):
1245
 
        self.assert_command_from_args(["-b", "foo"], BumpTimeoutCmd)
1246
 
 
1247
 
    def test_start_checker(self):
1248
 
        self.assert_command_from_args(["--start-checker", "foo"],
1249
 
                                      StartCheckerCmd)
1250
 
 
1251
 
    def test_stop_checker(self):
1252
 
        self.assert_command_from_args(["--stop-checker", "foo"],
1253
 
                                      StopCheckerCmd)
1254
 
 
1255
 
    def test_remove(self):
1256
 
        self.assert_command_from_args(["--remove", "foo"],
1257
 
                                      RemoveCmd)
1258
 
 
1259
 
    def test_remove_short(self):
1260
 
        self.assert_command_from_args(["-r", "foo"], RemoveCmd)
1261
 
 
1262
 
    def test_checker(self):
1263
 
        self.assert_command_from_args(["--checker", ":", "foo"],
1264
 
                                      SetCheckerCmd, value_to_set=":")
1265
 
 
1266
 
    def test_checker_empty(self):
1267
 
        self.assert_command_from_args(["--checker", "", "foo"],
1268
 
                                      SetCheckerCmd, value_to_set="")
1269
 
 
1270
 
    def test_checker_short(self):
1271
 
        self.assert_command_from_args(["-c", ":", "foo"],
1272
 
                                      SetCheckerCmd, value_to_set=":")
1273
 
 
1274
 
    def test_timeout(self):
1275
 
        self.assert_command_from_args(["--timeout", "PT5M", "foo"],
1276
 
                                      SetTimeoutCmd,
1277
 
                                      value_to_set=300000)
1278
 
 
1279
 
    def test_timeout_short(self):
1280
 
        self.assert_command_from_args(["-t", "PT5M", "foo"],
1281
 
                                      SetTimeoutCmd,
1282
 
                                      value_to_set=300000)
1283
 
 
1284
 
    def test_extended_timeout(self):
1285
 
        self.assert_command_from_args(["--extended-timeout", "PT15M",
1286
 
                                       "foo"],
1287
 
                                      SetExtendedTimeoutCmd,
1288
 
                                      value_to_set=900000)
1289
 
 
1290
 
    def test_interval(self):
1291
 
        self.assert_command_from_args(["--interval", "PT2M", "foo"],
1292
 
                                      SetIntervalCmd,
1293
 
                                      value_to_set=120000)
1294
 
 
1295
 
    def test_interval_short(self):
1296
 
        self.assert_command_from_args(["-i", "PT2M", "foo"],
1297
 
                                      SetIntervalCmd,
1298
 
                                      value_to_set=120000)
1299
 
 
1300
 
    def test_approve_by_default(self):
1301
 
        self.assert_command_from_args(["--approve-by-default", "foo"],
1302
 
                                      ApproveByDefaultCmd)
1303
 
 
1304
 
    def test_deny_by_default(self):
1305
 
        self.assert_command_from_args(["--deny-by-default", "foo"],
1306
 
                                      DenyByDefaultCmd)
1307
 
 
1308
 
    def test_approval_delay(self):
1309
 
        self.assert_command_from_args(["--approval-delay", "PT30S",
1310
 
                                       "foo"], SetApprovalDelayCmd,
1311
 
                                      value_to_set=30000)
1312
 
 
1313
 
    def test_approval_duration(self):
1314
 
        self.assert_command_from_args(["--approval-duration", "PT1S",
1315
 
                                       "foo"], SetApprovalDurationCmd,
1316
 
                                      value_to_set=1000)
1317
 
 
1318
 
    def test_host(self):
1319
 
        self.assert_command_from_args(["--host", "foo.example.org",
1320
 
                                       "foo"], SetHostCmd,
1321
 
                                      value_to_set="foo.example.org")
1322
 
 
1323
 
    def test_host_short(self):
1324
 
        self.assert_command_from_args(["-H", "foo.example.org",
1325
 
                                       "foo"], SetHostCmd,
1326
 
                                      value_to_set="foo.example.org")
1327
 
 
1328
 
    def test_secret_devnull(self):
1329
 
        self.assert_command_from_args(["--secret", os.path.devnull,
1330
 
                                       "foo"], SetSecretCmd,
1331
 
                                      value_to_set=b"")
1332
 
 
1333
 
    def test_secret_tempfile(self):
1334
 
        with tempfile.NamedTemporaryFile(mode="r+b") as f:
1335
 
            value = b"secret\0xyzzy\nbar"
1336
 
            f.write(value)
1337
 
            f.seek(0)
1338
 
            self.assert_command_from_args(["--secret", f.name,
1339
 
                                           "foo"], SetSecretCmd,
1340
 
                                          value_to_set=value)
1341
 
 
1342
 
    def test_secret_devnull_short(self):
1343
 
        self.assert_command_from_args(["-s", os.path.devnull, "foo"],
1344
 
                                      SetSecretCmd, value_to_set=b"")
1345
 
 
1346
 
    def test_secret_tempfile_short(self):
1347
 
        with tempfile.NamedTemporaryFile(mode="r+b") as f:
1348
 
            value = b"secret\0xyzzy\nbar"
1349
 
            f.write(value)
1350
 
            f.seek(0)
1351
 
            self.assert_command_from_args(["-s", f.name, "foo"],
1352
 
                                          SetSecretCmd,
1353
 
                                          value_to_set=value)
1354
 
 
1355
 
    def test_approve(self):
1356
 
        self.assert_command_from_args(["--approve", "foo"],
1357
 
                                      ApproveCmd)
1358
 
 
1359
 
    def test_approve_short(self):
1360
 
        self.assert_command_from_args(["-A", "foo"], ApproveCmd)
1361
 
 
1362
 
    def test_deny(self):
1363
 
        self.assert_command_from_args(["--deny", "foo"], DenyCmd)
1364
 
 
1365
 
    def test_deny_short(self):
1366
 
        self.assert_command_from_args(["-D", "foo"], DenyCmd)
1367
 
 
1368
 
    def test_dump_json(self):
1369
 
        self.assert_command_from_args(["--dump-json"], DumpJSONCmd)
1370
 
 
1371
 
    def test_is_enabled(self):
1372
 
        self.assert_command_from_args(["--is-enabled", "foo"],
1373
 
                                      IsEnabledCmd)
1374
 
 
1375
 
    def test_is_enabled_short(self):
1376
 
        self.assert_command_from_args(["-V", "foo"], IsEnabledCmd)
1377
 
 
1378
 
    def test_deny_before_remove(self):
1379
 
        options = self.parser.parse_args(["--deny", "--remove", "foo"])
1380
 
        check_option_syntax(self.parser, options)
1381
 
        commands = commands_from_options(options)
1382
 
        self.assertEqual(len(commands), 2)
1383
 
        self.assertIsInstance(commands[0], DenyCmd)
1384
 
        self.assertIsInstance(commands[1], RemoveCmd)
1385
 
 
1386
 
    def test_deny_before_remove_reversed(self):
1387
 
        options = self.parser.parse_args(["--remove", "--deny", "--all"])
1388
 
        check_option_syntax(self.parser, options)
1389
 
        commands = commands_from_options(options)
1390
 
        self.assertEqual(len(commands), 2)
1391
 
        self.assertIsInstance(commands[0], DenyCmd)
1392
 
        self.assertIsInstance(commands[1], RemoveCmd)
1393
 
 
1394
 
 
1395
 
class Test_check_option_syntax(unittest.TestCase):
1396
 
    # This mostly corresponds to the definition from has_actions() in
1397
 
    # check_option_syntax()
1398
 
    actions = {
1399
 
        # The actual values set here are not that important, but we do
1400
 
        # at least stick to the correct types, even though they are
1401
 
        # never used
1402
 
        "enable": True,
1403
 
        "disable": True,
1404
 
        "bump_timeout": True,
1405
 
        "start_checker": True,
1406
 
        "stop_checker": True,
1407
 
        "is_enabled": True,
1408
 
        "remove": True,
1409
 
        "checker": "x",
1410
 
        "timeout": datetime.timedelta(),
1411
 
        "extended_timeout": datetime.timedelta(),
1412
 
        "interval": datetime.timedelta(),
1413
 
        "approved_by_default": True,
1414
 
        "approval_delay": datetime.timedelta(),
1415
 
        "approval_duration": datetime.timedelta(),
1416
 
        "host": "x",
1417
 
        "secret": io.BytesIO(b"x"),
1418
 
        "approve": True,
1419
 
        "deny": True,
1420
 
    }
1421
 
 
1422
 
    def setUp(self):
1423
 
        self.parser = argparse.ArgumentParser()
1424
 
        add_command_line_options(self.parser)
1425
 
 
1426
 
    @contextlib.contextmanager
1427
 
    def assertParseError(self):
1428
 
        with self.assertRaises(SystemExit) as e:
1429
 
            with self.temporarily_suppress_stderr():
1430
 
                yield
1431
 
        # Exit code from argparse is guaranteed to be "2".  Reference:
1432
 
        # https://docs.python.org/3/library/argparse.html#exiting-methods
1433
 
        self.assertEqual(e.exception.code, 2)
1434
 
 
1435
 
    @staticmethod
1436
 
    @contextlib.contextmanager
1437
 
    def temporarily_suppress_stderr():
1438
 
        null = os.open(os.path.devnull, os.O_RDWR)
1439
 
        stderrcopy = os.dup(sys.stderr.fileno())
1440
 
        os.dup2(null, sys.stderr.fileno())
1441
 
        os.close(null)
1442
 
        try:
1443
 
            yield
1444
 
        finally:
1445
 
            # restore stderr
1446
 
            os.dup2(stderrcopy, sys.stderr.fileno())
1447
 
            os.close(stderrcopy)
1448
 
 
1449
 
    def check_option_syntax(self, options):
1450
 
        check_option_syntax(self.parser, options)
1451
 
 
1452
 
    def test_actions_requires_client_or_all(self):
1453
 
        for action, value in self.actions.items():
1454
 
            options = self.parser.parse_args()
1455
 
            setattr(options, action, value)
1456
 
            with self.assertParseError():
1457
 
                self.check_option_syntax(options)
1458
 
 
1459
 
    def test_actions_conflicts_with_verbose(self):
1460
 
        for action, value in self.actions.items():
1461
 
            options = self.parser.parse_args()
1462
 
            setattr(options, action, value)
1463
 
            options.verbose = True
1464
 
            with self.assertParseError():
1465
 
                self.check_option_syntax(options)
1466
 
 
1467
 
    def test_dump_json_conflicts_with_verbose(self):
1468
 
        options = self.parser.parse_args()
1469
 
        options.dump_json = True
1470
 
        options.verbose = True
1471
 
        with self.assertParseError():
1472
 
            self.check_option_syntax(options)
1473
 
 
1474
 
    def test_dump_json_conflicts_with_action(self):
1475
 
        for action, value in self.actions.items():
1476
 
            options = self.parser.parse_args()
1477
 
            setattr(options, action, value)
1478
 
            options.dump_json = True
1479
 
            with self.assertParseError():
1480
 
                self.check_option_syntax(options)
1481
 
 
1482
 
    def test_all_can_not_be_alone(self):
1483
 
        options = self.parser.parse_args()
1484
 
        options.all = True
1485
 
        with self.assertParseError():
1486
 
            self.check_option_syntax(options)
1487
 
 
1488
 
    def test_all_is_ok_with_any_action(self):
1489
 
        for action, value in self.actions.items():
1490
 
            options = self.parser.parse_args()
1491
 
            setattr(options, action, value)
1492
 
            options.all = True
1493
 
            self.check_option_syntax(options)
1494
 
 
1495
 
    def test_is_enabled_fails_without_client(self):
1496
 
        options = self.parser.parse_args()
1497
 
        options.is_enabled = True
1498
 
        with self.assertParseError():
1499
 
            self.check_option_syntax(options)
1500
 
 
1501
 
    def test_is_enabled_works_with_one_client(self):
1502
 
        options = self.parser.parse_args()
1503
 
        options.is_enabled = True
1504
 
        options.client = ["foo"]
1505
 
        self.check_option_syntax(options)
1506
 
 
1507
 
    def test_is_enabled_fails_with_two_clients(self):
1508
 
        options = self.parser.parse_args()
1509
 
        options.is_enabled = True
1510
 
        options.client = ["foo", "barbar"]
1511
 
        with self.assertParseError():
1512
 
            self.check_option_syntax(options)
1513
 
 
1514
 
    def test_remove_can_only_be_combined_with_action_deny(self):
1515
 
        for action, value in self.actions.items():
1516
 
            if action in {"remove", "deny"}:
1517
 
                continue
1518
 
            options = self.parser.parse_args()
1519
 
            setattr(options, action, value)
1520
 
            options.all = True
1521
 
            options.remove = True
1522
 
            with self.assertParseError():
1523
 
                self.check_option_syntax(options)
1524
 
 
1525
1820
 
1526
1821
 
1527
1822
def should_only_run_tests():
1541
1836
    return tests
1542
1837
 
1543
1838
if __name__ == "__main__":
1544
 
    if should_only_run_tests():
1545
 
        # Call using ./tdd-python-script --check [--verbose]
1546
 
        unittest.main()
1547
 
    else:
1548
 
        main()
 
1839
    try:
 
1840
        if should_only_run_tests():
 
1841
            # Call using ./tdd-python-script --check [--verbose]
 
1842
            unittest.main()
 
1843
        else:
 
1844
            main()
 
1845
    finally:
 
1846
        logging.shutdown()