From bf81e33668837d1fdb8e5a310fdc29a365ee215d Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Jan 2024 17:45:24 -0400 Subject: [PATCH 01/31] feat: DraftAliasGenerator class Encapsulates logic from generate_draft_aliases.py --- ietf/doc/utils.py | 124 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 46ecccc314..e49743493e 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -13,7 +13,7 @@ from collections import defaultdict, namedtuple, Counter from dataclasses import dataclass -from typing import Union +from typing import Iterator, Union from zoneinfo import ZoneInfo from django.conf import settings @@ -37,11 +37,12 @@ from ietf.doc.models import TelechatDocEvent, DocumentActionHolder, EditedAuthorsDocEvent from ietf.name.models import DocReminderTypeName, DocRelationshipName from ietf.group.models import Role, Group, GroupFeatures +from ietf.group.utils import get_group_role_emails, get_group_ad_emails from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, is_individual_draft_author, is_bofreq_editor from ietf.person.models import Person from ietf.review.models import ReviewWish from ietf.utils import draft, log -from ietf.utils.mail import send_mail +from ietf.utils.mail import parseaddr, send_mail from ietf.mailtrigger.utils import gather_address_lists from ietf.utils.timezone import date_today, datetime_from_date, datetime_today, DEADLINE_TZINFO from ietf.utils.xmldraft import XMLDraft @@ -1258,3 +1259,122 @@ def bibxml_for_draft(doc, rev=None): return render_to_string('doc/bibxml.xml', {'name':name, 'doc':doc, 'doc_bibtype':'I-D', 'settings':settings}) + +class DraftAliasGenerator: + days = 2 * 365 + + def get_draft_ad_emails(self, doc): + """Get AD email addresses for the given draft, if any.""" + ad_emails = set() + # If working group document, return current WG ADs + if doc.group and doc.group.acronym != "none": + ad_emails.update(get_group_ad_emails(doc.group)) + # Document may have an explicit AD set + if doc.ad: + ad_emails.add(doc.ad.email_address()) + return ad_emails + + def get_draft_chair_emails(self, doc): + """Get chair email addresses for the given draft, if any.""" + chair_emails = set() + if doc.group: + chair_emails.update(get_group_role_emails(doc.group, ["chair", "secr"])) + return chair_emails + + def get_draft_shepherd_email(self, doc): + """Get shepherd email addresses for the given draft, if any.""" + shepherd_email = set() + if doc.shepherd: + shepherd_email.add(doc.shepherd.email_address()) + return shepherd_email + + def get_draft_authors_emails(self, doc): + """Get list of authors for the given draft.""" + author_emails = set() + for author in doc.documentauthor_set.all(): + if author.email and author.email.email_address(): + author_emails.add(author.email.email_address()) + return author_emails + + def get_draft_notify_emails(self, doc): + """Get list of email addresses to notify for the given draft.""" + ad_email_alias_regex = r"^%s.ad@(%s|%s)$" % (doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + all_email_alias_regex = r"^%s.all@(%s|%s)$" % (doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + author_email_alias_regex = r"^%s@(%s|%s)$" % (doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + notify_email_alias_regex = r"^%s.notify@(%s|%s)$" % ( + doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + shepherd_email_alias_regex = r"^%s.shepherd@(%s|%s)$" % ( + doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + notify_emails = set() + if doc.notify: + for e in doc.notify.split(','): + e = e.strip() + if re.search(ad_email_alias_regex, e): + notify_emails.update(self.get_draft_ad_emails(doc)) + elif re.search(author_email_alias_regex, e): + notify_emails.update(self.get_draft_authors_emails(doc)) + elif re.search(shepherd_email_alias_regex, e): + notify_emails.update(self.get_draft_shepherd_email(doc)) + elif re.search(all_email_alias_regex, e): + notify_emails.update(self.get_draft_ad_emails(doc)) + notify_emails.update(self.get_draft_authors_emails(doc)) + notify_emails.update(self.get_draft_shepherd_email(doc)) + elif re.search(notify_email_alias_regex, e): + pass + else: + (name, email) = parseaddr(e) + notify_emails.add(email) + return notify_emails + + def __iter__(self) -> Iterator[tuple[str, list[str]]]: + # Internet-Drafts with active status or expired within self.days + show_since = timezone.now() - datetime.timedelta(days=self.days) + drafts = Document.objects.filter(type_id="draft") + active_drafts = drafts.filter(states__slug='active') + inactive_recent_drafts = drafts.exclude(states__slug='active').filter(expires__gte=show_since) + interesting_drafts = active_drafts | inactive_recent_drafts + + alias_domains = ['ietf.org', ] + for draft in interesting_drafts.distinct().iterator(): + # Omit drafts that became RFCs, unless they were published in the last DEFAULT_YEARS + if draft.get_state_slug() == "rfc": + rfc = draft.became_rfc() + log.assertion("rfc is not None") + if rfc.latest_event(type='published_rfc').time < show_since: + continue + + alias = draft.name + all = set() + + # no suffix and .authors are the same list + emails = self.get_draft_authors_emails(draft) + all.update(emails) + yield alias, emails + yield alias + ".authors", emails + + # .chairs = group chairs + emails = self.get_draft_chair_emails(draft) + if emails: + all.update(emails) + yield alias + ".chairs", emails + + # .ad = sponsoring AD / WG AD (WG document) + emails = self.get_draft_ad_emails(draft) + if emails: + all.update(emails) + yield alias + ".ad", emails + + # .notify = notify email list from the Document + emails = self.get_draft_notify_emails(draft) + if emails: + all.update(emails) + yield alias + ".notify", emails + + # .shepherd = shepherd email from the Document + emails = self.get_draft_shepherd_email(draft) + if emails: + all.update(emails) + yield alias + ".shepherd", emails + + # .all = everything from above + yield alias + ".all", all From c6ed428e2599c8d0422b7f0528474af4a80dec21 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Jan 2024 18:23:43 -0400 Subject: [PATCH 02/31] refactor: Avoid circular imports --- ietf/doc/utils.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index e49743493e..6e11fc6843 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -37,7 +37,6 @@ from ietf.doc.models import TelechatDocEvent, DocumentActionHolder, EditedAuthorsDocEvent from ietf.name.models import DocReminderTypeName, DocRelationshipName from ietf.group.models import Role, Group, GroupFeatures -from ietf.group.utils import get_group_role_emails, get_group_ad_emails from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, is_individual_draft_author, is_bofreq_editor from ietf.person.models import Person from ietf.review.models import ReviewWish @@ -1265,6 +1264,7 @@ class DraftAliasGenerator: def get_draft_ad_emails(self, doc): """Get AD email addresses for the given draft, if any.""" + from ietf.group.utils import get_group_ad_emails # avoid circular import ad_emails = set() # If working group document, return current WG ADs if doc.group and doc.group.acronym != "none": @@ -1276,6 +1276,7 @@ def get_draft_ad_emails(self, doc): def get_draft_chair_emails(self, doc): """Get chair email addresses for the given draft, if any.""" + from ietf.group.utils import get_group_role_emails # avoid circular import chair_emails = set() if doc.group: chair_emails.update(get_group_role_emails(doc.group, ["chair", "secr"])) @@ -1349,32 +1350,32 @@ def __iter__(self) -> Iterator[tuple[str, list[str]]]: # no suffix and .authors are the same list emails = self.get_draft_authors_emails(draft) all.update(emails) - yield alias, emails - yield alias + ".authors", emails + yield alias, list(emails) + yield alias + ".authors", list(emails) # .chairs = group chairs emails = self.get_draft_chair_emails(draft) if emails: all.update(emails) - yield alias + ".chairs", emails + yield alias + ".chairs", list(emails) # .ad = sponsoring AD / WG AD (WG document) emails = self.get_draft_ad_emails(draft) if emails: all.update(emails) - yield alias + ".ad", emails + yield alias + ".ad", list(emails) # .notify = notify email list from the Document emails = self.get_draft_notify_emails(draft) if emails: all.update(emails) - yield alias + ".notify", emails + yield alias + ".notify", list(emails) # .shepherd = shepherd email from the Document emails = self.get_draft_shepherd_email(draft) if emails: all.update(emails) - yield alias + ".shepherd", emails + yield alias + ".shepherd", list(emails) # .all = everything from above - yield alias + ".all", all + yield alias + ".all", list(all) From e4a902ae2b9a2ad5e30687a92c6427da63a222dd Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Jan 2024 18:27:35 -0400 Subject: [PATCH 03/31] feat: Add draft_aliases API endpoint --- ietf/api/urls.py | 1 + ietf/api/views.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ietf/api/urls.py b/ietf/api/urls.py index 7ee55cf708..bf79d3b566 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -22,6 +22,7 @@ url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()), # # --- Custom API endpoints, sorted alphabetically --- + url(r'^doc/draft-aliases/$', api_views.draft_aliases), # GPRD: export of personal information for the logged-in person url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()), # Let IESG members set positions programmatically diff --git a/ietf/api/views.py b/ietf/api/views.py index 78e3236842..360a3c44f4 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -13,7 +13,7 @@ from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import validate_email -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, JsonResponse from django.shortcuts import render, get_object_or_404 from django.urls import reverse from django.utils.decorators import method_decorator @@ -33,7 +33,7 @@ from ietf.api import _api_list from ietf.api.serializer import JsonExportMixin from ietf.api.ietf_utils import is_valid_token -from ietf.doc.utils import fuzzy_find_documents +from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents from ietf.ietfauth.views import send_account_creation_email from ietf.ietfauth.utils import role_required from ietf.meeting.models import Meeting @@ -453,3 +453,17 @@ def directauth(request): else: return HttpResponse(status=405) + + +@csrf_exempt +def draft_aliases(request): + if request.method == "POST": + # todo authentication + return JsonResponse( + { + "aliases": { + alias: address_list for alias, address_list in DraftAliasGenerator() + } + } + ) + return HttpResponse(status=405) From 8d0a533a6a5c861750c4230aec2b752659bfc342 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Jan 2024 18:33:40 -0400 Subject: [PATCH 04/31] feat: Add @requires_api_token decorator Stolen from feat/rpc-api --- ietf/api/ietf_utils.py | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ietf/api/ietf_utils.py b/ietf/api/ietf_utils.py index 06b9d76aff..0d24d58479 100644 --- a/ietf/api/ietf_utils.py +++ b/ietf/api/ietf_utils.py @@ -2,8 +2,12 @@ # This is not utils.py because Tastypie implicitly consumes ietf.api.utils. # See ietf.api.__init__.py for details. +from functools import wraps +from typing import Callable, Optional, Union from django.conf import settings +from django.http import HttpResponseForbidden + def is_valid_token(endpoint, token): # This is where we would consider integration with vault @@ -13,3 +17,55 @@ def is_valid_token(endpoint, token): if endpoint in token_store and token in token_store[endpoint]: return True return False + + +def requires_api_token(func_or_endpoint: Optional[Union[Callable, str]] = None): + """Validate API token before executing the wrapped method + + Usage: + * Basic: endpoint defaults to the qualified name of the wrapped method. E.g., in ietf.api.views, + + @requires_api_token + def my_view(request): + ... + + will require a token for "ietf.api.views.my_view" + + * Custom endpoint: specify the endpoint explicitly + + @requires_api_token("ietf.api.views.some_other_thing") + def my_view(request): + ... + + will require a token for "ietf.api.views.some_other_thing" + """ + + def decorate(f): + if _endpoint is None: + fname = getattr(f, "__qualname__", None) + if fname is None: + raise TypeError( + "Cannot automatically decorate function that does not support __qualname__. " + "Explicitly set the endpoint." + ) + endpoint = "{}.{}".format(f.__module__, fname) + else: + endpoint = _endpoint + + @wraps(f) + def wrapped(request, *args, **kwargs): + authtoken = request.META.get("HTTP_X_API_KEY", None) + if authtoken is None or not is_valid_token(endpoint, authtoken): + return HttpResponseForbidden() + return f(request, *args, **kwargs) + + return wrapped + + # Magic to allow decorator to be used with or without parentheses + if callable(func_or_endpoint): + func = func_or_endpoint + _endpoint = None + return decorate(func) + else: + _endpoint = func_or_endpoint + return decorate From d61ce80682f23e1d3ccb4b57b59ae7502ae35a66 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Jan 2024 19:42:29 -0400 Subject: [PATCH 05/31] feat: Add token auth to draft_aliases endpoint --- ietf/api/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/api/views.py b/ietf/api/views.py index 360a3c44f4..d2f2153988 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -32,7 +32,7 @@ from ietf.person.models import Person, Email from ietf.api import _api_list from ietf.api.serializer import JsonExportMixin -from ietf.api.ietf_utils import is_valid_token +from ietf.api.ietf_utils import is_valid_token, requires_api_token from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents from ietf.ietfauth.views import send_account_creation_email from ietf.ietfauth.utils import role_required @@ -455,10 +455,10 @@ def directauth(request): return HttpResponse(status=405) +@requires_api_token @csrf_exempt def draft_aliases(request): if request.method == "POST": - # todo authentication return JsonResponse( { "aliases": { From 83dc67c41eab01c5a4ef6ee8aea8283a3eccfe3e Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 31 Jan 2024 16:09:25 -0400 Subject: [PATCH 06/31] feat: draft-aliases-from-json.py script Parses output from the draft_aliases API call --- ietf/bin/draft-aliases-from-json.py | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 ietf/bin/draft-aliases-from-json.py diff --git a/ietf/bin/draft-aliases-from-json.py b/ietf/bin/draft-aliases-from-json.py new file mode 100644 index 0000000000..49e1bfda6e --- /dev/null +++ b/ietf/bin/draft-aliases-from-json.py @@ -0,0 +1,92 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# +# Uses only Python standard lib +# + +import argparse +import datetime +import json +import shutil +import stat +import sys + +from pathlib import Path +from tempfile import TemporaryDirectory + +# Default options +POSTCONFIRM_PATH = "/a/postconfirm/wrapper" +VDOMAIN = "virtual.ietf.org" +ADOMAINS = ["ietf.org"] + + +def generate_files(records, adest, vdest, postconfirm, vdomain, adomains): + """Generate files from an iterable of records + + If adest or vdest exists as a file, it will be overwritten. If it is a directory, files + with the default names (draft-aliases and draft-virtual) will be created, but existing + files _will not_ be overwritten! + """ + with TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + apath = tmppath / "draft-aliases" + vpath = tmppath / "draft-virtual" + + with apath.open("w") as afile, vpath.open("w") as vfile: + date = datetime.datetime.now(datetime.timezone.utc) + signature = f"# Generated by {Path(__file__).absolute()} at {date}\n" + afile.write(signature) + vfile.write(signature) + + for alias, address_list in records.items(): + filtername = f"xfilter-{alias}" + afile.write(f'{filtername + ":":64s} "|{postconfirm} filter expand-{alias} {vdomain}"\n') + for domain in adomains: + vfile.write(f"{f'{alias}@{domain}':64s} {filtername}\n") + vfile.write(f"{f'expand-{alias}@{vdomain}':64s} {', '.join(address_list)}\n") + + perms = stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH + apath.chmod(perms) + vpath.chmod(perms) + shutil.move(apath, adest) + shutil.move(vpath, vdest) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Convert a JSON stream of draft alias definitions into alias / virtual alias files." + ) + parser.add_argument( + "--output-dir", + default="./", + type=Path, + help="Destination for output files.", + ) + parser.add_argument( + "--postconfirm", + default=POSTCONFIRM_PATH, + help=f"Full path to postconfirm executable (defaults to {POSTCONFIRM_PATH}", + ) + parser.add_argument( + "--vdomain", + default=VDOMAIN, + help=f"Virtual domain (defaults to {VDOMAIN}_", + ) + parser.add_argument( + "--adomain", + action="append", + default=[], + help=f"Domains in which to create aliases (multiple allowed; if none are specified, defaults to {ADOMAINS})" + ) + args = parser.parse_args() + if not args.output_dir.is_dir(): + sys.stderr.write("Error: output-dir must be a directory") + data = json.load(sys.stdin) + generate_files( + data["aliases"], + adest=args.output_dir / "draft-aliases", + vdest=args.output_dir / "draft-virtual", + postconfirm=args.postconfirm, + vdomain=args.vdomain, + adomains=args.adomain or ADOMAINS, + ) + From 965d97e83bf09d24a6d64b26806945e084243077 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 31 Jan 2024 16:11:59 -0400 Subject: [PATCH 07/31] chore: Remove unused cruft --- ietf/doc/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 6e11fc6843..da932639f3 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -1335,7 +1335,6 @@ def __iter__(self) -> Iterator[tuple[str, list[str]]]: inactive_recent_drafts = drafts.exclude(states__slug='active').filter(expires__gte=show_since) interesting_drafts = active_drafts | inactive_recent_drafts - alias_domains = ['ietf.org', ] for draft in interesting_drafts.distinct().iterator(): # Omit drafts that became RFCs, unless they were published in the last DEFAULT_YEARS if draft.get_state_slug() == "rfc": From d38086718ff6a9e3a92136e8d9eba4d660351434 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 31 Jan 2024 16:12:39 -0400 Subject: [PATCH 08/31] refactor: Avoid shadowing "draft" name --- ietf/doc/utils.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index da932639f3..a61a5e0754 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -1335,43 +1335,43 @@ def __iter__(self) -> Iterator[tuple[str, list[str]]]: inactive_recent_drafts = drafts.exclude(states__slug='active').filter(expires__gte=show_since) interesting_drafts = active_drafts | inactive_recent_drafts - for draft in interesting_drafts.distinct().iterator(): + for this_draft in interesting_drafts.distinct().iterator(): # Omit drafts that became RFCs, unless they were published in the last DEFAULT_YEARS - if draft.get_state_slug() == "rfc": - rfc = draft.became_rfc() + if this_draft.get_state_slug() == "rfc": + rfc = this_draft.became_rfc() log.assertion("rfc is not None") if rfc.latest_event(type='published_rfc').time < show_since: continue - alias = draft.name + alias = this_draft.name all = set() # no suffix and .authors are the same list - emails = self.get_draft_authors_emails(draft) + emails = self.get_draft_authors_emails(this_draft) all.update(emails) yield alias, list(emails) yield alias + ".authors", list(emails) # .chairs = group chairs - emails = self.get_draft_chair_emails(draft) + emails = self.get_draft_chair_emails(this_draft) if emails: all.update(emails) yield alias + ".chairs", list(emails) # .ad = sponsoring AD / WG AD (WG document) - emails = self.get_draft_ad_emails(draft) + emails = self.get_draft_ad_emails(this_draft) if emails: all.update(emails) yield alias + ".ad", list(emails) # .notify = notify email list from the Document - emails = self.get_draft_notify_emails(draft) + emails = self.get_draft_notify_emails(this_draft) if emails: all.update(emails) yield alias + ".notify", list(emails) # .shepherd = shepherd email from the Document - emails = self.get_draft_shepherd_email(draft) + emails = self.get_draft_shepherd_email(this_draft) if emails: all.update(emails) yield alias + ".shepherd", list(emails) From 37df13e08fefeb14dd5bc0110605bf12d41da973 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 31 Jan 2024 16:21:06 -0400 Subject: [PATCH 09/31] fix: Suppress empty lists from DraftAliasGenerator --- ietf/doc/utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index a61a5e0754..ad1c2af223 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -1349,8 +1349,9 @@ def __iter__(self) -> Iterator[tuple[str, list[str]]]: # no suffix and .authors are the same list emails = self.get_draft_authors_emails(this_draft) all.update(emails) - yield alias, list(emails) - yield alias + ".authors", list(emails) + if emails: + yield alias, list(emails) + yield alias + ".authors", list(emails) # .chairs = group chairs emails = self.get_draft_chair_emails(this_draft) @@ -1377,4 +1378,5 @@ def __iter__(self) -> Iterator[tuple[str, list[str]]]: yield alias + ".shepherd", list(emails) # .all = everything from above - yield alias + ".all", list(all) + if all: + yield alias + ".all", list(all) From e5197b9433eec15c32ad03f9679f3df7037abb25 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 31 Jan 2024 16:42:11 -0400 Subject: [PATCH 10/31] refactor: Use a GET instead of POST --- ietf/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/api/views.py b/ietf/api/views.py index d2f2153988..c40c2cb865 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -458,7 +458,7 @@ def directauth(request): @requires_api_token @csrf_exempt def draft_aliases(request): - if request.method == "POST": + if request.method == "GET": return JsonResponse( { "aliases": { From e30ed3d463e3b0bcade8718934a7113506f4fa46 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 11:16:41 -0400 Subject: [PATCH 11/31] feat: GroupAliasGenerator class --- ietf/group/utils.py | 71 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 92b9ac1bd6..eb86e6a40d 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -1,11 +1,12 @@ # Copyright The IETF Trust 2012-2023, All Rights Reserved # -*- coding: utf-8 -*- - +import datetime from pathlib import Path from django.db.models import Q from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe from django.urls import reverse as urlreverse @@ -353,3 +354,71 @@ def update_role_set(group, role_name, new_value, by): e.save() return added, removed + + +class GroupAliasGenerator: + days = 5 * 365 + active_states = ["active", "bof", "proposed"] + group_types = [ + "wg", + "rg", + "rag", + "dir", + "team", + "review", + "program", + "rfcedtyp", + "edappr", + "edwg", + ] # This should become groupfeature driven... + no_ad_group_types = ["rg", "rag", "team", "program", "rfcedtyp", "edappr", "edwg"] + + def __iter__(self): + show_since = timezone.now() - datetime.timedelta(days=self.days) + + # Loop through each group type and build -ads and -chairs entries + for g in self.group_types: + domains = ["ietf"] + if g in ("rg", "rag"): + domains += "irtf" + if g == "program": + domains += "iab" + + entries = Group.objects.filter(type=g).all() + active_entries = entries.filter(state__in=self.active_states) + inactive_recent_entries = entries.exclude( + state__in=self.active_states + ).filter(time__gte=show_since) + interesting_entries = active_entries | inactive_recent_entries + + for e in interesting_entries.distinct().iterator(): + name = e.acronym + + # Research groups, teams, and programs do not have -ads lists + if not g in self.no_ad_group_types: + yield name + "-ads", domains, get_group_ad_emails(e) + # All group types have -chairs lists + yield name + "-chairs", domains, get_group_role_emails( + e, ["chair", "secr"] + ) + + # The area lists include every chair in active working groups in the area + areas = Group.objects.filter(type="area").all() + active_areas = areas.filter(state__in=self.active_states) + for area in active_areas: + name = area.acronym + area_ad_emails = get_group_role_emails(area, ["pre-ad", "ad", "chair"]) + yield name + "-ads", "ietf", area_ad_emails + yield name + "-chairs", "ietf", get_child_group_role_emails( + area, ["chair", "secr"] + ) | area_ad_emails + + # Other groups with chairs that require Internet-Draft submission approval + gtypes = GroupTypeName.objects.values_list("slug", flat=True) + special_groups = Group.objects.filter( + type__features__req_subm_approval=True, acronym__in=gtypes, state="active" + ) + for group in special_groups: + yield group.acronym + "-chairs", "ietf", get_group_role_emails( + group, ["chair", "delegate"] + ) From cc1cb55829f655f993af16cecf19132915307e37 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 13:21:58 -0400 Subject: [PATCH 12/31] feat: group aliases API view --- ietf/api/urls.py | 3 +++ ietf/api/views.py | 22 +++++++++++++++++++++- ietf/group/utils.py | 16 ++++++++-------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/ietf/api/urls.py b/ietf/api/urls.py index bf79d3b566..107bd398d9 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -22,9 +22,12 @@ url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()), # # --- Custom API endpoints, sorted alphabetically --- + # Email alias information for drafts url(r'^doc/draft-aliases/$', api_views.draft_aliases), # GPRD: export of personal information for the logged-in person url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()), + # Email alias information for groups + url(r'^group/group-aliases/$', api_views.group_aliases), # Let IESG members set positions programmatically url(r'^iesg/position', views_ballot.api_set_position), # Let Meetecho set session video URLs diff --git a/ietf/api/views.py b/ietf/api/views.py index c40c2cb865..8cbee10097 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -34,6 +34,7 @@ from ietf.api.serializer import JsonExportMixin from ietf.api.ietf_utils import is_valid_token, requires_api_token from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents +from ietf.group.utils import GroupAliasGenerator from ietf.ietfauth.views import send_account_creation_email from ietf.ietfauth.utils import role_required from ietf.meeting.models import Meeting @@ -455,7 +456,7 @@ def directauth(request): return HttpResponse(status=405) -@requires_api_token +@requires_api_token("ietf.api.views.email_aliases") @csrf_exempt def draft_aliases(request): if request.method == "GET": @@ -467,3 +468,22 @@ def draft_aliases(request): } ) return HttpResponse(status=405) + + +@requires_api_token("ietf.api.views.email_aliases") +@csrf_exempt +def group_aliases(request): + if request.method == "GET": + return JsonResponse( + { + "aliases": [ + { + "alias": alias, + "domains": domains, + "addresses": address_list, + } + for alias, domains, address_list in GroupAliasGenerator() + ] + } + ) + return HttpResponse(status=405) diff --git a/ietf/group/utils.py b/ietf/group/utils.py index eb86e6a40d..fbd587f717 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -396,11 +396,11 @@ def __iter__(self): # Research groups, teams, and programs do not have -ads lists if not g in self.no_ad_group_types: - yield name + "-ads", domains, get_group_ad_emails(e) + yield name + "-ads", domains, list(get_group_ad_emails(e)) # All group types have -chairs lists - yield name + "-chairs", domains, get_group_role_emails( + yield name + "-chairs", domains, list(get_group_role_emails( e, ["chair", "secr"] - ) + )) # The area lists include every chair in active working groups in the area areas = Group.objects.filter(type="area").all() @@ -408,10 +408,10 @@ def __iter__(self): for area in active_areas: name = area.acronym area_ad_emails = get_group_role_emails(area, ["pre-ad", "ad", "chair"]) - yield name + "-ads", "ietf", area_ad_emails - yield name + "-chairs", "ietf", get_child_group_role_emails( + yield name + "-ads", "ietf", list(area_ad_emails) + yield name + "-chairs", "ietf", list(get_child_group_role_emails( area, ["chair", "secr"] - ) | area_ad_emails + ) | area_ad_emails) # Other groups with chairs that require Internet-Draft submission approval gtypes = GroupTypeName.objects.values_list("slug", flat=True) @@ -419,6 +419,6 @@ def __iter__(self): type__features__req_subm_approval=True, acronym__in=gtypes, state="active" ) for group in special_groups: - yield group.acronym + "-chairs", "ietf", get_group_role_emails( + yield group.acronym + "-chairs", "ietf", list(get_group_role_emails( group, ["chair", "delegate"] - ) + )) From f2993085719a5e5bd5ea35b8238e7f0c06be6c39 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 13:24:46 -0400 Subject: [PATCH 13/31] fix: Handle domains array correctly --- ietf/group/utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ietf/group/utils.py b/ietf/group/utils.py index fbd587f717..d1def7f85b 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -380,9 +380,9 @@ def __iter__(self): for g in self.group_types: domains = ["ietf"] if g in ("rg", "rag"): - domains += "irtf" + domains.append("irtf") if g == "program": - domains += "iab" + domains.append("iab") entries = Group.objects.filter(type=g).all() active_entries = entries.filter(state__in=self.active_states) @@ -408,8 +408,8 @@ def __iter__(self): for area in active_areas: name = area.acronym area_ad_emails = get_group_role_emails(area, ["pre-ad", "ad", "chair"]) - yield name + "-ads", "ietf", list(area_ad_emails) - yield name + "-chairs", "ietf", list(get_child_group_role_emails( + yield name + "-ads", ["ietf"], list(area_ad_emails) + yield name + "-chairs", ["ietf"], list(get_child_group_role_emails( area, ["chair", "secr"] ) | area_ad_emails) @@ -419,6 +419,6 @@ def __iter__(self): type__features__req_subm_approval=True, acronym__in=gtypes, state="active" ) for group in special_groups: - yield group.acronym + "-chairs", "ietf", list(get_group_role_emails( + yield group.acronym + "-chairs", ["ietf"], list(get_group_role_emails( group, ["chair", "delegate"] )) From 110ebf6cbd26c844e4fa4d046fd5dfa2f5381822 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 17:22:17 -0400 Subject: [PATCH 14/31] fix: Suppress empty group aliases --- ietf/group/utils.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/ietf/group/utils.py b/ietf/group/utils.py index d1def7f85b..357f108c04 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -398,9 +398,9 @@ def __iter__(self): if not g in self.no_ad_group_types: yield name + "-ads", domains, list(get_group_ad_emails(e)) # All group types have -chairs lists - yield name + "-chairs", domains, list(get_group_role_emails( - e, ["chair", "secr"] - )) + chair_emails = get_group_role_emails(e, ["chair", "secr"]) + if chair_emails: + yield name + "-chairs", domains, list(chair_emails) # The area lists include every chair in active working groups in the area areas = Group.objects.filter(type="area").all() @@ -408,10 +408,11 @@ def __iter__(self): for area in active_areas: name = area.acronym area_ad_emails = get_group_role_emails(area, ["pre-ad", "ad", "chair"]) - yield name + "-ads", ["ietf"], list(area_ad_emails) - yield name + "-chairs", ["ietf"], list(get_child_group_role_emails( - area, ["chair", "secr"] - ) | area_ad_emails) + if area_ad_emails: + yield name + "-ads", ["ietf"], list(area_ad_emails) + chair_emails = get_child_group_role_emails(area, ["chair", "secr"]) | area_ad_emails + if chair_emails: + yield name + "-chairs", ["ietf"], list(chair_emails) # Other groups with chairs that require Internet-Draft submission approval gtypes = GroupTypeName.objects.values_list("slug", flat=True) @@ -419,6 +420,6 @@ def __iter__(self): type__features__req_subm_approval=True, acronym__in=gtypes, state="active" ) for group in special_groups: - yield group.acronym + "-chairs", ["ietf"], list(get_group_role_emails( - group, ["chair", "delegate"] - )) + chair_emails = get_group_role_emails(group, ["chair", "delegate"]) + if chair_emails: + yield group.acronym + "-chairs", ["ietf"], list(chair_emails) From 06d73eb38b69fecfaf5497286855ac13b4c88986 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 17:27:30 -0400 Subject: [PATCH 15/31] refactor: Generalize aliases-from-json.py script --- ...ases-from-json.py => aliases-from-json.py} | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) rename ietf/bin/{draft-aliases-from-json.py => aliases-from-json.py} (74%) diff --git a/ietf/bin/draft-aliases-from-json.py b/ietf/bin/aliases-from-json.py similarity index 74% rename from ietf/bin/draft-aliases-from-json.py rename to ietf/bin/aliases-from-json.py index 49e1bfda6e..0a38034db1 100644 --- a/ietf/bin/draft-aliases-from-json.py +++ b/ietf/bin/aliases-from-json.py @@ -16,10 +16,16 @@ # Default options POSTCONFIRM_PATH = "/a/postconfirm/wrapper" VDOMAIN = "virtual.ietf.org" -ADOMAINS = ["ietf.org"] +# Map from domain label to dns domain +ADOMAINS = { + "ietf": "ietf.org", + "irtf": "irtf.org", + "iab": "iab.org", +} -def generate_files(records, adest, vdest, postconfirm, vdomain, adomains): + +def generate_files(records, adest, vdest, postconfirm, vdomain): """Generate files from an iterable of records If adest or vdest exists as a file, it will be overwritten. If it is a directory, files @@ -28,8 +34,8 @@ def generate_files(records, adest, vdest, postconfirm, vdomain, adomains): """ with TemporaryDirectory() as tmpdir: tmppath = Path(tmpdir) - apath = tmppath / "draft-aliases" - vpath = tmppath / "draft-virtual" + apath = tmppath / "aliases" + vpath = tmppath / "virtual" with apath.open("w") as afile, vpath.open("w") as vfile: date = datetime.datetime.now(datetime.timezone.utc) @@ -37,11 +43,14 @@ def generate_files(records, adest, vdest, postconfirm, vdomain, adomains): afile.write(signature) vfile.write(signature) - for alias, address_list in records.items(): + for item in records: + alias = item["alias"] + domains = item["domains"] + address_list = item["addresses"] filtername = f"xfilter-{alias}" afile.write(f'{filtername + ":":64s} "|{postconfirm} filter expand-{alias} {vdomain}"\n') - for domain in adomains: - vfile.write(f"{f'{alias}@{domain}':64s} {filtername}\n") + for dom in domains: + vfile.write(f"{f'{alias}@{ADOMAINS[dom]}':64s} {filtername}\n") vfile.write(f"{f'expand-{alias}@{vdomain}':64s} {', '.join(address_list)}\n") perms = stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH @@ -55,6 +64,11 @@ def generate_files(records, adest, vdest, postconfirm, vdomain, adomains): parser = argparse.ArgumentParser( description="Convert a JSON stream of draft alias definitions into alias / virtual alias files." ) + parser.add_argument( + "--prefix", + required=True, + help="Prefix for output files. Files will be named -aliases and -virtual." + ) parser.add_argument( "--output-dir", default="./", @@ -71,22 +85,15 @@ def generate_files(records, adest, vdest, postconfirm, vdomain, adomains): default=VDOMAIN, help=f"Virtual domain (defaults to {VDOMAIN}_", ) - parser.add_argument( - "--adomain", - action="append", - default=[], - help=f"Domains in which to create aliases (multiple allowed; if none are specified, defaults to {ADOMAINS})" - ) args = parser.parse_args() if not args.output_dir.is_dir(): sys.stderr.write("Error: output-dir must be a directory") data = json.load(sys.stdin) generate_files( data["aliases"], - adest=args.output_dir / "draft-aliases", - vdest=args.output_dir / "draft-virtual", + adest=args.output_dir / f"{args.prefix}-aliases", + vdest=args.output_dir / f"{args.prefix}-virtual", postconfirm=args.postconfirm, vdomain=args.vdomain, - adomains=args.adomain or ADOMAINS, ) From 28dcdf768c40c65f3745d36cf29ce6838fe25d07 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 17:43:45 -0400 Subject: [PATCH 16/31] refactor: Same output fmt for draft and group alias apis --- ietf/api/views.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/ietf/api/views.py b/ietf/api/views.py index 8cbee10097..4205aa3153 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -2,11 +2,9 @@ # -*- coding: utf-8 -*- import json -import pytz import re -from jwcrypto.jwk import JWK - +import pytz from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.decorators import login_required @@ -20,25 +18,23 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.gzip import gzip_page from django.views.generic.detail import DetailView - +from jwcrypto.jwk import JWK from tastypie.exceptions import BadRequest -from tastypie.utils.mime import determine_format, build_content_type -from tastypie.utils import is_valid_jsonp_callback_value from tastypie.serializers import Serializer - -import debug # pyflakes:ignore +from tastypie.utils import is_valid_jsonp_callback_value +from tastypie.utils.mime import determine_format, build_content_type import ietf -from ietf.person.models import Person, Email from ietf.api import _api_list -from ietf.api.serializer import JsonExportMixin from ietf.api.ietf_utils import is_valid_token, requires_api_token +from ietf.api.serializer import JsonExportMixin from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents from ietf.group.utils import GroupAliasGenerator -from ietf.ietfauth.views import send_account_creation_email from ietf.ietfauth.utils import role_required +from ietf.ietfauth.views import send_account_creation_email from ietf.meeting.models import Meeting from ietf.nomcom.models import Volunteer, NomCom +from ietf.person.models import Person, Email from ietf.stats.models import MeetingRegistration from ietf.utils import log from ietf.utils.decorators import require_api_key @@ -462,9 +458,14 @@ def draft_aliases(request): if request.method == "GET": return JsonResponse( { - "aliases": { - alias: address_list for alias, address_list in DraftAliasGenerator() - } + "aliases": [ + { + "alias": alias, + "domains": ["ietf"], + "addresses": address_list, + } + for alias, address_list in DraftAliasGenerator() + ] } ) return HttpResponse(status=405) From 2b08802bb34bf0339ba7f7e34d59d9e1dfa381ec Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 17:51:05 -0400 Subject: [PATCH 17/31] feat: Sort addresses for stability --- ietf/bin/aliases-from-json.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ietf/bin/aliases-from-json.py b/ietf/bin/aliases-from-json.py index 0a38034db1..e5978fa38c 100644 --- a/ietf/bin/aliases-from-json.py +++ b/ietf/bin/aliases-from-json.py @@ -51,7 +51,7 @@ def generate_files(records, adest, vdest, postconfirm, vdomain): afile.write(f'{filtername + ":":64s} "|{postconfirm} filter expand-{alias} {vdomain}"\n') for dom in domains: vfile.write(f"{f'{alias}@{ADOMAINS[dom]}':64s} {filtername}\n") - vfile.write(f"{f'expand-{alias}@{vdomain}':64s} {', '.join(address_list)}\n") + vfile.write(f"{f'expand-{alias}@{vdomain}':64s} {', '.join(sorted(address_list))}\n") perms = stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH apath.chmod(perms) @@ -76,8 +76,8 @@ def generate_files(records, adest, vdest, postconfirm, vdomain): help="Destination for output files.", ) parser.add_argument( - "--postconfirm", - default=POSTCONFIRM_PATH, + "--postconfirm", + default=POSTCONFIRM_PATH, help=f"Full path to postconfirm executable (defaults to {POSTCONFIRM_PATH}", ) parser.add_argument( @@ -90,10 +90,9 @@ def generate_files(records, adest, vdest, postconfirm, vdomain): sys.stderr.write("Error: output-dir must be a directory") data = json.load(sys.stdin) generate_files( - data["aliases"], + data["aliases"], adest=args.output_dir / f"{args.prefix}-aliases", vdest=args.output_dir / f"{args.prefix}-virtual", postconfirm=args.postconfirm, vdomain=args.vdomain, - ) - + ) From 24b929ac1de1cab57e45823c91eb4c353c63150f Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 1 Feb 2024 18:23:37 -0400 Subject: [PATCH 18/31] fix: Add "anything" virtual alias --- ietf/bin/aliases-from-json.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ietf/bin/aliases-from-json.py b/ietf/bin/aliases-from-json.py index e5978fa38c..72fcb469f7 100644 --- a/ietf/bin/aliases-from-json.py +++ b/ietf/bin/aliases-from-json.py @@ -42,6 +42,7 @@ def generate_files(records, adest, vdest, postconfirm, vdomain): signature = f"# Generated by {Path(__file__).absolute()} at {date}\n" afile.write(signature) vfile.write(signature) + vfile.write(f"{vdomain} anything\n") for item in records: alias = item["alias"] From 510fb32573829cf3ebf7ac68976538e0877e21c8 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 12:23:00 -0400 Subject: [PATCH 19/31] test: Test requires_api_token decorator --- ietf/api/tests.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 3d3e3ac121..6b118986bf 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -4,6 +4,7 @@ import datetime import json import html +import mock import os import sys @@ -12,7 +13,8 @@ from django.apps import apps from django.conf import settings -from django.test import Client +from django.http import HttpResponseForbidden +from django.test import Client, RequestFactory from django.test.utils import override_settings from django.urls import reverse as urlreverse from django.utils import timezone @@ -38,6 +40,8 @@ from ietf.utils.models import DumpInfo from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects +from .ietf_utils import requires_api_token + OMITTED_APPS = ( 'ietf.secr.meetings', 'ietf.secr.proceedings', @@ -1133,3 +1137,73 @@ def test_no_such_document(self): url = urlreverse(self.target_view, kwargs={'name': name}) r = self.client.get(url) self.assertEqual(r.status_code, 404) + + +class TokenTests(TestCase): + @mock.patch("ietf.api.ietf_utils.is_valid_token") + def test_requires_api_token(self, mock_is_valid_token): + called = False + + @requires_api_token + def fn_to_wrap(request, *args, **kwargs): + nonlocal called + called = True + return request, args, kwargs + + req_factory = RequestFactory() + arg = object() + kwarg = object() + + # No X-Api-Key header + mock_is_valid_token.return_value = False + val = fn_to_wrap( + req_factory.get("/some/url", headers={}), + arg, + kwarg=kwarg, + ) + self.assertTrue(isinstance(val, HttpResponseForbidden)) + self.assertFalse(mock_is_valid_token.called) + self.assertFalse(called) + + # Bad X-Api-Key header (not resetting the mock, it was not used yet) + val = fn_to_wrap( + req_factory.get("/some/url", headers={"X-Api-Key": "some-value"}), + arg, + kwarg=kwarg, + ) + self.assertTrue(isinstance(val, HttpResponseForbidden)) + self.assertTrue(mock_is_valid_token.called) + self.assertEqual( + mock_is_valid_token.call_args[0], + (fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"), + ) + self.assertFalse(called) + + # Valid header + mock_is_valid_token.reset_mock() + mock_is_valid_token.return_value = True + request = req_factory.get("/some/url", headers={"X-Api-Key": "some-value"}) + # Bad X-Api-Key header (not resetting the mock, it was not used yet) + val = fn_to_wrap( + request, + arg, + kwarg=kwarg, + ) + self.assertEqual(val, (request, (arg,), {"kwarg": kwarg})) + self.assertTrue(mock_is_valid_token.called) + self.assertEqual( + mock_is_valid_token.call_args[0], + (fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"), + ) + self.assertTrue(called) + + # Test the endpoint setting + @requires_api_token("endpoint") + def another_fn_to_wrap(request): + return "yep" + + val = another_fn_to_wrap(request) + self.assertEqual( + mock_is_valid_token.call_args[0], + ("endpoint", "some-value"), + ) From 3862a15afacf03661c5bf629ceb77326068800cf Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 12:41:09 -0400 Subject: [PATCH 20/31] feat: Harden is_valid_token against misconfig --- ietf/api/ietf_utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ietf/api/ietf_utils.py b/ietf/api/ietf_utils.py index 0d24d58479..50767a5afd 100644 --- a/ietf/api/ietf_utils.py +++ b/ietf/api/ietf_utils.py @@ -14,8 +14,13 @@ def is_valid_token(endpoint, token): # Settings implementation for now. if hasattr(settings, "APP_API_TOKENS"): token_store = settings.APP_API_TOKENS - if endpoint in token_store and token in token_store[endpoint]: - return True + if endpoint in token_store: + endpoint_tokens = token_store[endpoint] + # Be sure endpoints is a list or tuple so we don't accidentally use substring matching! + if not isinstance(endpoint_tokens, (list, tuple)): + endpoint_tokens = [endpoint_tokens] + if token in endpoint_tokens: + return True return False From 25bd4b12e228620ab271ebb7af8685e1e8480f86 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 12:42:01 -0400 Subject: [PATCH 21/31] test: Test is_valid_token --- ietf/api/tests.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 6b118986bf..10bd973f08 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -40,7 +40,7 @@ from ietf.utils.models import DumpInfo from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects -from .ietf_utils import requires_api_token +from .ietf_utils import is_valid_token, requires_api_token OMITTED_APPS = ( 'ietf.secr.meetings', @@ -1140,6 +1140,18 @@ def test_no_such_document(self): class TokenTests(TestCase): + @override_settings(APP_API_TOKENS={"known.endpoint": ["token in a list"], "oops": "token as a str"}) + def test_is_valid_token(self): + # various invalid cases + self.assertFalse(is_valid_token("unknown.endpoint", "token in a list")) + self.assertFalse(is_valid_token("known.endpoint", "token")) + self.assertFalse(is_valid_token("known.endpoint", "token as a str")) + self.assertFalse(is_valid_token("oops", "token")) + self.assertFalse(is_valid_token("oops", "token in a list")) + # the only valid cases + self.assertTrue(is_valid_token("known.endpoint", "token in a list")) + self.assertTrue(is_valid_token("oops", "token as a str")) + @mock.patch("ietf.api.ietf_utils.is_valid_token") def test_requires_api_token(self, mock_is_valid_token): called = False From bbaec7d0b59eef26e0b0caa29a1af68db0f3ed0b Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 13:04:18 -0400 Subject: [PATCH 22/31] test: Test draft_aliases view --- ietf/api/tests.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 10bd973f08..f7bd08726d 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -784,7 +784,40 @@ def test_api_get_session_matherials_no_agenda_meeting_url(self): url = urlreverse('ietf.meeting.views.api_get_session_materials', kwargs={'session_id': session.pk}) r = self.client.get(url) self.assertEqual(r.status_code, 200) - + + @override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]}) + @mock.patch("ietf.api.views.DraftAliasGenerator") + def test_draft_aliases(self, mock): + mock.return_value = (("alias1", ("a1", "a2")), ("alias2", ("a3", "a4"))) + url = urlreverse("ietf.api.views.draft_aliases") + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["Content-type"], "application/json") + self.assertEqual( + json.loads(r.content), + { + "aliases": [ + {"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]}, + {"alias": "alias2", "domains": ["ietf"], "addresses": ["a3", "a4"]}, + ]} + ) + # some invalid cases + self.assertEqual( + self.client.get(url, headers={}).status_code, + 403, + ) + self.assertEqual( + self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code, + 405, + ) class DirectAuthApiTests(TestCase): From cec54d5fbdf0020c669712958ca2c0cac788a24e Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 13:19:02 -0400 Subject: [PATCH 23/31] test: Test group_aliases view --- ietf/api/tests.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/ietf/api/tests.py b/ietf/api/tests.py index f7bd08726d..a495accc31 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -819,6 +819,40 @@ def test_draft_aliases(self, mock): 405, ) + @override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]}) + @mock.patch("ietf.api.views.GroupAliasGenerator") + def test_group_aliases(self, mock): + mock.return_value = (("alias1", ("ietf",), ("a1", "a2")), ("alias2", ("ietf", "iab"), ("a3", "a4"))) + url = urlreverse("ietf.api.views.group_aliases") + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["Content-type"], "application/json") + self.assertEqual( + json.loads(r.content), + { + "aliases": [ + {"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]}, + {"alias": "alias2", "domains": ["ietf", "iab"], "addresses": ["a3", "a4"]}, + ]} + ) + # some invalid cases + self.assertEqual( + self.client.get(url, headers={}).status_code, + 403, + ) + self.assertEqual( + self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code, + 405, + ) + class DirectAuthApiTests(TestCase): From 2d2353c7f86a9d8b7b76e35495203ea398e72407 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 14:46:27 -0400 Subject: [PATCH 24/31] test: Test DraftAliasGenerator --- ietf/doc/tests.py | 99 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 63953876aa..9c13c2a7ca 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -45,7 +45,7 @@ StatusChangeFactory, DocExtResourceFactory, RgDraftFactory, BcpFactory) from ietf.doc.forms import NotifyForm from ietf.doc.fields import SearchableDocumentsField -from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name +from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name, DraftAliasGenerator from ietf.group.models import Group, Role from ietf.group.factories import GroupFactory, RoleFactory from ietf.ipr.factories import HolderIprDisclosureFactory @@ -2291,6 +2291,7 @@ def testManagementCommand(self): "xfilter-" + doc3.name + ".ad", "xfilter-" + doc3.name + ".authors", "xfilter-" + doc3.name + ".chairs", + "xfilter-" + doc3.name + ".all", "xfilter-" + doc5.name, "xfilter-" + doc5.name + ".authors", "xfilter-" + doc5.name + ".all", @@ -2307,6 +2308,102 @@ def testManagementCommand(self): ]: self.assertNotIn(x, vcontent) + def test_generator_class(self): + """The DraftAliasGenerator should generate the same lists as the old mgmt cmd""" + a_month_ago = (timezone.now() - datetime.timedelta(30)).astimezone(RPC_TZINFO) + a_month_ago = a_month_ago.replace(hour=0, minute=0, second=0, microsecond=0) + ad = RoleFactory( + name_id="ad", group__type_id="area", group__state_id="active" + ).person + shepherd = PersonFactory() + author1 = PersonFactory() + author2 = PersonFactory() + author3 = PersonFactory() + author4 = PersonFactory() + author5 = PersonFactory() + author6 = PersonFactory() + mars = GroupFactory(type_id="wg", acronym="mars") + marschairman = PersonFactory(user__username="marschairman") + mars.role_set.create( + name_id="chair", person=marschairman, email=marschairman.email() + ) + doc1 = IndividualDraftFactory(authors=[author1], shepherd=shepherd.email(), ad=ad) + doc2 = WgDraftFactory( + name="draft-ietf-mars-test", group__acronym="mars", authors=[author2], ad=ad + ) + doc3 = WgDraftFactory.create( + name="draft-ietf-mars-finished", + group__acronym="mars", + authors=[author3], + ad=ad, + std_level_id="ps", + states=[("draft", "rfc"), ("draft-iesg", "pub")], + time=a_month_ago, + ) + rfc3 = WgRfcFactory() + DocEventFactory.create(doc=rfc3, type="published_rfc", time=a_month_ago) + doc3.relateddocument_set.create(relationship_id="became_rfc", target=rfc3) + doc4 = WgDraftFactory.create( + authors=[author4, author5], + ad=ad, + std_level_id="ps", + states=[("draft", "rfc"), ("draft-iesg", "pub")], + time=datetime.datetime(2010, 10, 10, tzinfo=ZoneInfo(settings.TIME_ZONE)), + ) + rfc4 = WgRfcFactory() + DocEventFactory.create( + doc=rfc4, + type="published_rfc", + time=datetime.datetime(2010, 10, 10, tzinfo=RPC_TZINFO), + ) + doc4.relateddocument_set.create(relationship_id="became_rfc", target=rfc4) + doc5 = IndividualDraftFactory(authors=[author6]) + + output = [(alias, alist) for alias, alist in DraftAliasGenerator()] + alias_dict = dict(output) + self.assertEqual(len(alias_dict), len(output)) # no duplicate aliases + expected_dict = { + doc1.name: [author1.email_address()], + doc1.name + ".ad": [ad.email_address()], + doc1.name + ".authors": [author1.email_address()], + doc1.name + ".shepherd": [shepherd.email_address()], + doc1.name + + ".all": [ + author1.email_address(), + ad.email_address(), + shepherd.email_address(), + ], + doc2.name: [author2.email_address()], + doc2.name + ".ad": [ad.email_address()], + doc2.name + ".authors": [author2.email_address()], + doc2.name + ".chairs": [marschairman.email_address()], + doc2.name + + ".all": [ + author2.email_address(), + ad.email_address(), + marschairman.email_address(), + ], + doc3.name: [author3.email_address()], + doc3.name + ".ad": [ad.email_address()], + doc3.name + ".authors": [author3.email_address()], + doc3.name + ".chairs": [marschairman.email_address()], + doc3.name + + ".all": [ + author3.email_address(), + ad.email_address(), + marschairman.email_address(), + ], + doc5.name: [author6.email_address()], + doc5.name + ".authors": [author6.email_address()], + doc5.name + ".all": [author6.email_address()], + } + # Sort lists for comparison + self.assertEqual( + {k: sorted(v) for k, v in alias_dict.items()}, + {k: sorted(v) for k, v in expected_dict.items()}, + ) + + class EmailAliasesTests(TestCase): def setUp(self): From 69be6a0511db2c2faa93fde8cac86f9cb244ac41 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 19:45:29 -0400 Subject: [PATCH 25/31] fix: ise group is type "ise" in test data --- ietf/utils/test_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 7123af5c81..c5d3472751 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -84,7 +84,7 @@ def make_immutable_base_data(): create_person(iab, "chair") create_person(iab, "member") - ise = create_group(name="Independent Submission Editor", acronym="ise", type_id="rfcedtyp") + ise = create_group(name="Independent Submission Editor", acronym="ise", type_id="ise") create_person(ise, "chair") rsoc = create_group(name="RFC Series Oversight Committee", acronym="rsoc", type_id="rfcedtyp") From c315f31648b4660c24e3f0f66f539bc3228f92c0 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 20:15:13 -0400 Subject: [PATCH 26/31] test: Fix logic in testManagementCommand The test was incorrect - and fails when fixed. :-( --- ietf/group/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/group/tests.py b/ietf/group/tests.py index b11ed8e5fb..13b0414081 100644 --- a/ietf/group/tests.py +++ b/ietf/group/tests.py @@ -220,7 +220,7 @@ def testManagementCommand(self): testrgchair.email_address(), testragchair.email_address(), ]])) - self.assertFalse(all([x in vcontent for x in [ + self.assertFalse(any([x in vcontent for x in [ done_ad.email_address(), wayoldchair.email_address(), individual.email_address(), From ede43c370fb6534e3511a9cd00718a166b2c9886 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 20:39:05 -0400 Subject: [PATCH 27/31] test: Test GroupAliasGenerator Test currently fails --- ietf/group/tests.py | 60 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/ietf/group/tests.py b/ietf/group/tests.py index 13b0414081..ffc67e01f4 100644 --- a/ietf/group/tests.py +++ b/ietf/group/tests.py @@ -20,7 +20,7 @@ from ietf.doc.factories import DocumentFactory, WgDraftFactory, EditorialDraftFactory from ietf.doc.models import DocEvent, RelatedDocument, Document from ietf.group.models import Role, Group -from ietf.group.utils import get_group_role_emails, get_child_group_role_emails, get_group_ad_emails +from ietf.group.utils import get_group_role_emails, get_child_group_role_emails, get_group_ad_emails, GroupAliasGenerator from ietf.group.factories import GroupFactory, RoleFactory from ietf.person.factories import PersonFactory, EmailFactory from ietf.person.models import Person @@ -248,6 +248,64 @@ def testManagementCommand(self): 'xfilter-' + wayold.acronym + '-chairs', ]])) + def test_generator_class(self): + """The GroupAliasGenerator should generate the same lists as the old mgmt cmd""" + # clean out test fixture group roles we don't need for this test + Role.objects.filter( + group__acronym__in=["farfut", "iab", "ietf", "irtf", "ise", "ops", "rsab", "rsoc", "sops"] + ).delete() + + a_month_ago = timezone.now() - datetime.timedelta(30) + a_decade_ago = timezone.now() - datetime.timedelta(3650) + role1 = RoleFactory(name_id='ad', group__type_id='area', group__acronym='myth', group__state_id='active') + area = role1.group + ad = role1.person + mars = GroupFactory(type_id='wg', acronym='mars', parent=area) + marschair = PersonFactory(user__username='marschair') + mars.role_set.create(name_id='chair', person=marschair, email=marschair.email()) + marssecr = PersonFactory(user__username='marssecr') + mars.role_set.create(name_id='secr', person=marssecr, email=marssecr.email()) + ames = GroupFactory(type_id='wg', acronym='ames', parent=area) + ameschair = PersonFactory(user__username='ameschair') + ames.role_set.create(name_id='chair', person=ameschair, email=ameschair.email()) + recent = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_month_ago) + recentchair = PersonFactory(user__username='recentchair') + recent.role_set.create(name_id='chair', person=recentchair, email=recentchair.email()) + wayold = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_decade_ago) + wayoldchair = PersonFactory(user__username='wayoldchair') + wayold.role_set.create(name_id='chair', person=wayoldchair, email=wayoldchair.email()) + # create a "done" group that should not be included anywhere + RoleFactory(name_id='ad', group__type_id='area', group__acronym='done', group__state_id='conclude') + irtf = Group.objects.get(acronym='irtf') + testrg = GroupFactory(type_id='rg', acronym='testrg', parent=irtf) + testrgchair = PersonFactory(user__username='testrgchair') + testrg.role_set.create(name_id='chair', person=testrgchair, email=testrgchair.email()) + testrag = GroupFactory(type_id='rg', acronym='testrag', parent=irtf) + testragchair = PersonFactory(user__username='testragchair') + testrag.role_set.create(name_id='chair', person=testragchair, email=testragchair.email()) + + output = [(alias, (domains, alist)) for alias, domains, alist in GroupAliasGenerator()] + alias_dict = dict(output) + self.maxDiff = None + self.assertEqual(len(alias_dict), len(output)) # no duplicate aliases + expected_dict = { + area.acronym + "-ads": (["ietf"], [ad.email_address()]), + area.acronym + "-chairs": (["ietf"], [ad.email_address(), marschair.email_address(), marssecr.email_address(), ameschair.email_address()]), + mars.acronym + "-ads": (["ietf"], [ad.email_address()]), + mars.acronym + "-chairs": (["ietf"], [marschair.email_address(), marssecr.email_address()]), + ames.acronym + "-ads": (["ietf"], [ad.email_address()]), + ames.acronym + "-chairs": (["ietf"], [ameschair.email_address()]), + recent.acronym + "-ads": (["ietf"], [ad.email_address()]), + recent.acronym + "-chairs": (["ietf"], [recentchair.email_address()]), + testrg.acronym + "-chairs": (["ietf", "irtf"], [testrgchair.email_address()]), + testrag.acronym + "-chairs": (["ietf", "irtf"], [testragchair.email_address()]), + } + # Sort lists for comparison + self.assertEqual( + {k: (sorted(doms), sorted(addrs)) for k, (doms, addrs) in alias_dict.items()}, + {k: (sorted(doms), sorted(addrs)) for k, (doms, addrs) in expected_dict.items()}, + ) + class GroupRoleEmailTests(TestCase): From 405d4e02363d46e9734849ff2f3f6048f36b6af8 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 20:39:34 -0400 Subject: [PATCH 28/31] fix: Suppress empty -ads alias --- ietf/group/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 357f108c04..36917d3124 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -396,7 +396,9 @@ def __iter__(self): # Research groups, teams, and programs do not have -ads lists if not g in self.no_ad_group_types: - yield name + "-ads", domains, list(get_group_ad_emails(e)) + ad_emails = get_group_ad_emails(e) + if ad_emails: + yield name + "-ads", domains, list(ad_emails) # All group types have -chairs lists chair_emails = get_group_role_emails(e, ["chair", "secr"]) if chair_emails: From aa1edd49c37e5a579397f96af780ffc6a32a27e2 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 21:04:39 -0400 Subject: [PATCH 29/31] test: Fix group acronym copy/paste error I *think* this must be what had been intended. The code does not look like it ever dealt with GroupHistory, so I'm pretty sure it wasn't meant to have the same acronym used by two different Groups at different times. --- ietf/group/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/group/tests.py b/ietf/group/tests.py index ffc67e01f4..66a854000d 100644 --- a/ietf/group/tests.py +++ b/ietf/group/tests.py @@ -163,7 +163,7 @@ def testManagementCommand(self): recent = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_month_ago) recentchair = PersonFactory(user__username='recentchair') recent.role_set.create(name_id='chair', person=recentchair, email=recentchair.email()) - wayold = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_decade_ago) + wayold = GroupFactory(type_id='wg', acronym='wayold', parent=area, state_id='conclude', time=a_decade_ago) wayoldchair = PersonFactory(user__username='wayoldchair') wayold.role_set.create(name_id='chair', person=wayoldchair, email=wayoldchair.email()) role2 = RoleFactory(name_id='ad', group__type_id='area', group__acronym='done', group__state_id='conclude') @@ -271,7 +271,7 @@ def test_generator_class(self): recent = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_month_ago) recentchair = PersonFactory(user__username='recentchair') recent.role_set.create(name_id='chair', person=recentchair, email=recentchair.email()) - wayold = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_decade_ago) + wayold = GroupFactory(type_id='wg', acronym='wayold', parent=area, state_id='conclude', time=a_decade_ago) wayoldchair = PersonFactory(user__username='wayoldchair') wayold.role_set.create(name_id='chair', person=wayoldchair, email=wayoldchair.email()) # create a "done" group that should not be included anywhere From 4a80e30cfa7a741b09a5fc61071b36c8c20d8eef Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 22:37:49 -0400 Subject: [PATCH 30/31] test: Check draft .notify alias generation --- ietf/doc/tests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 9c13c2a7ca..07fb0d9bb9 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -2308,6 +2308,7 @@ def testManagementCommand(self): ]: self.assertNotIn(x, vcontent) + @override_settings(TOOLS_SERVER="tools.example.org", DRAFT_ALIAS_DOMAIN="draft.example.org") def test_generator_class(self): """The DraftAliasGenerator should generate the same lists as the old mgmt cmd""" a_month_ago = (timezone.now() - datetime.timedelta(30)).astimezone(RPC_TZINFO) @@ -2331,6 +2332,8 @@ def test_generator_class(self): doc2 = WgDraftFactory( name="draft-ietf-mars-test", group__acronym="mars", authors=[author2], ad=ad ) + doc2.notify = f"{doc2.name}.ad@draft.example.org" + doc2.save() doc3 = WgDraftFactory.create( name="draft-ietf-mars-finished", group__acronym="mars", @@ -2377,6 +2380,7 @@ def test_generator_class(self): doc2.name + ".ad": [ad.email_address()], doc2.name + ".authors": [author2.email_address()], doc2.name + ".chairs": [marschairman.email_address()], + doc2.name + ".notify": [ad.email_address()], doc2.name + ".all": [ author2.email_address(), @@ -2403,6 +2407,11 @@ def test_generator_class(self): {k: sorted(v) for k, v in expected_dict.items()}, ) + @override_settings(TOOLS_SERVER="tools.example.org", DRAFT_ALIAS_DOMAIN="draft.example.org") + def test_get_draft_notify_emails(self): + generator = DraftAliasGenerator() + doc = DocumentFactory + generator.get_draft_notify_emails() class EmailAliasesTests(TestCase): From ceb52a1a5acad7a68435535bcd0fadd74fdc350a Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 6 Feb 2024 23:18:50 -0400 Subject: [PATCH 31/31] test: Cover get_draft_notify_emails() --- ietf/doc/tests.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 07fb0d9bb9..6d4902542f 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -2409,9 +2409,46 @@ def test_generator_class(self): @override_settings(TOOLS_SERVER="tools.example.org", DRAFT_ALIAS_DOMAIN="draft.example.org") def test_get_draft_notify_emails(self): + ad = PersonFactory() + shepherd = PersonFactory() + author = PersonFactory() + doc = DocumentFactory(authors=[author], shepherd=shepherd.email(), ad=ad) generator = DraftAliasGenerator() - doc = DocumentFactory - generator.get_draft_notify_emails() + + doc.notify = f"{doc.name}@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [author.email_address()]) + + doc.notify = f"{doc.name}.ad@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [ad.email_address()]) + + doc.notify = f"{doc.name}.shepherd@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [shepherd.email_address()]) + + doc.notify = f"{doc.name}.all@draft.example.org" + doc.save() + self.assertCountEqual( + generator.get_draft_notify_emails(doc), + [ad.email_address(), author.email_address(), shepherd.email_address()] + ) + + doc.notify = f"{doc.name}.notify@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), []) + + doc.notify = f"{doc.name}.ad@somewhere.example.com" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [f"{doc.name}.ad@somewhere.example.com"]) + + doc.notify = f"somebody@example.com, nobody@example.com, {doc.name}.ad@tools.example.org" + doc.save() + self.assertCountEqual( + generator.get_draft_notify_emails(doc), + ["somebody@example.com", "nobody@example.com", ad.email_address()] + ) + class EmailAliasesTests(TestCase):