unittest.py 21.6 KB
Newer Older
1
from __future__ import annotations
Enrico Zini's avatar
Enrico Zini committed
2
import backend.models as bmodels
3
import legacy.models as lmodels
4
from backend.models import Person, AM, Fingerprint, _build_fullname
Enrico Zini's avatar
Enrico Zini committed
5
from backend import const
6
from dsa.models import LDAPFields
7
from signon.models import Identity
8
from django.conf import settings
Enrico Zini's avatar
Enrico Zini committed
9
from django.utils.timezone import now
10
from django.contrib.auth.backends import ModelBackend
11
from django.test import Client
Enrico Zini's avatar
Enrico Zini committed
12
from rest_framework.test import APIClient
13
from collections import defaultdict
Enrico Zini's avatar
Enrico Zini committed
14
import nm2.lib.unittest
Enrico Zini's avatar
Enrico Zini committed
15
import contextlib
Enrico Zini's avatar
Enrico Zini committed
16
17
18
19
import datetime
import re


Enrico Zini's avatar
Enrico Zini committed
20
class NamedObjects(nm2.lib.unittest.NamedObjects):
Enrico Zini's avatar
Enrico Zini committed
21
22
23
24
25
26
27
28
29
30
    def create(self, _name, **kw):
        self._update_kwargs_with_defaults(_name, kw)
        self[_name] = o = self._model.objects.create(**kw)
        return o


class TestPersons(NamedObjects):
    def __init__(self, **defaults):
        defaults.setdefault("cn", lambda name, **kw: name.capitalize())
        defaults.setdefault("email", "{_name}@example.org")
31
        defaults.setdefault("email_ldap", "{_name}@example.org")
32
        super().__init__(Person, **defaults)
Enrico Zini's avatar
Enrico Zini committed
33

34
    def create(self, _name, **kw):
Enrico Zini's avatar
Enrico Zini committed
35
        self._update_kwargs_with_defaults(_name, kw)
36
37
38
39
40
41
42
43
44
45
46
47
48

        # Allow to create Person and LDAPFields in one shot
        ldap_fields = {}
        for field in ("cn", "mn", "sn", "uid"):
            if field in kw:
                ldap_fields[field] = kw.pop(field)
        if "email_ldap" in kw:
            ldap_fields["email"] = kw.pop("email_ldap")
        ldap_fields.setdefault("uid", _name)

        if "fullname" not in kw:
            kw["fullname"] = _build_fullname(ldap_fields.get("cn"), ldap_fields.get("mn"), ldap_fields.get("sn"))

Enrico Zini's avatar
Enrico Zini committed
49
        self[_name] = o = self._model.objects.create_user(audit_skip=True, **kw)
50
        LDAPFields.objects.create(person=o, audit_skip=True, **ldap_fields)
51

Enrico Zini's avatar
Enrico Zini committed
52
53
54
        return o


55
56
57
58
59
60
61
62
63
64
65
66
class TestProcesses(NamedObjects):
    def __init__(self, **defaults):
        from process.models import Process
        super().__init__(Process, **defaults)

    def create(self, _name, **kw):
        from process.models import Process
        self._update_kwargs_with_defaults(_name, kw)
        self[_name] = o = Process.objects.create(**kw)
        return o


Enrico Zini's avatar
Enrico Zini committed
67
68
69
70
71
72
73
74
75
76
77
78
class TestKeys(NamedObjects):
    def __init__(self, **defaults):
        from keyring.models import Key
        super(TestKeys, self).__init__(Key, **defaults)

    def create(self, _name, **kw):
        self._update_kwargs_with_defaults(_name, kw)
        self._model.objects.test_preload(_name)
        self[_name] = o = self._model.objects.get_or_download(_name, **kw)
        return o


79
80
81
82
class TestAuthenticationBackend(ModelBackend):
    pass


Enrico Zini's avatar
Enrico Zini committed
83
class TestBase(nm2.lib.unittest.TestBase):
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
    TEST_AUTH_BACKEND = "backend.unittest.TestAuthenticationBackend"

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        if cls.TEST_AUTH_BACKEND not in settings.AUTHENTICATION_BACKENDS:
            settings.AUTHENTICATION_BACKENDS.append(cls.TEST_AUTH_BACKEND)

    @classmethod
    def tearDownClass(cls):
        if cls.TEST_AUTH_BACKEND in settings.AUTHENTICATION_BACKENDS:
            settings.AUTHENTICATION_BACKENDS.remove(cls.TEST_AUTH_BACKEND)
        super().tearDownClass()

    def make_test_client(self, person, signon_identities=()):
Enrico Zini's avatar
Enrico Zini committed
99
100
101
102
103
104
105
        """
        Instantiate a test client, logging in the given person.

        If person is None, visit anonymously. If person is None but
        sso_username is not None, authenticate as the given sso_username even
        if a Person record does not exist.
        """
106
107
108
109
        client = Client()
        for identity in signon_identities:
            identity = self.identities[identity]
            if identity.issuer == "salsa":
110
111
112
                session = client.session
                session["signon_identity_salsa"] = identity.pk
                session.save()
113
114
115
116
117
            elif identity.issuer == "debsso":
                client.defaults["SSL_CLIENT_S_DN_CN"] = identity.subject
            else:
                raise NotImplementedError(f"{identity.issuer} not supported as identity during testing")

118
119
        if isinstance(person, str):
            person = self.persons[person]
Enrico Zini's avatar
Enrico Zini committed
120
        if person is not None:
121
            client.force_login(person, backend=self.TEST_AUTH_BACKEND)
122
123
        client.visitor = person
        return client
Enrico Zini's avatar
Enrico Zini committed
124

125
    def make_test_apiclient(self, person, signon_identities=()):
Enrico Zini's avatar
Enrico Zini committed
126
127
128
129
130
131
132
        """
        Instantiate a test client, logging in the given person.

        If person is None, visit anonymously. If person is None but
        sso_username is not None, authenticate as the given sso_username even
        if a Person record does not exist.
        """
133
134
135
136
        client = APIClient()
        for identity in signon_identities:
            identity = self.identities[identity]
            if identity.issuer == "salsa":
137
                client.session["signon_identity_salsa"] = identity.pk
138
139
140
141
142
143
                client.session.save()
            elif identity.issuer == "debsso":
                client.defaults["SSL_CLIENT_S_DN_CN"] = identity.subject
            else:
                raise NotImplementedError(f"{identity.issuer} not supported as identity during testing")

144
145
        if isinstance(person, str):
            person = self.persons[person]
Enrico Zini's avatar
Enrico Zini committed
146
        if person is not None:
147
            client.force_login(person, backend=self.TEST_AUTH_BACKEND)
Enrico Zini's avatar
Enrico Zini committed
148
149
150
        client.visitor = person
        return client

151
152
153
154
155
156
    def assertPermissionDenied(self, response):
        if response.status_code == 403:
            pass
        else:
            self.fail("response has status code {} instead of a 403 Forbidden".format(response.status_code))

157
158
159
160
161
162
163
164
165
166
167
    def assertFormRedirectMatches(self, response, target, form_name="form"):
        if response.status_code == 200:
            form = response.context[form_name]
            if form.errors:
                self.fail("{} did not validate. Errors: {}".format(form_name, repr(form.errors)))
            self.fail("response has status code 200 instead of redirecting, and did not find any form errors")
        if response.status_code != 302:
            self.fail("response has status code {} instead of a Redirect".format(response.status_code))
        if target and not re.search(target, response["Location"]):
            self.fail("response redirects to {} which does not match {}".format(response["Location"], target))

Enrico Zini's avatar
Enrico Zini committed
168
169
170
171
172
173
    def assertContainsElements(self, response, elements, *names):
        """
        Check that the response contains only the elements in `names` from PageElements `elements`
        """
        want = set(names)
        extras = want - set(elements.keys())
Enrico Zini's avatar
Enrico Zini committed
174
175
        if extras:
            raise RuntimeError("Wanted elements not found in the list of possible ones: {}".format(", ".join(extras)))
Enrico Zini's avatar
Enrico Zini committed
176
177
178
        should_have = []
        should_not_have = []
        content = response.content.decode("utf-8")
Enrico Zini's avatar
Enrico Zini committed
179
        for name, regex in elements.items():
Enrico Zini's avatar
Enrico Zini committed
180
181
182
183
184
185
186
187
            if name in want:
                if not regex.search(content):
                    should_have.append(name)
            else:
                if regex.search(content):
                    should_not_have.append(name)
        if should_have or should_not_have:
            msg = []
Enrico Zini's avatar
Enrico Zini committed
188
189
190
191
            if should_have:
                msg.append("should have element(s) {}".format(", ".join(should_have)))
            if should_not_have:
                msg.append("should not have element(s) {}".format(", ".join(should_not_have)))
Enrico Zini's avatar
Enrico Zini committed
192
            self.fail("page " + " and ".join(msg))
Enrico Zini's avatar
Enrico Zini committed
193

Enrico Zini's avatar
Enrico Zini committed
194

195
class BaseFixtureMixin(TestBase):
Enrico Zini's avatar
Enrico Zini committed
196
197
198
    @classmethod
    def get_persons_defaults(cls):
        """
199
200
201
202
        Get default arguments for test persons
        """
        return {}

Enrico Zini's avatar
Enrico Zini committed
203
204
    @classmethod
    def setUpClass(cls):
205
        super().setUpClass()
Enrico Zini's avatar
Enrico Zini committed
206
207
        cls.add_named_objects(
            persons=TestPersons(**cls.get_persons_defaults()),
208
            identities=NamedObjects(Identity),
Enrico Zini's avatar
Enrico Zini committed
209
210
            fingerprints=NamedObjects(Fingerprint),
            ams=NamedObjects(AM),
211
            processes=TestProcesses(),
Enrico Zini's avatar
Enrico Zini committed
212
213
            keys=TestKeys()
        )
Enrico Zini's avatar
Enrico Zini committed
214

Enrico Zini's avatar
Enrico Zini committed
215
        # Preload keys
216
217
        cls.keys.create("66B4DFB68CB24EBBD8650BC4F4B4B0CC797EBFAB")
        cls.keys.create("1793D6AB75663E6BF104953A634F4BD1E7AD5568")
Enrico Zini's avatar
Enrico Zini committed
218
        cls.keys.create("0EED77DC41D760FDE44035FF5556A34E04A3610B")
219

220
221
222
223
224
225
226
227
    @classmethod
    def create_person(cls, name, *args, alioth=False, username=None, **kw):
        if username is None:
            if alioth:
                username = name + "-guest@users.alioth.debian.org"
            else:
                username = name + "@debian.org"
        p = cls.persons.create(name, *args, **kw)
228
229
230
        # cls.identities.create(
        #         f"{name}_debsso", person=p, issuer="debsso", subject=username, username=username,
        #         audit_skip=True)
231
232
        return p

233

Enrico Zini's avatar
Enrico Zini committed
234
235
236
237
238
239
240
241
242
243
244
245
246
247
class OpFixtureMixin(BaseFixtureMixin):
    def check_op(self, o, check_contents):
        # FIXME: compatibility, remove when code has been ported
        return self.assertOperationSerializes(o, check_contents)

    def assertOperationSerializes(self, o, check_contents=None):
        """
        When run with check_contents set to a function, it
        serializes/deserializes o and checks its contents with the
        check_contents function.

        When run without check_contents, it acts as a decorator, allowing a
        shorter invocation, like this:

Enrico Zini's avatar
Enrico Zini committed
248
249
250
251
           @self.assertOperationSerializes(operation_to_check)
           def _(o):
               self.assertEqual(o.audit_author, self.persons.fd)
               # ...more checks on o...
Enrico Zini's avatar
Enrico Zini committed
252
253
254
255
256
257
258
259
260
261
262
263
264
        """
        if check_contents is None:
            # Act as a decorator
            def run_test(check_contents):
                self.assertOperationSerializes(o, check_contents)
            return run_test
        else:
            # Actually run the test
            from backend import ops
            cls = o.__class__

            self.assertIsInstance(o.audit_time, datetime.datetime)
            check_contents(o)
Enrico Zini's avatar
Enrico Zini committed
265

Enrico Zini's avatar
Enrico Zini committed
266
267
268
269
270
271
272
            d = o.to_dict()
            o = cls(**d)
            self.assertIsInstance(o.audit_time, datetime.datetime)
            check_contents(o)

            j = o.to_json()
            o = ops.Operation.from_json(j)
Enrico Zini's avatar
Enrico Zini committed
273
            self.assertIsInstance(o, cls)
Enrico Zini's avatar
Enrico Zini committed
274
275
276
277
278
279
280
281
282
283
284
            self.assertIsInstance(o.audit_time, datetime.datetime)
            check_contents(o)

    @contextlib.contextmanager
    def collect_operations(self):
        from backend import ops
        with ops.Operation.test_collect() as ops:
            yield ops


class PersonFixtureMixin(OpFixtureMixin):
285
286
287
288
289
290
    """
    Pre-create some persons
    """
    @classmethod
    def setUpClass(cls):
        super(PersonFixtureMixin, cls).setUpClass()
Enrico Zini's avatar
Enrico Zini committed
291
        # pending account
292
        cls.create_person(
Enrico Zini's avatar
Enrico Zini committed
293
294
                "pending", status=const.STATUS_DC, expires=now() +
                datetime.timedelta(days=1), pending="12345", alioth=True)
Enrico Zini's avatar
Enrico Zini committed
295
        # debian contributor
296
        cls.create_person("dc", status=const.STATUS_DC, alioth=True)
Enrico Zini's avatar
Enrico Zini committed
297
        # debian contributor with guest account
298
        cls.create_person("dc_ga", status=const.STATUS_DC_GA, alioth=True)
Enrico Zini's avatar
Enrico Zini committed
299
        # dm
300
        cls.create_person("dm", status=const.STATUS_DM, alioth=True)
Enrico Zini's avatar
Enrico Zini committed
301
        # dm with guest account
302
        cls.create_person("dm_ga", status=const.STATUS_DM_GA, alioth=True)
Enrico Zini's avatar
Enrico Zini committed
303
        # dd, nonuploading
304
        cls.create_person("dd_nu", status=const.STATUS_DD_NU)
Enrico Zini's avatar
Enrico Zini committed
305
        # dd, uploading
306
        cls.create_person("dd_u", status=const.STATUS_DD_U)
307
        # dd, emeritus
308
        cls.create_person("dd_e", status=const.STATUS_EMERITUS_DD)
309
        # dd, removed
310
        cls.create_person("dd_r", status=const.STATUS_REMOVED_DD)
311
        # unrelated active am
312
        activeam = cls.create_person("activeam", status=const.STATUS_DD_NU)
313
314
        cls.ams.create("activeam", person=activeam)
        # inactive am
315
        oldam = cls.create_person("oldam", status=const.STATUS_DD_NU)
316
        cls.ams.create("oldam", person=oldam, is_am=False)
Enrico Zini's avatar
Enrico Zini committed
317
        # fd
318
        fd = cls.create_person("fd", status=const.STATUS_DD_NU, is_superuser=True, is_staff=True)
Enrico Zini's avatar
Enrico Zini committed
319
320
        cls.ams.create("fd", person=fd, is_fd=True)
        # dam
321
        dam = cls.create_person("dam", status=const.STATUS_DD_U, is_superuser=True, is_staff=True)
Enrico Zini's avatar
Enrico Zini committed
322
        cls.ams.create("dam", person=dam, is_fd=True, is_dam=True)
323
324


325
326
327
328
329
330
class TestSet(set):
    """
    Set of strings that can be initialized from space-separated strings, and
    changed with simple text patches.
    """
    def __init__(self, initial=""):
Enrico Zini's avatar
Enrico Zini committed
331
332
        if initial:
            self.update(initial.split())
333
334
335
336
337
338
339
340
341
342
343
344

    def set(self, vals):
        self.clear()
        self.update(vals.split())

    def patch(self, diff):
        for change in diff.split():
            if change[0] == "+":
                self.add(change[1:])
            elif change[0] == "-":
                self.discard(change[1:])
            else:
Enrico Zini's avatar
Enrico Zini committed
345
346
                raise RuntimeError("Changes {} contain {} that is nether an add nor a remove".format(
                    repr(diff), repr(change)))
347
348
349
350
351
352
353

    def clone(self):
        res = TestSet()
        res.update(self)
        return res


354
355
356
357
358
359
360
361
class PatchExact(object):
    def __init__(self, text):
        if text:
            self.items = set(text.split())
        else:
            self.items = set()

    def apply(self, cur):
Enrico Zini's avatar
Enrico Zini committed
362
363
        if self.items:
            return set(self.items)
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
        return None


class PatchDiff(object):
    def __init__(self, text):
        self.added = set()
        self.removed = set()
        for change in text.split():
            if change[0] == "+":
                self.added.add(change[1:])
            elif change[0] == "-":
                self.removed.add(change[1:])
            else:
                raise RuntimeError("Changes {} contain {} that is nether an add nor a remove".format(text, change))

    def apply(self, cur):
        if cur is None:
            cur = set(self.added)
        else:
            cur = (cur - self.removed) | self.added
Enrico Zini's avatar
Enrico Zini committed
384
385
        if not cur:
            return None
386
387
388
        return cur


389
class ExpectedSets(defaultdict):
390
391
392
    """
    Store the permissions expected out of a *VisitorPermissions object
    """
393
    def __init__(self, testcase, action_msg="{visitor}", issue_msg="{problem} {mismatch}"):
394
        super(ExpectedSets, self).__init__(TestSet)
395
        self.testcase = testcase
396
397
398
399
400
        self.action_msg = action_msg
        self.issue_msg = issue_msg

    @property
    def visitors(self):
Enrico Zini's avatar
Enrico Zini committed
401
        return list(self.keys())
402

403
404
405
406
407
    def __getitem__(self, key):
        if key == "-":
            key = None
        return super().__getitem__(key)

408
    def set(self, visitors, text):
409
410
        for v in visitors.split():
            self[v].set(text)
411
412

    def patch(self, visitors, text):
413
414
        for v in visitors.split():
            self[v].patch(text)
415
416
417
418
419
420
421
422

    def select_others(self, persons):
        other_visitors = set(persons.keys())
        other_visitors.add(None)
        other_visitors -= set(self.keys())
        return other_visitors

    def combine(self, other):
423
        res = ExpectedSets(self.testcase, action_msg=self.action_msg, issue_msg=self.issue_msg)
Enrico Zini's avatar
Enrico Zini committed
424
        for k, v in list(self.items()):
425
            res[k] = v.clone()
Enrico Zini's avatar
Enrico Zini committed
426
        for k, v in list(other.items()):
427
            res[k].update(v)
428
429
        return res

430
    def assertEqual(self, visitor, got):
431
432
        got = set(got)
        wanted = self.get(visitor, set())
Enrico Zini's avatar
Enrico Zini committed
433
434
        if got == wanted:
            return
435
436
437
        extra = got - wanted
        missing = wanted - got
        msgs = []
Enrico Zini's avatar
Enrico Zini committed
438
439
440
441
        if missing:
            msgs.append(self.issue_msg.format(problem="misses", mismatch=", ".join(sorted(missing))))
        if extra:
            msgs.append(self.issue_msg.format(problem="has extra", mismatch=", ".join(sorted(extra))))
442
        self.testcase.fail(self.action_msg.format(visitor=visitor) + " " + " and ".join(msgs))
443

444
    def assertEmpty(self, visitor, got):
445
        extra = set(got)
Enrico Zini's avatar
Enrico Zini committed
446
447
448
449
450
        if not extra:
            return
        self.testcase.fail(
                self.action_msg.format(visitor=visitor) + " " +
                self.issue_msg.format(problem="has", mismatch=", ".join(sorted(extra))))
451

452
    def assertMatches(self, visited):
453
        from django.contrib.auth.models import AnonymousUser
454
        for visitor in self.visitors:
455
            visit_perms = visited.permissions_of(self.testcase.persons[visitor] if visitor else AnonymousUser())
456
457
            self.assertEqual(visitor, visit_perms)
        for visitor in self.select_others(self.testcase.persons):
458
            visit_perms = visited.permissions_of(self.testcase.persons[visitor] if visitor else AnonymousUser())
459
460
            self.assertEmpty(visitor, visit_perms)

461

462
463
464
465
class ExpectedPerms(object):
    """
    Store the permissions expected out of a *VisitorPermissions object
    """
466
    def __init__(self, perms=None):
Enrico Zini's avatar
Enrico Zini committed
467
468
        if perms is None:
            perms = {}
469
        self.perms = {}
470
        for visitors, expected_perms in perms.items():
471
472
473
474
            for visitor in visitors.split():
                self.perms[visitor] = set(expected_perms.split())

    def _apply_diff(self, d, diff):
475
        for visitors, change in diff.items():
476
477
478
479
480
481
482
483
484
485
486
            for visitor in visitors.split():
                cur = change.apply(d.get(visitor, None))
                if not cur:
                    d.pop(visitor, None)
                else:
                    d[visitor] = cur

    def update_perms(self, diff):
        self._apply_diff(self.perms, diff)

    def set_perms(self, visitors, text):
Enrico Zini's avatar
Enrico Zini committed
487
        self.update_perms({visitors: PatchExact(text)})
488
489

    def patch_perms(self, visitors, text):
Enrico Zini's avatar
Enrico Zini committed
490
        self.update_perms({visitors: PatchDiff(text)})
491
492


Enrico Zini's avatar
Enrico Zini committed
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
class PageElements(dict):
    """
    List of all page elements possibly expected in the results of a view.

    dict matching name used to refer to the element with regexp matching the
    element.
    """
    def add_id(self, id):
        self[id] = re.compile(r"""id\s*=\s*["']{}["']""".format(re.escape(id)))

    def add_class(self, cls):
        self[cls] = re.compile(r"""class\s*=\s*["']{}["']""".format(re.escape(cls)))

    def add_href(self, name, url):
        self[name] = re.compile(r"""href\s*=\s*["']{}["']""".format(re.escape(url)))

509
510
511
    def add_th(self, name, title):
        self[name] = re.compile(r"""<th>\s*{}\s*</th>""".format(re.escape(title)))

Enrico Zini's avatar
Enrico Zini committed
512
513
    def add_string(self, name, term):
        self[name] = re.compile(r"""{}""".format(re.escape(term)))
Enrico Zini's avatar
Enrico Zini committed
514

515
516
    def clone(self):
        res = PageElements()
Enrico Zini's avatar
Enrico Zini committed
517
        res.update(self.items())
518
519
        return res

Enrico Zini's avatar
Enrico Zini committed
520
521
522

class TestOldProcesses(NamedObjects):
    def __init__(self, **defaults):
523
        super(TestOldProcesses, self).__init__(lmodels.Process, **defaults)
Enrico Zini's avatar
Enrico Zini committed
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
        defaults.setdefault("progress", const.PROGRESS_APP_NEW)

    def create(self, _name, advocates=[], **kw):
        self._update_kwargs_with_defaults(_name, kw)

        if "process" in kw:
            kw.setdefault("is_active", kw["process"] not in (const.PROGRESS_DONE, const.PROGRESS_CANCELLED))
        else:
            kw.setdefault("is_active", True)

        if "manager" in kw:
            try:
                am = kw["manager"].am
            except bmodels.AM.DoesNotExist:
                am = bmodels.AM.objects.create(person=kw["manager"])
            kw["manager"] = am

        self[_name] = o = self._model.objects.create(**kw)
        for a in advocates:
            o.advocates.add(a)
        return o


class OldProcessFixtureMixin(PersonFixtureMixin):
    @classmethod
    def get_processes_defaults(cls):
        """
        Get default arguments for test processes
        """
        return {}

    @classmethod
    def setUpClass(cls):
        super(OldProcessFixtureMixin, cls).setUpClass()
        cls.processes = TestOldProcesses(**cls.get_processes_defaults())
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576


class SignonFixtureMixin(BaseFixtureMixin):
    """
    Base test fixture for signon tests
    """
    @classmethod
    def setUpClass(cls):
        from signon.models import Identity
        super().setUpClass()
        cls.add_named_objects(
            identities=NamedObjects(Identity),
        )
        cls.create_person("user1", status=const.STATUS_DD_NU)
        cls.create_person("user2", status=const.STATUS_DD_NU)

        cls.user1 = cls.persons.user1
        cls.user2 = cls.persons.user2
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592


class ImpersonateFixtureMixin(BaseFixtureMixin):
    """
    Base test fixture for impersonate tests
    """
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.create_person("admin", status=const.STATUS_DD_NU, is_staff=True, is_superuser=True)
        cls.create_person("user1", status=const.STATUS_DD_NU)
        cls.create_person("user2", status=const.STATUS_DD_NU)

        cls.admin = cls.persons.admin
        cls.user1 = cls.persons.user1
        cls.user2 = cls.persons.user2
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632


class MockHousekeeping:
    """
    Mock version of django_housekeeping.run.Housekeeper, to run single
    housekeeping tasks
    """
    def __init__(self, housekeeper):
        # Mock the 'housekeeper' member, that is expected to be a
        # backend.housekeeping.Housekeeper instance
        self.housekeeper = type("Housekeeper", (object, ), {"user": housekeeper})

    def link(self, person):
        # Mock the 'link' member, that is expected to be a
        # backend.housekeeping.MakeLink instance
        return str(person.ldap_fields.uid or person.pk)

    def run(self, cls, stage: str = "main"):
        """
        Run a housekeeping Task
        """
        task = cls(self)
        task.IDENTIFIER = f"test.{cls.__name__}"
        getattr(task, f"run_{stage}")(None)
        return task


class HousekeepingMixin:
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.create_person(
                "housekeeper", email="nm@debian.org", status=const.STATUS_DC, is_staff=False, is_superuser=False)

    def run_housekeeping_task(self, cls, stage: str = "main"):
        """
        Run a housekeeping task class, returning its instance
        """
        housekeeping = MockHousekeeping(self.persons.housekeeper)
        return housekeeping.run(cls, stage)