Skip to content

Commit fc182c1

Browse files
committed
Merged in [18941] from jennifer@painless-security.com:
Add purge_old_personal_api_key_events management command. Fixes ietf-tools#3144. - Legacy-Id: 18950 Note: SVN reference [18941] has been migrated to Git commit 475fb37
2 parents 6e3460e + 475fb37 commit fc182c1

3 files changed

Lines changed: 199 additions & 1 deletion

File tree

ietf/person/factories.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44

55
import factory
6+
from factory.fuzzy import FuzzyChoice
67
import faker
78
import faker.config
89
import os
@@ -18,7 +19,7 @@
1819

1920
import debug # pyflakes:ignore
2021

21-
from ietf.person.models import Person, Alias, Email
22+
from ietf.person.models import Person, Alias, Email, PersonalApiKey, PersonApiKeyEvent, PERSON_API_KEY_ENDPOINTS
2223
from ietf.person.name import normalize_name, unidecode_name
2324

2425

@@ -144,3 +145,20 @@ class Meta:
144145
active = True
145146
primary = False
146147
origin = factory.LazyAttribute(lambda obj: obj.person.user.username if obj.person.user else '')
148+
149+
150+
class PersonalApiKeyFactory(factory.DjangoModelFactory):
151+
person = factory.SubFactory(PersonFactory)
152+
endpoint = FuzzyChoice(PERSON_API_KEY_ENDPOINTS)
153+
154+
class Meta:
155+
model = PersonalApiKey
156+
157+
class PersonApiKeyEventFactory(factory.DjangoModelFactory):
158+
key = factory.SubFactory(PersonalApiKeyFactory)
159+
person = factory.LazyAttribute(lambda o: o.key.person)
160+
type = 'apikey_login'
161+
desc = factory.Faker('sentence', nb_words=6)
162+
163+
class Meta:
164+
model = PersonApiKeyEvent
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright The IETF Trust 2021, All Rights Reserved
2+
# -*- coding: utf-8 -*-
3+
4+
from datetime import datetime, timedelta
5+
from django.core.management.base import BaseCommand, CommandError
6+
from django.db.models import Max, Min
7+
8+
from ietf.person.models import PersonApiKeyEvent
9+
10+
11+
class Command(BaseCommand):
12+
help = 'Purge PersonApiKeyEvent instances older than KEEP_DAYS days'
13+
14+
def add_arguments(self, parser):
15+
parser.add_argument('keep_days', type=int,
16+
help='Delete events older than this many days')
17+
parser.add_argument('-n', '--dry-run', action='store_true', default=False,
18+
help="Don't delete events, just show what would be done")
19+
20+
def handle(self, *args, **options):
21+
keep_days = options['keep_days']
22+
dry_run = options['dry_run']
23+
24+
def _format_count(count, unit='day'):
25+
return '{} {}{}'.format(count, unit, ('' if count == 1 else 's'))
26+
27+
if keep_days < 0:
28+
raise CommandError('Negative keep_days not allowed ({} was specified)'.format(keep_days))
29+
30+
if dry_run:
31+
self.stdout.write('Dry run requested, records will not be deleted\n')
32+
33+
self.stdout.write('Finding events older than {}\n'.format(_format_count(keep_days)))
34+
self.stdout.flush()
35+
36+
now = datetime.now()
37+
old_events = PersonApiKeyEvent.objects.filter(
38+
time__lt=now - timedelta(days=keep_days)
39+
)
40+
41+
stats = old_events.aggregate(Min('time'), Max('time'))
42+
old_count = old_events.count()
43+
if old_count == 0:
44+
self.stdout.write('No events older than {} found\n'.format(_format_count(keep_days)))
45+
return
46+
47+
oldest_date = stats['time__min']
48+
oldest_ago = now - oldest_date
49+
newest_date = stats['time__max']
50+
newest_ago = now - newest_date
51+
52+
action_fmt = 'Would delete {}\n' if dry_run else 'Deleting {}\n'
53+
self.stdout.write(action_fmt.format(_format_count(old_count, 'event')))
54+
self.stdout.write(' Oldest at {} ({} ago)\n'.format(oldest_date, _format_count(oldest_ago.days)))
55+
self.stdout.write(' Most recent at {} ({} ago)\n'.format(newest_date, _format_count(newest_ago.days)))
56+
self.stdout.flush()
57+
58+
if not dry_run:
59+
old_events.delete()
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Copyright The IETF Trust 2021, All Rights Reserved
2+
# -*- coding: utf-8 -*-
3+
4+
import datetime
5+
from io import StringIO
6+
7+
from django.core.management import call_command, CommandError
8+
9+
from ietf.person.factories import PersonApiKeyEventFactory
10+
from ietf.person.models import PersonApiKeyEvent, PersonEvent
11+
from ietf.utils.test_utils import TestCase
12+
13+
14+
class CommandTests(TestCase):
15+
@staticmethod
16+
def _call_command(command_name, *args, **options):
17+
out = StringIO()
18+
options['stdout'] = out
19+
call_command(command_name, *args, **options)
20+
return out.getvalue()
21+
22+
def _assert_purge_results(self, cmd_output, expected_delete_count, expected_kept_events):
23+
self.assertNotIn('Dry run requested', cmd_output)
24+
if expected_delete_count == 0:
25+
delete_text = 'No events older than'
26+
else:
27+
delete_text = 'Deleting {} event'.format(expected_delete_count)
28+
self.assertIn(delete_text, cmd_output)
29+
self.assertCountEqual(
30+
PersonApiKeyEvent.objects.all(),
31+
expected_kept_events,
32+
'Wrong events were deleted'
33+
)
34+
35+
def _assert_purge_dry_run_results(self, cmd_output, expected_delete_count, expected_kept_events):
36+
self.assertIn('Dry run requested', cmd_output)
37+
if expected_delete_count == 0:
38+
delete_text = 'No events older than'
39+
else:
40+
delete_text = 'Would delete {} event'.format(expected_delete_count)
41+
self.assertIn(delete_text, cmd_output)
42+
self.assertCountEqual(
43+
PersonApiKeyEvent.objects.all(),
44+
expected_kept_events,
45+
'Events were deleted when dry-run option was used'
46+
)
47+
48+
def test_purge_old_personal_api_key_events(self):
49+
keep_days = 10
50+
51+
# Remember how many PersonEvents were present so we can verify they're cleaned up properly.
52+
personevents_before = PersonEvent.objects.count()
53+
54+
now = datetime.datetime.now()
55+
# The first of these events will be timestamped a fraction of a second more than keep_days
56+
# days ago by the time we call the management command, so will just barely chosen for purge.
57+
old_events = [
58+
PersonApiKeyEventFactory(time=now - datetime.timedelta(days=n))
59+
for n in range(keep_days, 2 * keep_days + 1)
60+
]
61+
num_old_events = len(old_events)
62+
63+
recent_events = [
64+
PersonApiKeyEventFactory(time=now - datetime.timedelta(days=n))
65+
for n in range(0, keep_days)
66+
]
67+
# We did not create recent_event timestamped exactly keep_days ago because it would
68+
# be treated as an old_event by the management command. Create an event a few seconds
69+
# on the "recent" side of keep_days old to test the threshold.
70+
recent_events.append(
71+
PersonApiKeyEventFactory(
72+
time=now + datetime.timedelta(seconds=3) - datetime.timedelta(days=keep_days)
73+
)
74+
)
75+
num_recent_events = len(recent_events)
76+
77+
# call with dry run
78+
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '--dry-run')
79+
self._assert_purge_dry_run_results(output, num_old_events, old_events + recent_events)
80+
81+
# call for real
82+
output = self._call_command('purge_old_personal_api_key_events', str(keep_days))
83+
self._assert_purge_results(output, num_old_events, recent_events)
84+
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
85+
'PersonEvents were not cleaned up properly')
86+
87+
# repeat - there should be nothing left to delete
88+
output = self._call_command('purge_old_personal_api_key_events', '--dry-run', str(keep_days))
89+
self._assert_purge_dry_run_results(output, 0, recent_events)
90+
91+
output = self._call_command('purge_old_personal_api_key_events', str(keep_days))
92+
self._assert_purge_results(output, 0, recent_events)
93+
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
94+
'PersonEvents were not cleaned up properly')
95+
96+
# and now delete the remaining events
97+
output = self._call_command('purge_old_personal_api_key_events', '0')
98+
self._assert_purge_results(output, num_recent_events, [])
99+
self.assertEqual(PersonEvent.objects.count(), personevents_before,
100+
'PersonEvents were not cleaned up properly')
101+
102+
def test_purge_old_personal_api_key_events_rejects_invalid_arguments(self):
103+
"""The purge_old_personal_api_key_events command should reject invalid arguments"""
104+
event = PersonApiKeyEventFactory(time=datetime.datetime.now() - datetime.timedelta(days=30))
105+
106+
with self.assertRaises(CommandError):
107+
self._call_command('purge_old_personal_api_key_events')
108+
109+
with self.assertRaises(CommandError):
110+
self._call_command('purge_old_personal_api_key_events', '-15')
111+
112+
with self.assertRaises(CommandError):
113+
self._call_command('purge_old_personal_api_key_events', '15.3')
114+
115+
with self.assertRaises(CommandError):
116+
self._call_command('purge_old_personal_api_key_events', '15', '15')
117+
118+
with self.assertRaises(CommandError):
119+
self._call_command('purge_old_personal_api_key_events', 'abc', '15')
120+
121+
self.assertCountEqual(PersonApiKeyEvent.objects.all(), [event])

0 commit comments

Comments
 (0)