diff --git a/src/Database/Database.php b/src/Database/Database.php index f9fad6808..102acecf3 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -377,6 +377,15 @@ class Database */ protected static array $filters = []; + /** + * Lazy-derived list of caller-facing meta keys (`$id`, `$updatedAt`, …) + * sourced from INTERNAL_ATTRIBUTES. Cached because rebuilding it on every + * updateDocument() call is wasted work — the constant doesn't change. + * + * @var list|null + */ + private static ?array $internalMetaKeys = null; + /** * @var array */ @@ -4840,11 +4849,13 @@ public function getDocument(string $collection, string $id, array $queries = [], $selections ); - try { - $cached = $this->cache->load($documentKey, self::TTL, $hashKey); - } catch (Exception $e) { - Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); - $cached = null; + $cached = null; + if (!$forUpdate) { + try { + $cached = $this->cache->load($documentKey, self::TTL, $hashKey); + } catch (Exception $e) { + Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); + } } if ($cached) { @@ -4917,7 +4928,7 @@ public function getDocument(string $collection, string $id, array $queries = [], ); // Don't save to cache if it's part of a relationship - if (empty($relationships)) { + if (!$forUpdate && empty($relationships)) { try { $this->cache->save($documentKey, $document->getArrayCopy(), $hashKey); $this->cache->save($collectionKey, 'empty', $documentKey); @@ -6138,8 +6149,16 @@ public function updateDocument(string $collection, string $id, Document $documen $collection = $this->silent(fn () => $this->getCollection($collection)); $newUpdatedAt = $document->getUpdatedAt(); - $document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt) { + $isNoop = false; + $silentNoop = false; + $document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt, &$isNoop, &$silentNoop) { $time = DateTime::now(); + // Reset on every attempt: withTransaction may retry on non-domain + // failures, and a previous attempt's flags must not bleed into a + // retry that ends up writing for real. + $isNoop = false; + $silentNoop = false; + $skipAdapterUpdate = false; $old = $this->authorization->skip(fn () => $this->silent( fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) )); @@ -6159,8 +6178,9 @@ public function updateDocument(string $collection, string $id, Document $documen $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); } $createdAt = $document->getCreatedAt(); + $rawInput = $document->getArrayCopy(); - $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); + $document = \array_merge($old->getArrayCopy(), $rawInput); $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; @@ -6181,6 +6201,8 @@ public function updateDocument(string $collection, string $id, Document $documen if ($collection->getId() !== self::METADATA) { $documentSecurity = $collection->getAttribute('documentSecurity', false); + $updatedAtChanged = false; + $floatAttributes = null; foreach ($relationships as $relationship) { $relationships[$relationship->getAttribute('key')] = $relationship; @@ -6272,14 +6294,94 @@ public function updateDocument(string $collection, string $id, Document $documen continue; } + if ($key === '$updatedAt') { + if ($value !== $old->getAttribute($key)) { + $updatedAtChanged = true; + } + + continue; + } + $oldValue = $old->getAttribute($key); if ($value !== $oldValue) { + // VAR_FLOAT: tolerate scalar type drift (e.g. JSON round-trip + // dropping trailing zeros so 5.0 comes back as int 5) by comparing + // as floats. Build the float-attribute map lazily — only on the + // first numeric-mismatch we encounter — so collections with no + // VAR_FLOAT attributes never pay the up-front scan cost. + if (\is_numeric($value) && \is_numeric($oldValue) && (float)$value === (float)$oldValue) { + if ($floatAttributes === null) { + $floatAttributes = []; + foreach ($attributes as $attribute) { + if ($attribute->getAttribute('type') === self::VAR_FLOAT) { + $floatAttributes[$attribute->getAttribute('key')] = true; + } + } + } + if (isset($floatAttributes[$key])) { + continue; + } + } + $shouldUpdate = true; break; } } + $onlyUpdatedAtChanged = !$shouldUpdate && $updatedAtChanged; + $updatedAtIsValid = false; + $inputIsBareUpdatedAt = false; + + if ($onlyUpdatedAtChanged) { + $updatedAt = $document->getAttribute('$updatedAt'); + $updatedAtIsValid = \is_null($updatedAt) || $updatedAt instanceof \DateTime; + + // Strict ISO-shape check: \DateTime() accepts "now", "yesterday", + // "+1 year", "@0" etc. — none of which a real cached timestamp + // would carry. Constrain to formatDb ("Y-m-d H:i:s.v") and + // formatTz ("Y-m-d\TH:i:s.vP") prefixes so the tolerance branch + // can't be triggered by relative or symbolic time expressions. + if (!$updatedAtIsValid && \is_string($updatedAt) && $updatedAt !== '') { + $updatedAtIsValid = \preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/', $updatedAt) === 1; + } + + // "Bare" = caller supplied no real attribute keys, only system + // metadata. A "real" key here must be both (a) not an internal + // meta key AND (b) actually defined on the collection schema — + // a junk/typo key like {garbageKey: null} must not be enough to + // flip the input to "non-bare", otherwise a read-only caller could + // unlock the stale-cache tolerance branch (and the event it fires) + // just by appending an unknown null-valued key. + // + // Single-pass isset lookups over $rawInput keys: prior versions + // used array_map + array_intersect + array_diff, which allocated + // two intermediate arrays and did O(N*M) value scans per call. + $schemaKeyLookup = []; + foreach ($attributes as $attr) { + $schemaKeyLookup[$attr->getAttribute('key')] = true; + } + $metaKeyLookup = \array_fill_keys(self::internalMetaKeys(), true); + $inputIsBareUpdatedAt = true; + foreach ($rawInput as $key => $_) { + if (isset($schemaKeyLookup[$key]) && !isset($metaKeyLookup[$key])) { + $inputIsBareUpdatedAt = false; + break; + } + } + + if (!$inputIsBareUpdatedAt && \is_null($updatedAt)) { + // Caller nulled $updatedAt while resubmitting otherwise unchanged + // fields → treat as a silent no-op (don't touch storage). + $skipAdapterUpdate = true; + $document = $old; + } else { + // Caller explicitly set $updatedAt (or supplied only that key) → + // honor as a real update — requires UPDATE perm. + $shouldUpdate = true; + } + } + $updatePermissions = [ ...$collection->getUpdate(), ...($documentSecurity ? $old->getUpdate() : []) @@ -6292,7 +6394,29 @@ public function updateDocument(string $collection, string $id, Document $documen if ($shouldUpdate) { if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $updatePermissions))) { - throw new AuthorizationException($this->authorization->getDescription()); + // Stale-cache tolerance: a caller without UPDATE perm who has READ + // perm and resubmitted a stale $updatedAt (alongside otherwise + // unchanged fields) likely didn't intend to write — treat as a + // silent no-op instead of an authorization error. Bare + // {$updatedAt: ...} is an explicit timestamp write and still + // requires UPDATE perm. + $canTolerateStaleCache = $onlyUpdatedAtChanged + && !$inputIsBareUpdatedAt + && $updatedAtIsValid + && $this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions)); + + if ($canTolerateStaleCache) { + $shouldUpdate = false; + $skipAdapterUpdate = true; + // Auth-rejected → tolerated: the caller lacked UPDATE, + // so don't emit EVENT_DOCUMENT_UPDATE downstream. Firing + // the event here would let a read-only caller probe doc + // existence and inject spurious audit-log entries. + $silentNoop = true; + $document = $old; + } else { + throw new AuthorizationException($this->authorization->getDescription()); + } } } else { if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { @@ -6301,16 +6425,31 @@ public function updateDocument(string $collection, string $id, Document $documen } } - if ($shouldUpdate) { - $document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt); - } - - // Check if document was updated after the request timestamp + // Optimistic-concurrency check runs before the no-op short-circuit: + // a caller that set $this->timestamp asserted "I last saw this doc + // at T; reject if mutated since." Letting a no-op silently return + // $old when storage was concurrently modified past T would violate + // that contract — main's pre-PR code ran this check on every call, + // including no-ops, because every call still hit the adapter. $oldUpdatedAt = new \DateTime($old->getUpdatedAt()); if (!is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { throw new ConflictException('Document was updated after the request timestamp'); } + if ($skipAdapterUpdate) { + // No-op: storage is unchanged, so re-running encode/structure-validation/ + // casting against $old (which is already decoded + relationships-populated + // by getDocument) would re-apply filters and corrupt the return shape. + // The caller expects the same shape as a real update would return; $old + // already matches that shape. + $isNoop = true; + return $old; + } + + if ($shouldUpdate) { + $document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt); + } + $document = $this->encode($collection, $document); if ($this->validate) { @@ -6365,6 +6504,20 @@ public function updateDocument(string $collection, string $id, Document $documen return $document; } + if ($isNoop) { + // $old was returned directly from getDocument(), which already populated + // relationships, decoded filters, and applied the custom document type. + // Re-running those would corrupt the shape; skip post-processing. The + // caller still invoked updateDocument(), so by default emit the event so + // audit listeners and change-stream consumers see the attempt — except + // on the stale-cache tolerance path, where the caller lacked UPDATE perm + // and the event would leak doc existence / forge audit entries. + if (!$silentNoop) { + $this->trigger(self::EVENT_DOCUMENT_UPDATE, $document); + } + return $document; + } + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth)); $document = $documents[0]; @@ -9460,6 +9613,14 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ]; } + /** + * @return list + */ + private static function internalMetaKeys(): array + { + return self::$internalMetaKeys ??= \array_column(self::INTERNAL_ATTRIBUTES, '$id'); + } + private static function computeCallableSignature(callable $callable): string { if (\is_string($callable)) { diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 23cc3b623..cb190159e 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -6146,14 +6146,14 @@ public function testSingleDocumentDateOperations(): void $originalCreatedAt4 = $doc4->getAttribute('$createdAt'); $originalUpdatedAt4 = $doc4->getAttribute('$updatedAt'); - sleep(1); // Ensure $updatedAt differs when adapter timestamp precision is seconds - $doc4->setAttribute('$updatedAt', null); $doc4->setAttribute('$createdAt', null); $updatedDoc4 = $database->updateDocument($collection, 'doc4', document: $doc4); + // No content changed and dates were nulled, so the update is a no-op + // and both timestamps are preserved. $this->assertEquals($originalCreatedAt4, $updatedDoc4->getAttribute('$createdAt')); - $this->assertNotEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt')); + $this->assertEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt')); // Test 5: Update only updatedAt $updatedDoc4->setAttribute('$updatedAt', $updateDate); diff --git a/tests/unit/ForUpdateCacheTest.php b/tests/unit/ForUpdateCacheTest.php new file mode 100644 index 000000000..fe3ff74ca --- /dev/null +++ b/tests/unit/ForUpdateCacheTest.php @@ -0,0 +1,606 @@ +setDatabase('utopiaTests') + ->setNamespace('for_update_' . uniqid()); + + $database->create(); + $database->createCollection('projects'); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'stale', + ])); + + $cached = $database->getDocument('projects', 'project'); + $this->assertSame('stale', $cached->getAttribute('name')); + + $collection = $database->getCollection('projects'); + $document = $adapter->getDocument($collection, 'project'); + $document->setAttribute('name', 'fresh'); + $adapter->updateDocument($collection, 'project', $document, true); + + $cached = $database->getDocument('projects', 'project'); + $this->assertSame('stale', $cached->getAttribute('name')); + + $fresh = $database->getDocument('projects', 'project', forUpdate: true); + $this->assertSame('fresh', $fresh->getAttribute('name')); + + // A locking read must not repopulate the cache, otherwise subsequent + // non-locking reads would see transactionally-scoped data. + $afterForUpdate = $database->getDocument('projects', 'project'); + $this->assertSame('stale', $afterForUpdate->getAttribute('name')); + } + + public function testNoopUpdateIgnoresStaleCachedUpdatedAt(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('stale_updated_at_' . uniqid()); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + $cached = $database->getDocument('projects', 'project'); + $this->assertSame('same', $cached->getAttribute('name')); + + $collection = $database->getCollection('projects'); + $stored = $adapter->getDocument($collection, 'project'); + $stored->setAttribute('$updatedAt', '2030-01-01T00:00:00.000+00:00'); + $adapter->updateDocument($collection, 'project', $stored, true); + + $stale = $database->getDocument('projects', 'project'); + $this->assertNotSame('2030-01-01T00:00:00.000+00:00', $stale->getUpdatedAt()); + + $database->setPreserveDates(true); + $updated = $database->updateDocument('projects', 'project', $stale); + $this->assertSame('2030-01-01T00:00:00.000+00:00', $updated->getUpdatedAt()); + + // The no-op must not mutate stored attribute values. + $afterUpdate = $adapter->getDocument($collection, 'project'); + $this->assertSame('same', $afterUpdate->getAttribute('name')); + $this->assertSame('2030-01-01T00:00:00.000+00:00', $afterUpdate->getUpdatedAt()); + } + + public function testBareUpdatedAtInputStillRequiresUpdatePermission(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('bare_updated_at_' . uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + // A read-only caller submitting *only* $updatedAt is an explicit timestamp + // write — the stale-cache tolerance must not apply, and UPDATE perm is required. + $database->setPreserveDates(true); + $this->expectException(AuthorizationException::class); + $database->updateDocument('projects', 'project', new Document([ + '$updatedAt' => '2030-01-01T00:00:00.000+00:00', + ])); + } + + public function testStaleCacheResubmitWithRealChangeStillRequiresUpdatePermission(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('stale_real_change_' . uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'original', + ])); + + $stale = $database->getDocument('projects', 'project'); + $stale->setAttribute('name', 'mutated'); + + // The tolerance branch must reject any input with a real attribute diff, + // even if the caller also has a stale $updatedAt. + $this->expectException(AuthorizationException::class); + $database->updateDocument('projects', 'project', $stale); + } + + public function testNumericallyEqualFloatDoesNotTriggerSpuriousUpdate(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('float_noop_' . uniqid()); + + $database->create(); + $database->createCollection('measurements', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('measurements', 'value', Database::VAR_FLOAT, 0, false); + $database->createDocument('measurements', new Document([ + '$id' => 'm1', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'value' => 5.0, + ])); + + // Simulate cache returning the float as an int (JSON round-trips drop trailing zeros). + $stale = $database->getDocument('measurements', 'm1'); + $stale->setAttribute('value', 5); + + // Read-only caller resubmits the doc; equal-as-float should be treated as a no-op + // instead of failing the update permission check. + $updated = $database->updateDocument('measurements', 'm1', $stale); + // Numerically equal is the contract this branch promises (5 == 5.0); the + // post-write storage type is intentionally adapter-defined and is + // re-coerced by casting() on subsequent reads. + $this->assertEquals(5.0, $updated->getAttribute('value')); + + // Storage holds a numerically equal value (intentional pre-existing + // drift on adapters without castingBefore); subsequent reads through + // the Database layer re-coerce via casting(). + $collection = $database->getCollection('measurements'); + $stored = $adapter->getDocument($collection, 'm1'); + $this->assertEquals(5.0, $stored->getAttribute('value')); + } + + public function testExplicitNullUpdatedAtStillUpdatesTimestamp(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('null_updated_at_' . uniqid()); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $created = $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + // DateTime::now() formats with millisecond precision (Y-m-d H:i:s.v), + // so the sleep must be well above 1ms to avoid same-bucket flakes on + // loaded CI runners. + \usleep(50_000); + + $database->setPreserveDates(true); + $updated = $database->updateDocument('projects', 'project', new Document([ + '$updatedAt' => null, + ])); + + $this->assertNotSame($created->getUpdatedAt(), $updated->getUpdatedAt()); + } + + public function testNonBareNullUpdatedAtIsSilentNoopForReadOnlyCaller(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('null_noop_readonly_' . uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + // Caller has READ but not UPDATE. Resubmits the full document with + // $updatedAt nulled and no actual attribute diff → must be a silent + // no-op (no AuthorizationException, no storage write, no audit event). + $stale = $database->getDocument('projects', 'project'); + $stale->setAttribute('$updatedAt', null); + + $result = $database->updateDocument('projects', 'project', $stale); + + $this->assertSame('same', $result->getAttribute('name')); + + $collection = $database->getCollection('projects'); + $stored = $adapter->getDocument($collection, 'project'); + $this->assertSame('same', $stored->getAttribute('name')); + } + + public function testUnparseableUpdatedAtRejectsReadOnlyCaller(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('unparseable_updated_at_' . uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + $stale = $database->getDocument('projects', 'project'); + $stale->setAttribute('$updatedAt', 'not-a-date'); + + // An unparseable $updatedAt is not a recognizable stale-cache value, so the + // tolerance branch must NOT apply; a caller without UPDATE perm must be + // rejected, not silently treated as a no-op. + $database->setPreserveDates(true); + $this->expectException(AuthorizationException::class); + $database->updateDocument('projects', 'project', $stale); + } + + public function testFloatNoopDoesNotAdvanceUpdatedAt(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('float_noop_storage_' . uniqid('', true)); + + $database->create(); + $database->createCollection('measurements', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + $database->createAttribute('measurements', 'value', Database::VAR_FLOAT, 0, false); + $created = $database->createDocument('measurements', new Document([ + '$id' => 'm1', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'value' => 5.0, + ])); + + // Even a 50ms sleep here would still pass the assertSame below if the + // no-op detection works, since no write should happen. The sleep proves + // the assertion is meaningful: if the adapter were called, $updatedAt + // would advance by ≥1ms and the assertion would fail. + \usleep(50_000); + + // 5 vs 5.0 is a float-drift no-op. Without the no-op detection, the + // adapter would still be called and $updatedAt would advance to now(). + $stale = $database->getDocument('measurements', 'm1'); + $stale->setAttribute('value', 5); + + $updated = $database->updateDocument('measurements', 'm1', $stale); + + // shouldUpdate stayed false because of the float-noop detection, so + // $updatedAt was not bumped — proves the diff loop treated 5 vs 5.0 as + // equal even though strict !== would say otherwise. + $this->assertSame($created->getUpdatedAt(), $updated->getUpdatedAt()); + $this->assertEquals(5.0, $updated->getAttribute('value')); + + // Subsequent reads through the Database layer re-coerce via casting() + // back to float, even if the adapter stores a numerically equal int. + $reread = $database->getDocument('measurements', 'm1', forUpdate: true); + $this->assertSame($created->getUpdatedAt(), $reread->getUpdatedAt()); + $this->assertIsFloat($reread->getAttribute('value')); + $this->assertSame(5.0, $reread->getAttribute('value')); + } + + public function testMetaOnlyInputStillRequiresUpdatePermission(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('meta_only_updated_at_' . \uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + // Caller submits only system meta keys ($id + $updatedAt) — no real attribute + // keys. This is an explicit timestamp write, not a stale-cache resubmit, so it + // must require UPDATE perm regardless of how many meta keys are echoed back. + // Without the meta-aware "bare" check, a strict array_keys === ['$updatedAt'] + // comparison would fail open for this shape. + $database->setPreserveDates(true); + $this->expectException(AuthorizationException::class); + $database->updateDocument('projects', 'project', new Document([ + '$id' => 'project', + '$updatedAt' => '2030-01-01T00:00:00.000+00:00', + ])); + } + + public function testRelativeTimestampStringStillRequiresUpdatePermission(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('relative_updated_at_' . \uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + $stale = $database->getDocument('projects', 'project'); + // \DateTime("now") and \DateTime("yesterday") both parse without throwing, + // but no real cached timestamp would carry these values. The tolerance branch + // must reject relative/symbolic time expressions via a strict ISO-shape check; + // otherwise an attacker who knows neither the real $updatedAt nor any prior + // doc state can engage tolerance just by submitting "now". + $stale->setAttribute('$updatedAt', 'now'); + + $database->setPreserveDates(true); + $this->expectException(AuthorizationException::class); + $database->updateDocument('projects', 'project', $stale); + } + + public function testStaleCacheToleranceDoesNotEmitUpdateEvent(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('tolerance_event_' . \uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + // Warm the cache with the original $updatedAt before poking the adapter, + // otherwise the next getDocument() reads straight from storage and there's + // no stale value for tolerance to engage on. + $database->getDocument('projects', 'project'); + + // Force a stale cached $updatedAt so the tolerance branch will engage. + $collection = $database->getCollection('projects'); + $stored = $adapter->getDocument($collection, 'project'); + $stored->setAttribute('$updatedAt', '2030-01-01T00:00:00.000+00:00'); + $adapter->updateDocument($collection, 'project', $stored, true); + + $stale = $database->getDocument('projects', 'project'); + + // Subscribe before triggering the no-op resubmit. + $eventHits = 0; + $database->on(Database::EVENT_DOCUMENT_UPDATE, 'tolerance-probe', function () use (&$eventHits) { + $eventHits++; + }); + + // Caller has READ but not UPDATE. Tolerance triggers a silent no-op. + // The event must NOT fire: it would let a read-only caller forge audit + // entries and probe document existence via downstream listeners. + $result = $database->updateDocument('projects', 'project', $stale); + + $this->assertSame('same', $result->getAttribute('name')); + $this->assertSame(0, $eventHits); + } + + public function testLegitimateNoopStillEmitsUpdateEvent(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('legit_noop_event_' . \uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + $stale = $database->getDocument('projects', 'project'); + $stale->setAttribute('$updatedAt', null); + + $eventHits = 0; + $database->on(Database::EVENT_DOCUMENT_UPDATE, 'legit-noop', function () use (&$eventHits) { + $eventHits++; + }); + + // Caller HAS UPDATE perm; the no-op (null-$updatedAt) path is a legitimate + // invocation, so the event must still fire for audit / change-stream consumers. + $database->updateDocument('projects', 'project', $stale); + $this->assertSame(1, $eventHits); + } + + public function testJunkKeyDoesNotUnlockStaleCacheTolerance(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('junk_key_' . \uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'same', + ])); + + // A read-only caller submitting only a stale $updatedAt + a junk null-valued + // key (not in the schema, not an internal meta key) must NOT engage the + // tolerance branch — junk keys can't count as "real attribute keys" or a + // caller could trivially unlock the tolerance path by appending any unknown + // null key. + $database->setPreserveDates(true); + $this->expectException(AuthorizationException::class); + $database->updateDocument('projects', 'project', new Document([ + '$updatedAt' => '2030-01-01T00:00:00.000+00:00', + 'garbageKey' => null, + ])); + } + + public function testNoopStillEnforcesRequestTimestampConflict(): void + { + $cache = new Cache(new CacheMemory()); + $adapter = new DatabaseMemory(); + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace('noop_conflict_' . \uniqid('', true)); + + $database->create(); + $database->createCollection('projects', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'same', + ])); + + // No-op resubmit (null $updatedAt + UPDATE perm + no real diff) under a + // request-timestamp older than storage's $updatedAt must still throw + // ConflictException — the short-circuit return must not silently + // succeed when the optimistic-concurrency contract is violated. + $stale = $database->getDocument('projects', 'project'); + $stale->setAttribute('$updatedAt', null); + + $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); + + $this->expectException(ConflictException::class); + $database->withRequestTimestamp($oneHourAgo, function () use ($database, $stale) { + return $database->updateDocument('projects', 'project', $stale); + }); + } +}