fix: reject fabricated observation timestamps (#20) - #25
Merged
Merged
Conversation
`Price::fromArray()` built the observation timestamp with
`DateTimeImmutable::createFromFormat()` and never called `getLastErrors()`.
PHP reports a repaired parse only through that method, so an impossible value
came back as a perfectly usable object:
"2026-13-45T99:99:99Z" -> 2027-02-18T04:40:39+00:00
"2026-02-30T00:00:00Z" -> 2026-03-02T00:00:00+00:00
The tolerant `new DateTimeImmutable($timestamp)` fallback was looser still and
raised nothing at all for relative expressions:
"now" -> <the moment the SDK ran>
"next friday" -> 2026-09-18T00:00:00+00:00
"0000-00-00" -> -0001-11-30T00:00:00+00:00
This is the same fabrication class as the zero price fixed in #18, and #18 did
not cover it: its guard only caught an outright parse failure. A fabricated
timestamp on a real price is the harder failure to catch, because the number
is right and only its position in time is invented - nobody eyeballs that the
way they eyeball a $0.00 Brent quote.
Timestamps are now matched against an explicit list of absolute formats, and a
parse counts only when `getLastErrors()` reports zero warnings and zero
errors. Anything else raises `ApiException` naming the offending value.
Two deliberate calls beyond the reported inputs:
- Naive timestamps ("2026-07-19 12:00:00") are read as UTC, not as the host's
default timezone, so the same payload does not describe a different instant
on a server in Chicago than on one in London.
- Leap seconds ("23:59:60") are rejected. PHP has no representation for one
and rolls it into the next minute; accepting that would be a silent
one-second shift, the same fabrication in miniature.
Genuine spellings keep working: 'Z' and lowercase 'z', +00:00, +0000 and
offset forms, fractional seconds, the space separator, and date-only values.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
#25 (timestamp rejection) and #26 (redirect refusal) landed while this was open. Both conflicts were co-located additions, not competing edits, and both sides survive: - src/Price.php: #25 added the private parseTimestamp() helper at the same offset this branch added optionalString(). Both methods are kept, and fromArray() calls both - the currency guard runs before the timestamp is parsed, then the label fields are read. Verified by direct probe: a fabricated timestamp, a relative timestamp, a leap second, an array currency, a missing currency, an array unit and a row carrying two defects at once are each rejected, while a well-formed row parses. - CHANGELOG.md: #25's timestamp bullet and this branch's label bullet are separate fixes; both stay under "### Fixed". Nothing from #26 conflicted. No assertion on either side was weakened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
#25 and #26 landed; their CHANGELOG bullets merge cleanly under the 3.0.0 heading this branch created. No conflict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #20.
The defect
src/Price.phpbuilt the observation timestamp withDateTimeImmutable::createFromFormat()and never calledgetLastErrors().PHP returns a valid, usable object for input it had to repair and reports the
repair only through that static method. The tolerant
new DateTimeImmutable($timestamp)fallback was looser still: it acceptsrelative expressions with no error at all.
Reproduced on
origin/main@21339d43e, PHP 8.5.8:This is the same fabrication class as the zero price #18 fixed, and #18 does
not cover it — its guard fires only on outright parse failure, which is the
one row in that table that already worked. A fabricated timestamp on a real
price is the harder failure to catch: the number is correct and only its
position in time is invented, so nobody eyeballs it the way they eyeball a
$0.00Brent quote.The fix
Timestamps are matched against an explicit list of absolute formats, each
anchored with
!so unspecified fields reset to the epoch rather than to"now", and a parse counts only when
getLastErrors()reports zero warningsand zero errors. Anything else raises
ApiExceptionnaming the value.Two deliberate calls beyond the reported inputs:
2026-07-19 12:00:00does not describe a different instant on a server inChicago than on one in London. Test pins this by setting
date_default_timezone_set('America/Chicago').23:59:60androlls it into the next minute. Accepting that is a silent one-second shift —
the same fabrication in miniature — so it raises instead.
Accepted spellings, all covered by tests:
Zand lowercasez,+00:00,+0000,-05:00,+05:30, fractional seconds (.123and.123456), naiveISO, the space separator with and without an offset, and date-only. Leap day
in a leap year is accepted; 29 February in a common year is not.
TDD evidence
Test written first, against unmodified
origin/mainsource.RED (
./vendor/bin/phpunit --filter FabricatedTimestampTest, source atorigin/main):Re-proved red-capable after the fix by
git checkout origin/main -- src/Price.php:Tests: 41, Assertions: 56, Failures: 21.— then restored.GREEN (this branch):
Full suite, PHP 8.5.8 / PHPUnit 13.3.3:
main@21339d43e:OK (108 tests, 478 assertions)OK (149 tests, 534 assertions)No pre-existing test changed.
Compatibility
Price::fromArray()throws for input it previously accepted. That method wentfrom total to partial in #18 already; this narrows the same boundary further.
The version bump and the written-out BC note are handled separately in the
versioning PR rather than bundled here.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo