Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion ctfcli/core/properties/scalars.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,26 @@ def create_payload(self, ctx: PropertyContext) -> dict:

# parse validates the timezone is explicit and raises otherwise
parsed = self.parse(ctx.challenge.get("scheduled_at"), ctx.challenge.challenge_file_path)
return {"scheduled_at": parsed.isoformat() if parsed else None}

# Omit the key when there's nothing to schedule. CTFd passes the payload
# straight to the challenge model, so instances that predate scheduled_at
# (or challenge type plugins that do) fail with a 500 on an unknown key
if parsed is None:
return {}

return {"scheduled_at": parsed.isoformat()}

def sync_payload(self, ctx: PropertyContext) -> dict:
payload = self.create_payload(ctx)
if payload or self.ignored(ctx):
return payload

# Clearing a schedule requires an explicit null, which is only safe to send
# to a remote that reports the field, and therefore knows it
if ctx.remote_challenge is not None and "scheduled_at" in ctx.remote_challenge:
return {"scheduled_at": None}

return {}

def pull(self, ctx: PropertyContext, remote_data: dict):
return self.normalize(remote_data.get("scheduled_at"))
Expand Down
50 changes: 30 additions & 20 deletions tests/core/test_challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,6 @@ def test_updates_simple_properties(self, mock_api_constructor: MagicMock, *args,
"value": 150,
"state": "hidden",
"connection_info": "https://example.com",
"scheduled_at": None,
"max_attempts": 0,
}

Expand Down Expand Up @@ -597,7 +596,6 @@ def test_updates_attempts(self, mock_api_constructor: MagicMock, *args, **kwargs
"state": "hidden",
"max_attempts": 5,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -646,7 +644,6 @@ def test_updates_extra_properties(self, mock_api_constructor: MagicMock, *args,
"application_name": "application-name",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -704,7 +701,6 @@ def test_updates_flags(self, mock_api_constructor: MagicMock, *args, **kwargs):
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -794,7 +790,6 @@ def test_updates_topics(self, mock_api_constructor: MagicMock, *args, **kwargs):
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -856,7 +851,6 @@ def test_updates_tags(self, mock_api_constructor: MagicMock, *args, **kwargs):
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -921,7 +915,6 @@ def test_updates_files(self, mock_api_constructor: MagicMock, *args, **kwargs):
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

def mock_get(*args, **kwargs):
Expand Down Expand Up @@ -1077,7 +1070,6 @@ def test_updates_hints_with_requirements(self, mock_api_constructor: MagicMock,
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -1185,7 +1177,6 @@ def test_updates_requirements(self, mock_api_constructor: MagicMock, *args, **kw
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -1387,7 +1378,6 @@ def test_challenge_cannot_require_itself(
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

def mock_get(*args, **kwargs):
Expand Down Expand Up @@ -1449,7 +1439,6 @@ def test_defaults_to_standard_challenge_type(self, mock_api_constructor: MagicMo
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -1488,7 +1477,6 @@ def test_defaults_to_visible_state(self, mock_api_constructor: MagicMock, *args,
"value": 150,
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
# initial patch should set the state to hidden for the duration of the update
"state": "hidden",
}
Expand Down Expand Up @@ -1542,7 +1530,6 @@ def test_does_not_update_dynamic_value(self, mock_api_constructor: MagicMock, *a
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -1602,7 +1589,6 @@ def test_updates_multiple_attributes_at_once(self, mock_api_constructor: MagicMo
"state": "hidden",
"max_attempts": 5,
"connection_info": "https://example.com",
"scheduled_at": None,
}

mock_api: MagicMock = mock_api_constructor.return_value
Expand Down Expand Up @@ -1720,6 +1706,7 @@ def test_does_not_update_ignored_attributes(self):
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
# the remote reports scheduled_at, so an unset local value clears it
"scheduled_at": None,
}

Expand Down Expand Up @@ -1830,7 +1817,6 @@ def test_creates_standard_challenge(self, mock_api_constructor: MagicMock, *args
"max_attempts": 5,
"type": "standard",
"connection_info": "https://example.com",
"scheduled_at": None,
"extra_property": "extra_property_value",
"state": "hidden",
}
Expand Down Expand Up @@ -2035,7 +2021,6 @@ def test_does_not_set_ignored_attributes(self):
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
}

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

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

if p == "state":
challenge[p] = "new-value"
Expand Down Expand Up @@ -2731,7 +2715,6 @@ def test_sync_substitutes_media_in_description(self, mock_api_constructor: Magic
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
},
)

Expand Down Expand Up @@ -2873,6 +2856,9 @@ def _prop(self):
def _payload(self, challenge, ignore=()):
return self._prop().create_payload(PropertyContext(challenge, ignore=ignore))

def _sync_payload(self, challenge, remote_challenge=None, ignore=()):
return self._prop().sync_payload(PropertyContext(challenge, ignore=ignore, remote_challenge=remote_challenge))

def test_parse_accepts_timezone_aware_string(self):
parsed = self._prop().parse("2026-06-15T12:00:00+00:00")
self.assertEqual(parsed, datetime(2026, 6, 15, 12, 0, 0, tzinfo=timezone.utc))
Expand Down Expand Up @@ -2908,9 +2894,33 @@ def test_payload_includes_scheduled_at_iso(self):
# The explicit offset is preserved when sent to CTFd (CTFd normalizes server-side)
self.assertEqual(self._payload(challenge)["scheduled_at"], "2026-06-15T14:00:00+02:00")

def test_payload_scheduled_at_none_when_absent(self):
def test_payload_omits_scheduled_at_when_absent(self):
# CTFd passes the payload straight to the challenge model, so instances
# (or challenge type plugins) which don't know scheduled_at fail on the key
challenge = Challenge(self.minimal_challenge)
self.assertIsNone(self._payload(challenge)["scheduled_at"])
self.assertNotIn("scheduled_at", self._payload(challenge))

def test_sync_payload_includes_scheduled_at_iso(self):
challenge = Challenge(self.minimal_challenge, {"scheduled_at": "2026-06-15T14:00:00+02:00"})
remote = {"scheduled_at": None}
self.assertEqual(self._sync_payload(challenge, remote)["scheduled_at"], "2026-06-15T14:00:00+02:00")

def test_sync_payload_clears_scheduled_at_set_on_the_remote(self):
challenge = Challenge(self.minimal_challenge)
remote = {"scheduled_at": "2026-06-15T12:00:00"}
self.assertEqual(self._sync_payload(challenge, remote), {"scheduled_at": None})
self.assertEqual(self._sync_payload(challenge, {"scheduled_at": None}), {"scheduled_at": None})

def test_sync_payload_omits_scheduled_at_for_remotes_without_the_field(self):
# remotes which don't support scheduled_at don't return the key at all,
# and reject a payload carrying it
challenge = Challenge(self.minimal_challenge)
self.assertEqual(self._sync_payload(challenge, {}), {})

def test_sync_payload_omits_scheduled_at_when_ignored(self):
challenge = Challenge(self.minimal_challenge, {"scheduled_at": "2026-06-15T12:00:00+00:00"})
remote = {"scheduled_at": "2026-06-15T12:00:00"}
self.assertEqual(self._sync_payload(challenge, remote, ignore=("scheduled_at",)), {})

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