unittest.py 20.9 KB
Newer Older
1
import backend.models as bmodels
Enrico Zini's avatar
Enrico Zini committed
2
from backend.models import Person, AM, Fingerprint
Enrico Zini's avatar
Enrico Zini committed
3 4 5
from backend import const
from django.utils.timezone import now
from django.test import Client
6
from rest_framework.test import APIClient
7
from collections import defaultdict
8
import contextlib
Enrico Zini's avatar
Enrico Zini committed
9 10 11 12 13 14 15 16 17
import datetime
import re
import six


class NamedObjects(dict):
    """
    Container for fixture model objects.
    """
18
    def __init__(self, model, skip_delete_all=False, **defaults):
Enrico Zini's avatar
Enrico Zini committed
19 20 21
        super(NamedObjects, self).__init__()
        self._model = model
        self._defaults = defaults
22
        self._skip_delete_all = skip_delete_all
Enrico Zini's avatar
Enrico Zini committed
23 24 25 26 27 28 29 30

    def __getitem__(self, key):
        """
        Dict that only looks things up if they are strings, otherwise just return key.

        This allows to use __getitem__ with already resolved objects, just to have
        functions that can take either objects or their fixture names.
        """
Enrico Zini's avatar
Enrico Zini committed
31 32
        if not isinstance(key, str):
            return key
Enrico Zini's avatar
Enrico Zini committed
33 34 35 36 37 38 39
        return super(NamedObjects, self).__getitem__(key)

    def __getattr__(self, key):
        """
        Make dict elements also appear as class members
        """
        res = self.get(key, None)
Enrico Zini's avatar
Enrico Zini committed
40 41
        if res is not None:
            return res
Enrico Zini's avatar
Enrico Zini committed
42 43 44 45 46 47 48 49 50
        raise AttributeError("member {} not found".format(key))

    def _update_kwargs_with_defaults(self, _name, kw):
        """
        Update the kw dict with defaults from self._defaults.

        If self._defaults for an argument is a string, then calls .format() on
        it passing _name and self._defaults as format arguments.
        """
51
        for k, v in self._defaults.items():
Enrico Zini's avatar
Enrico Zini committed
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
            if isinstance(v, six.string_types):
                kw.setdefault(k, v.format(_name=_name, **self._defaults))
            elif hasattr(v, "__call__"):
                kw.setdefault(k, v(_name, **self._defaults))
            else:
                kw.setdefault(k, v)

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

    def refresh(self):
        """
        Reload all the objects from the database.

        This is needed because although Django's TestCase rolls back the
        database after a test, the data stored in memory in the objects stored
        in NamedObjects repositories is not automatically refreshed.
        """
72 73 74 75 76 77
        for name, o in list(self.items()):
            try:
                self[name].refresh_from_db()
            except self._model.DoesNotExist:
                del self[name]

Enrico Zini's avatar
Enrico Zini committed
78 79 80 81 82 83 84
    def delete_all(self):
        """
        Call delete() on all model objects registered in this dict.

        This can be used in methods like tearDownClass to remove objects common
        to all tests.
        """
Enrico Zini's avatar
Enrico Zini committed
85 86
        if self._skip_delete_all:
            return
87
        for o in self.values():
Enrico Zini's avatar
Enrico Zini committed
88 89 90 91 92 93 94
            o.delete()


class TestPersons(NamedObjects):
    def __init__(self, **defaults):
        defaults.setdefault("cn", lambda name, **kw: name.capitalize())
        defaults.setdefault("email", "{_name}@example.org")
95
        defaults.setdefault("email_ldap", "{_name}@example.org")
Enrico Zini's avatar
Enrico Zini committed
96 97 98 99 100 101 102
        super(TestPersons, self).__init__(Person, **defaults)

    def create(self, _name, alioth=False, **kw):
        if alioth:
            kw.setdefault("username", _name + "-guest@users.alioth.debian.org")
        else:
            kw.setdefault("username", _name + "@debian.org")
103
        kw.setdefault("uid", _name)
Enrico Zini's avatar
Enrico Zini committed
104 105 106 107 108
        self._update_kwargs_with_defaults(_name, kw)
        self[_name] = o = self._model.objects.create_user(audit_skip=True, **kw)
        return o


Enrico Zini's avatar
Enrico Zini committed
109 110 111 112 113 114 115 116 117 118 119 120
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


Enrico Zini's avatar
Enrico Zini committed
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
class TestMeta(type):
    def __new__(cls, name, bases, attrs):
        res = super(TestMeta, cls).__new__(cls, name, bases, attrs)
        if hasattr(res, "__add_extra_tests__"):
            res.__add_extra_tests__()
        return res


@six.add_metaclass(TestMeta)
class TestBase(object):
    @classmethod
    def _add_method(cls, meth, *args, **kw):
        """
        Add a test method, made of the given method called with the given args
        and kwargs.

        The method name and args are used to built the test method name, the
        kwargs are not: make sure you use the args to make the test case
        unique, and the kwargs for things you do not want to appear in the test
        name, like the expected test results for those args.
        """
        name = re.sub(r"[^0-9A-Za-z_]", "_", "{}_{}".format(meth.__name__.lstrip("_"), "_".join(str(x) for x in args)))
        setattr(cls, name, lambda self: meth(self, *args, **kw))

145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
    @classmethod
    def setUpClass(cls):
        super(TestBase, cls).setUpClass()
        cls._object_repos = []

    @classmethod
    def tearDownClass(cls):
        super(TestBase, cls).tearDownClass()
        for r in cls._object_repos[::-1]:
            r.delete_all()

    def setUp(self):
        super(TestBase, self).setUp()
        for r in self._object_repos:
            r.refresh()

    @classmethod
    def add_named_objects(cls, **kw):
        for name, repo in kw.items():
            cls._object_repos.append(repo)
            setattr(cls, name, repo)

Enrico Zini's avatar
Enrico Zini committed
167 168 169 170 171 172 173 174 175 176 177 178 179
    def make_test_client(self, person, sso_username=None, **kw):
        """
        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.
        """
        person = self.persons[person]
        if person is not None:
            kw["SSL_CLIENT_S_DN_CN"] = person.username
        elif sso_username is not None:
            kw["SSL_CLIENT_S_DN_CN"] = sso_username
180 181 182
        client = Client(**kw)
        client.visitor = person
        return client
Enrico Zini's avatar
Enrico Zini committed
183

184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
    def make_test_apiclient(self, person, sso_username=None, **kw):
        """
        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.
        """
        person = self.persons[person]
        if person is not None:
            kw["SSL_CLIENT_S_DN_CN"] = person.username
        elif sso_username is not None:
            kw["SSL_CLIENT_S_DN_CN"] = sso_username
        client = APIClient(**kw)
        client.visitor = person
        return client

201 202 203 204 205 206 207 208 209 210 211
    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))

    def assertRedirectMatches(self, response, target):
        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
212

213 214 215 216 217 218 219 220 221 222 223
    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
224 225 226
    def assertFormErrorMatches(self, response, form_name, field_name, regex):
        form = response.context[form_name]
        errors = form.errors
Enrico Zini's avatar
Enrico Zini committed
227 228 229 230
        if not errors:
            self.fail("Form {} has no errors".format(form_name))
        if field_name not in errors:
            self.fail("Form {} has no errors in field {}".format(form_name, field_name))
Enrico Zini's avatar
Enrico Zini committed
231 232
        match = re.compile(regex)
        for errmsg in errors[field_name]:
Enrico Zini's avatar
Enrico Zini committed
233 234
            if match.search(errmsg):
                return
Enrico Zini's avatar
Enrico Zini committed
235 236
        self.fail("{} dit not match any in {}".format(regex, repr(errors)))

237 238 239 240 241 242
    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
243 244
        if extras:
            raise RuntimeError("Wanted elements not found in the list of possible ones: {}".format(", ".join(extras)))
245 246 247
        should_have = []
        should_not_have = []
        content = response.content.decode("utf-8")
248
        for name, regex in elements.items():
249 250 251 252 253 254 255 256
            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
257 258 259 260
            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)))
261
            self.fail("page " + " and ".join(msg))
Enrico Zini's avatar
Enrico Zini committed
262

263

264
class BaseFixtureMixin(TestBase):
Enrico Zini's avatar
Enrico Zini committed
265 266 267
    @classmethod
    def get_persons_defaults(cls):
        """
268 269 270 271
        Get default arguments for test persons
        """
        return {}

Enrico Zini's avatar
Enrico Zini committed
272 273
    @classmethod
    def setUpClass(cls):
274
        import process.models as pmodels
275
        super(BaseFixtureMixin, cls).setUpClass()
276 277 278 279 280 281 282
        cls.add_named_objects(
            persons=TestPersons(**cls.get_persons_defaults()),
            fingerprints=NamedObjects(Fingerprint),
            ams=NamedObjects(AM),
            processes=NamedObjects(pmodels.Process),
            keys=TestKeys()
        )
Enrico Zini's avatar
Enrico Zini committed
283

Enrico Zini's avatar
Enrico Zini committed
284
        # Preload keys
285 286
        cls.keys.create("66B4DFB68CB24EBBD8650BC4F4B4B0CC797EBFAB")
        cls.keys.create("1793D6AB75663E6BF104953A634F4BD1E7AD5568")
Enrico Zini's avatar
Enrico Zini committed
287
        cls.keys.create("0EED77DC41D760FDE44035FF5556A34E04A3610B")
288 289


290 291 292 293 294 295 296 297 298 299 300 301 302 303
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
304 305 306 307
           @self.assertOperationSerializes(operation_to_check)
           def _(o):
               self.assertEqual(o.audit_author, self.persons.fd)
               # ...more checks on o...
308 309 310 311 312 313 314 315 316 317 318 319 320
        """
        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
321

322 323 324 325 326 327 328
            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)
329
            self.assertIsInstance(o, cls)
330 331 332 333 334 335 336 337 338 339 340
            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):
341 342 343 344 345 346
    """
    Pre-create some persons
    """
    @classmethod
    def setUpClass(cls):
        super(PersonFixtureMixin, cls).setUpClass()
Enrico Zini's avatar
Enrico Zini committed
347
        # pending account
Enrico Zini's avatar
Enrico Zini committed
348 349 350
        cls.persons.create(
                "pending", status=const.STATUS_DC, expires=now() +
                datetime.timedelta(days=1), pending="12345", alioth=True)
Enrico Zini's avatar
Enrico Zini committed
351 352 353 354 355 356 357 358 359 360 361 362
        # debian contributor
        cls.persons.create("dc", status=const.STATUS_DC, alioth=True)
        # debian contributor with guest account
        cls.persons.create("dc_ga", status=const.STATUS_DC_GA, alioth=True)
        # dm
        cls.persons.create("dm", status=const.STATUS_DM, alioth=True)
        # dm with guest account
        cls.persons.create("dm_ga", status=const.STATUS_DM_GA, alioth=True)
        # dd, nonuploading
        cls.persons.create("dd_nu", status=const.STATUS_DD_NU)
        # dd, uploading
        cls.persons.create("dd_u", status=const.STATUS_DD_U)
363 364 365 366
        # dd, emeritus
        cls.persons.create("dd_e", status=const.STATUS_EMERITUS_DD)
        # dd, removed
        cls.persons.create("dd_r", status=const.STATUS_REMOVED_DD)
367
        # unrelated active am
368 369 370 371 372
        activeam = cls.persons.create("activeam", status=const.STATUS_DD_NU)
        cls.ams.create("activeam", person=activeam)
        # inactive am
        oldam = cls.persons.create("oldam", status=const.STATUS_DD_NU)
        cls.ams.create("oldam", person=oldam, is_am=False)
Enrico Zini's avatar
Enrico Zini committed
373 374 375 376
        # fd
        fd = cls.persons.create("fd", status=const.STATUS_DD_NU)
        cls.ams.create("fd", person=fd, is_fd=True)
        # dam
377
        dam = cls.persons.create("dam", status=const.STATUS_DD_U)
Enrico Zini's avatar
Enrico Zini committed
378
        cls.ams.create("dam", person=dam, is_fd=True, is_dam=True)
379 380


381 382 383 384 385 386
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
387 388
        if initial:
            self.update(initial.split())
389 390 391 392 393 394 395 396 397 398 399 400

    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
401 402
                raise RuntimeError("Changes {} contain {} that is nether an add nor a remove".format(
                    repr(diff), repr(change)))
403 404 405 406 407 408 409

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


410 411 412 413 414 415 416 417
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
418 419
        if self.items:
            return set(self.items)
420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439
        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
440 441
        if not cur:
            return None
442 443 444
        return cur


445
class ExpectedSets(defaultdict):
446 447 448
    """
    Store the permissions expected out of a *VisitorPermissions object
    """
449
    def __init__(self, testcase, action_msg="{visitor}", issue_msg="{problem} {mismatch}"):
450
        super(ExpectedSets, self).__init__(TestSet)
451
        self.testcase = testcase
452 453 454 455 456
        self.action_msg = action_msg
        self.issue_msg = issue_msg

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

459 460 461 462 463
    def __getitem__(self, key):
        if key == "-":
            key = None
        return super().__getitem__(key)

464
    def set(self, visitors, text):
465 466
        for v in visitors.split():
            self[v].set(text)
467 468

    def patch(self, visitors, text):
469 470
        for v in visitors.split():
            self[v].patch(text)
471 472 473 474 475 476 477 478

    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):
479
        res = ExpectedSets(self.testcase, action_msg=self.action_msg, issue_msg=self.issue_msg)
Enrico Zini's avatar
Enrico Zini committed
480
        for k, v in list(self.items()):
481
            res[k] = v.clone()
Enrico Zini's avatar
Enrico Zini committed
482
        for k, v in list(other.items()):
483
            res[k].update(v)
484 485
        return res

486
    def assertEqual(self, visitor, got):
487 488
        got = set(got)
        wanted = self.get(visitor, set())
Enrico Zini's avatar
Enrico Zini committed
489 490
        if got == wanted:
            return
491 492 493
        extra = got - wanted
        missing = wanted - got
        msgs = []
Enrico Zini's avatar
Enrico Zini committed
494 495 496 497
        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))))
498
        self.testcase.fail(self.action_msg.format(visitor=visitor) + " " + " and ".join(msgs))
499

500
    def assertEmpty(self, visitor, got):
501
        extra = set(got)
Enrico Zini's avatar
Enrico Zini committed
502 503 504 505 506
        if not extra:
            return
        self.testcase.fail(
                self.action_msg.format(visitor=visitor) + " " +
                self.issue_msg.format(problem="has", mismatch=", ".join(sorted(extra))))
507

508 509 510 511 512 513 514 515
    def assertMatches(self, visited):
        for visitor in self.visitors:
            visit_perms = visited.permissions_of(self.testcase.persons[visitor])
            self.assertEqual(visitor, visit_perms)
        for visitor in self.select_others(self.testcase.persons):
            visit_perms = visited.permissions_of(self.testcase.persons[visitor] if visitor else None)
            self.assertEmpty(visitor, visit_perms)

516

517 518 519 520
class ExpectedPerms(object):
    """
    Store the permissions expected out of a *VisitorPermissions object
    """
521
    def __init__(self, perms=None):
Enrico Zini's avatar
Enrico Zini committed
522 523
        if perms is None:
            perms = {}
524
        self.perms = {}
525
        for visitors, expected_perms in perms.items():
526 527 528 529
            for visitor in visitors.split():
                self.perms[visitor] = set(expected_perms.split())

    def _apply_diff(self, d, diff):
530
        for visitors, change in diff.items():
531 532 533 534 535 536 537 538 539 540 541
            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
542
        self.update_perms({visitors: PatchExact(text)})
543 544

    def patch_perms(self, visitors, text):
Enrico Zini's avatar
Enrico Zini committed
545
        self.update_perms({visitors: PatchDiff(text)})
546 547


Enrico Zini's avatar
Enrico Zini committed
548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565
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)))

    def add_string(self, name, term):
        self[name] = re.compile(r"""{}""".format(re.escape(term)))
566

567 568
    def clone(self):
        res = PageElements()
569
        res.update(self.items())
570 571
        return res

572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610

class TestOldProcesses(NamedObjects):
    def __init__(self, **defaults):
        super(TestOldProcesses, self).__init__(bmodels.Process, **defaults)
        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())