diff --git a/src/msg/core/executor.py b/src/msg/core/executor.py index 6c417e68..7023104a 100644 --- a/src/msg/core/executor.py +++ b/src/msg/core/executor.py @@ -55,7 +55,8 @@ async def execute(self,request, *, entry='network'): capacity_writes={'identity.register','identity.temporary','identity.custodial_create','content.topic_create', 'content.post_create','content.post_edit','content.file_put','content.attach', 'content.template_put','discussion.reply','discussion.quote','discussion.repost', - 'transfer.part_put','git.create','git.push','git.receive','hosting.deploy','keystore.put'} + 'transfer.part_put','git.create','git.push','git.receive','hosting.deploy','keystore.put', + 'achievement.start'} if spec.name in capacity_writes: require(session.setting('runtime_config',{}).get('accept_writes',True),'writes_paused') for rid,generation in request.expected_generations: diff --git a/src/msg/plugins/achievements.py b/src/msg/plugins/achievements.py index 04a59d38..d567a92a 100644 --- a/src/msg/plugins/achievements.py +++ b/src/msg/plugins/achievements.py @@ -143,6 +143,18 @@ async def list_grants(ctx, request, tx): async def start(ctx, request, tx): subject = _owner(ctx) await tx.subject(subject) + grant = tx.one( + 'SELECT 1 FROM achievement_grants WHERE subject=? AND achievement_id=? AND spec_version=?', + (subject, I_AM_NOT_HUMAN.id, I_AM_NOT_HUMAN.version), + ) + require(grant is None, 'achievement_already_granted') + ceremonies = [loads(row[0]) for row in tx.rows( + 'SELECT body FROM achievement_ceremonies WHERE subject=?', (subject,))] + require(not any(item['status'] == 'active' and ctx.now < parse_time(item['expires_at']) + for item in ceremonies), 'achievement_ceremony_active') + # A subject can retain at most one ceremony. Reclaim failed, completed, and + # expired attempts before allocating a replacement challenge. + tx.execute('DELETE FROM achievement_ceremonies WHERE subject=?', (subject,), write=True) state = {'id': new_id('achc'), 'subject_id': subject, 'achievement_id': I_AM_NOT_HUMAN.id, 'spec_version': I_AM_NOT_HUMAN.version, 'status': 'active', 'round': 1, 'started_at': wire(ctx.now), 'expires_at': wire(ctx.now + timedelta(seconds=CEREMONY_TTL_SECONDS)), diff --git a/tests/test_achievements.py b/tests/test_achievements.py index d7dd183e..e120315e 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -90,6 +90,36 @@ async def test_wrong_nonce_and_round_ttl_fail_closed(installed): assert expired.data['reason'] == 'round_expired' +@pytest.mark.asyncio +async def test_start_keeps_only_one_ceremony_per_subject(installed): + app, _ = installed + key, subject, _ = await register(app, 'achievement-bounded') + started = await call(app, 'achievement.start', {}, key=key, subject=subject) + duplicate = await call(app, 'achievement.start', {}, key=key, subject=subject) + assert duplicate.status == 'error' and duplicate.error.code == 'achievement_ceremony_active' + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_ceremonies WHERE subject=?', (subject,))[0] == 1 + + app.executor.clock = lambda: NOW + timedelta(seconds=301) + replacement = await call(app, 'achievement.start', {}, key=key, subject=subject) + assert replacement.status == 'ok', wire(replacement) + assert replacement.data['challenge_id'] != started.data['challenge_id'] + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_ceremonies WHERE subject=?', (subject,))[0] == 1 + + +@pytest.mark.asyncio +async def test_start_obeys_capacity_pause(installed): + app, _ = installed + key, subject, _ = await register(app, 'achievement-paused') + async with app.metadata.transaction(write=True) as tx: + tx.set_setting('runtime_config', {'accept_writes': False, 'cleanup_enabled': True}) + blocked = await call(app, 'achievement.start', {}, key=key, subject=subject) + assert blocked.status == 'error' and blocked.error.code == 'writes_paused' + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_ceremonies WHERE subject=?', (subject,))[0] == 0 + + @pytest.mark.asyncio async def test_machine_payload_and_final_digest_are_required(installed): app, _ = installed