Skip to content

Commit a59463d

Browse files
authored
don't attach scheduled_at if not requested (#219)
1 parent 4babaac commit a59463d

2 files changed

Lines changed: 50 additions & 21 deletions

File tree

‎ctfcli/core/properties/scalars.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,26 @@ def create_payload(self, ctx: PropertyContext) -> dict:
260260

261261
# parse validates the timezone is explicit and raises otherwise
262262
parsed = self.parse(ctx.challenge.get("scheduled_at"), ctx.challenge.challenge_file_path)
263-
return {"scheduled_at": parsed.isoformat() if parsed else None}
263+
264+
# Omit the key when there's nothing to schedule. CTFd passes the payload
265+
# straight to the challenge model, so instances that predate scheduled_at
266+
# (or challenge type plugins that do) fail with a 500 on an unknown key
267+
if parsed is None:
268+
return {}
269+
270+
return {"scheduled_at": parsed.isoformat()}
271+
272+
def sync_payload(self, ctx: PropertyContext) -> dict:
273+
payload = self.create_payload(ctx)
274+
if payload or self.ignored(ctx):
275+
return payload
276+
277+
# Clearing a schedule requires an explicit null, which is only safe to send
278+
# to a remote that reports the field, and therefore knows it
279+
if ctx.remote_challenge is not None and "scheduled_at" in ctx.remote_challenge:
280+
return {"scheduled_at": None}
281+
282+
return {}
264283

265284
def pull(self, ctx: PropertyContext, remote_data: dict):
266285
return self.normalize(remote_data.get("scheduled_at"))

‎tests/core/test_challenge.py‎

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,6 @@ def test_updates_simple_properties(self, mock_api_constructor: MagicMock, *args,
550550
"value": 150,
551551
"state": "hidden",
552552
"connection_info": "https://example.com",
553-
"scheduled_at": None,
554553
"max_attempts": 0,
555554
}
556555

@@ -597,7 +596,6 @@ def test_updates_attempts(self, mock_api_constructor: MagicMock, *args, **kwargs
597596
"state": "hidden",
598597
"max_attempts": 5,
599598
"connection_info": None,
600-
"scheduled_at": None,
601599
}
602600

603601
mock_api: MagicMock = mock_api_constructor.return_value
@@ -646,7 +644,6 @@ def test_updates_extra_properties(self, mock_api_constructor: MagicMock, *args,
646644
"application_name": "application-name",
647645
"max_attempts": 0,
648646
"connection_info": None,
649-
"scheduled_at": None,
650647
}
651648

652649
mock_api: MagicMock = mock_api_constructor.return_value
@@ -704,7 +701,6 @@ def test_updates_flags(self, mock_api_constructor: MagicMock, *args, **kwargs):
704701
"state": "hidden",
705702
"max_attempts": 0,
706703
"connection_info": None,
707-
"scheduled_at": None,
708704
}
709705

710706
mock_api: MagicMock = mock_api_constructor.return_value
@@ -794,7 +790,6 @@ def test_updates_topics(self, mock_api_constructor: MagicMock, *args, **kwargs):
794790
"state": "hidden",
795791
"max_attempts": 0,
796792
"connection_info": None,
797-
"scheduled_at": None,
798793
}
799794

800795
mock_api: MagicMock = mock_api_constructor.return_value
@@ -856,7 +851,6 @@ def test_updates_tags(self, mock_api_constructor: MagicMock, *args, **kwargs):
856851
"state": "hidden",
857852
"max_attempts": 0,
858853
"connection_info": None,
859-
"scheduled_at": None,
860854
}
861855

862856
mock_api: MagicMock = mock_api_constructor.return_value
@@ -921,7 +915,6 @@ def test_updates_files(self, mock_api_constructor: MagicMock, *args, **kwargs):
921915
"state": "hidden",
922916
"max_attempts": 0,
923917
"connection_info": None,
924-
"scheduled_at": None,
925918
}
926919

927920
def mock_get(*args, **kwargs):
@@ -1077,7 +1070,6 @@ def test_updates_hints_with_requirements(self, mock_api_constructor: MagicMock,
10771070
"state": "hidden",
10781071
"max_attempts": 0,
10791072
"connection_info": None,
1080-
"scheduled_at": None,
10811073
}
10821074

10831075
mock_api: MagicMock = mock_api_constructor.return_value
@@ -1185,7 +1177,6 @@ def test_updates_requirements(self, mock_api_constructor: MagicMock, *args, **kw
11851177
"state": "hidden",
11861178
"max_attempts": 0,
11871179
"connection_info": None,
1188-
"scheduled_at": None,
11891180
}
11901181

11911182
mock_api: MagicMock = mock_api_constructor.return_value
@@ -1387,7 +1378,6 @@ def test_challenge_cannot_require_itself(
13871378
"state": "hidden",
13881379
"max_attempts": 0,
13891380
"connection_info": None,
1390-
"scheduled_at": None,
13911381
}
13921382

13931383
def mock_get(*args, **kwargs):
@@ -1449,7 +1439,6 @@ def test_defaults_to_standard_challenge_type(self, mock_api_constructor: MagicMo
14491439
"state": "hidden",
14501440
"max_attempts": 0,
14511441
"connection_info": None,
1452-
"scheduled_at": None,
14531442
}
14541443

14551444
mock_api: MagicMock = mock_api_constructor.return_value
@@ -1488,7 +1477,6 @@ def test_defaults_to_visible_state(self, mock_api_constructor: MagicMock, *args,
14881477
"value": 150,
14891478
"max_attempts": 0,
14901479
"connection_info": None,
1491-
"scheduled_at": None,
14921480
# initial patch should set the state to hidden for the duration of the update
14931481
"state": "hidden",
14941482
}
@@ -1542,7 +1530,6 @@ def test_does_not_update_dynamic_value(self, mock_api_constructor: MagicMock, *a
15421530
"state": "hidden",
15431531
"max_attempts": 0,
15441532
"connection_info": None,
1545-
"scheduled_at": None,
15461533
}
15471534

15481535
mock_api: MagicMock = mock_api_constructor.return_value
@@ -1602,7 +1589,6 @@ def test_updates_multiple_attributes_at_once(self, mock_api_constructor: MagicMo
16021589
"state": "hidden",
16031590
"max_attempts": 5,
16041591
"connection_info": "https://example.com",
1605-
"scheduled_at": None,
16061592
}
16071593

16081594
mock_api: MagicMock = mock_api_constructor.return_value
@@ -1720,6 +1706,7 @@ def test_does_not_update_ignored_attributes(self):
17201706
"state": "hidden",
17211707
"max_attempts": 0,
17221708
"connection_info": None,
1709+
# the remote reports scheduled_at, so an unset local value clears it
17231710
"scheduled_at": None,
17241711
}
17251712

@@ -1830,7 +1817,6 @@ def test_creates_standard_challenge(self, mock_api_constructor: MagicMock, *args
18301817
"max_attempts": 5,
18311818
"type": "standard",
18321819
"connection_info": "https://example.com",
1833-
"scheduled_at": None,
18341820
"extra_property": "extra_property_value",
18351821
"state": "hidden",
18361822
}
@@ -2035,7 +2021,6 @@ def test_does_not_set_ignored_attributes(self):
20352021
"state": "hidden",
20362022
"max_attempts": 0,
20372023
"connection_info": None,
2038-
"scheduled_at": None,
20392024
}
20402025

20412026
# add a property that should be defined but ignored
@@ -2067,7 +2052,6 @@ def test_does_not_set_ignored_attributes(self):
20672052

20682053
if p == "scheduled_at":
20692054
challenge["scheduled_at"] = "2026-06-15T12:00:00+00:00"
2070-
del expected_challenge_payload["scheduled_at"]
20712055

20722056
if p == "state":
20732057
challenge[p] = "new-value"
@@ -2731,7 +2715,6 @@ def test_sync_substitutes_media_in_description(self, mock_api_constructor: Magic
27312715
"state": "hidden",
27322716
"max_attempts": 0,
27332717
"connection_info": None,
2734-
"scheduled_at": None,
27352718
},
27362719
)
27372720

@@ -2873,6 +2856,9 @@ def _prop(self):
28732856
def _payload(self, challenge, ignore=()):
28742857
return self._prop().create_payload(PropertyContext(challenge, ignore=ignore))
28752858

2859+
def _sync_payload(self, challenge, remote_challenge=None, ignore=()):
2860+
return self._prop().sync_payload(PropertyContext(challenge, ignore=ignore, remote_challenge=remote_challenge))
2861+
28762862
def test_parse_accepts_timezone_aware_string(self):
28772863
parsed = self._prop().parse("2026-06-15T12:00:00+00:00")
28782864
self.assertEqual(parsed, datetime(2026, 6, 15, 12, 0, 0, tzinfo=timezone.utc))
@@ -2908,9 +2894,33 @@ def test_payload_includes_scheduled_at_iso(self):
29082894
# The explicit offset is preserved when sent to CTFd (CTFd normalizes server-side)
29092895
self.assertEqual(self._payload(challenge)["scheduled_at"], "2026-06-15T14:00:00+02:00")
29102896

2911-
def test_payload_scheduled_at_none_when_absent(self):
2897+
def test_payload_omits_scheduled_at_when_absent(self):
2898+
# CTFd passes the payload straight to the challenge model, so instances
2899+
# (or challenge type plugins) which don't know scheduled_at fail on the key
29122900
challenge = Challenge(self.minimal_challenge)
2913-
self.assertIsNone(self._payload(challenge)["scheduled_at"])
2901+
self.assertNotIn("scheduled_at", self._payload(challenge))
2902+
2903+
def test_sync_payload_includes_scheduled_at_iso(self):
2904+
challenge = Challenge(self.minimal_challenge, {"scheduled_at": "2026-06-15T14:00:00+02:00"})
2905+
remote = {"scheduled_at": None}
2906+
self.assertEqual(self._sync_payload(challenge, remote)["scheduled_at"], "2026-06-15T14:00:00+02:00")
2907+
2908+
def test_sync_payload_clears_scheduled_at_set_on_the_remote(self):
2909+
challenge = Challenge(self.minimal_challenge)
2910+
remote = {"scheduled_at": "2026-06-15T12:00:00"}
2911+
self.assertEqual(self._sync_payload(challenge, remote), {"scheduled_at": None})
2912+
self.assertEqual(self._sync_payload(challenge, {"scheduled_at": None}), {"scheduled_at": None})
2913+
2914+
def test_sync_payload_omits_scheduled_at_for_remotes_without_the_field(self):
2915+
# remotes which don't support scheduled_at don't return the key at all,
2916+
# and reject a payload carrying it
2917+
challenge = Challenge(self.minimal_challenge)
2918+
self.assertEqual(self._sync_payload(challenge, {}), {})
2919+
2920+
def test_sync_payload_omits_scheduled_at_when_ignored(self):
2921+
challenge = Challenge(self.minimal_challenge, {"scheduled_at": "2026-06-15T12:00:00+00:00"})
2922+
remote = {"scheduled_at": "2026-06-15T12:00:00"}
2923+
self.assertEqual(self._sync_payload(challenge, remote, ignore=("scheduled_at",)), {})
29142924

29152925
def test_payload_omits_scheduled_at_when_ignored(self):
29162926
challenge = Challenge(self.minimal_challenge, {"scheduled_at": "2026-06-15T12:00:00+00:00"})

0 commit comments

Comments
 (0)