Skip to content

fix: reject fabricated observation timestamps (#20) - #25

Merged
karlwaldman merged 1 commit into
mainfrom
fix/fabricated-timestamps
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
fix/fabricated-timestamps

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #20.

The defect

src/Price.php built the observation timestamp with
DateTimeImmutable::createFromFormat() and never called getLastErrors().
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 accepts
relative expressions with no error at all.

Reproduced on origin/main @ 21339d43e, PHP 8.5.8:

2026-13-45T99:99:99Z     -> 2027-02-18T04:40:39+00:00
next friday              -> 2026-09-18T00:00:00+00:00
now                      -> 2026-09-13T16:49:40+00:00
0000-00-00               -> -0001-11-30T00:00:00+00:00
tomorrow                 -> 2026-09-14T00:00:00+00:00
2026-02-30T00:00:00Z     -> 2026-03-02T00:00:00+00:00
+1 week                  -> 2026-09-20T16:49:40+00:00
not-a-date               -> REJECTED (the only case the old guard caught)

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.00 Brent 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 warnings
and zero errors
. Anything else raises ApiException naming the value.

Two deliberate calls beyond the reported inputs:

  • Naive timestamps are read as UTC, not as the host's default timezone, so
    2026-07-19 12:00:00 does not describe a different instant on a server in
    Chicago than on one in London. Test pins this by setting
    date_default_timezone_set('America/Chicago').
  • Leap seconds are rejected. PHP has no representation for 23:59:60 and
    rolls 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: Z and lowercase z, +00:00,
+0000, -05:00, +05:30, fractional seconds (.123 and .123456), naive
ISO, 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/main source.

RED (./vendor/bin/phpunit --filter FabricatedTimestampTest, source at
origin/main):

FFFFFFFFFFFFFFF.FF....F..............F.FF                         41 / 41 (100%)

There were 21 failures:

1) FabricatedTimestampTest::testFabricatedTimestampIsRejectedRatherThanInvented@impossible date and time with data ('2026-13-45T99:99:99Z')
Timestamp '2026-13-45T99:99:99Z' was fabricated into '2027-02-18T04:40:39+00:00' instead of being rejected.

7) ...@relative next friday with data ('next friday')
Timestamp 'next friday' was fabricated into '2026-09-18T00:00:00+00:00' instead of being rejected.

8) ...@relative now with data ('now')
Timestamp 'now' was fabricated into '2026-09-13T16:53:27+00:00' instead of being rejected.

19) FabricatedTimestampTest::testNaiveTimestampDoesNotDependOnTheHostTimezone
Failed asserting that two strings are identical.
-'2026-07-19T12:00:00+00:00'
+'2026-07-19T12:00:00-05:00'

21) FabricatedTimestampTest::testClientRefusesARowWithAFabricatedTimestamp
Failed asserting that exception of type "OilPriceAPI\Exception\ApiException" is thrown.

FAILURES!
Tests: 41, Assertions: 56, Failures: 21.

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):

.........................................                         41 / 41 (100%)
OK (41 tests, 56 assertions)

Full suite, PHP 8.5.8 / PHPUnit 13.3.3:

  • baseline on clean main @ 21339d43e: OK (108 tests, 478 assertions)
  • this branch: OK (149 tests, 534 assertions)

No pre-existing test changed.

Compatibility

Price::fromArray() throws for input it previously accepted. That method went
from 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

`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
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: bd606846-e087-468a-8879-27d92756f3c8


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@karlwaldman
karlwaldman merged commit 395415c into main Sep 13, 2026
8 checks passed
@karlwaldman
karlwaldman deleted the fix/fabricated-timestamps branch September 13, 2026 17:08
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1] Price::fromArray fabricates timestamps: 2026-13-45T99:99:99Z becomes 2027-02-18

1 participant