Merge pull request #11193 from premtsd-code/fix-10612-validate-relationship-document-id

This commit is contained in:
Jake Barnby 2026-01-29 04:56:47 +00:00 committed by GitHub
commit b1ca73a041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 300 additions and 0 deletions

View file

@ -6,6 +6,7 @@ use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction;
use Appwrite\Utopia\Database\Validator\CustomId;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
@ -250,6 +251,35 @@ abstract class Action extends DatabasesAction
return $document;
}
/**
* Validate relationship values.
* Handles Document objects, ID strings, and associative arrays.
*/
protected function validateRelationship(mixed $relation): void
{
$relationId = null;
if ($relation instanceof Document) {
$relationId = $relation->getId();
} elseif (\is_string($relation)) {
$relationId = $relation;
} elseif (\is_array($relation) && !\array_is_list($relation)) {
$relationId = $relation['$id'] ?? null;
} else {
throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, 'Relationship value must be an object, document ID string, or associative array');
}
if ($relationId !== null) {
if (!\is_string($relationId)) {
throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, 'Relationship $id must be a string');
}
$validator = new CustomId();
if (!$validator->isValid($relationId)) {
throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, $validator->getDescription());
}
}
}
/**
* Resolves relationships in a document and attaches metadata.
*/

View file

@ -319,6 +319,9 @@ class Create extends Action
$relation['$id'] = ID::unique();
$relation = new Document($relation);
}
$this->validateRelationship($relation);
if ($relation instanceof Document) {
$relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser);

View file

@ -210,6 +210,9 @@ class Update extends Action
$relation['$id'] = ID::unique();
$relation = new Document($relation);
}
$this->validateRelationship($relation);
if ($relation instanceof Document) {
$relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser);

View file

@ -220,6 +220,9 @@ class Upsert extends Action
$relation['$id'] = ID::unique();
$relation = new Document($relation);
}
$this->validateRelationship($relation);
if ($relation instanceof Document) {
$relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser);

View file

@ -7626,6 +7626,267 @@ trait DatabasesBase
$this->assertEquals(200, $update['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testInvalidRelationshipDocumentId(array $data): void
{
$databaseId = $data['databaseId'];
// Create parent table
$parentTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'ParentTable',
]);
$this->assertEquals(201, $parentTable['headers']['status-code']);
$parentTableId = $parentTable['body']['$id'];
// Create child table
$childTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'ChildTable',
]);
$this->assertEquals(201, $childTable['headers']['status-code']);
$childTableId = $childTable['body']['$id'];
// Add string column to parent
$this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'name',
'size' => 255,
'required' => false,
]);
// Add string column to child
$this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $childTableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'title',
'size' => 255,
'required' => false,
]);
// Create one-to-many relationship
$relationship = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/columns/relationship', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'relatedTableId' => $childTableId,
'type' => Database::RELATION_ONE_TO_MANY,
'twoWay' => false,
'key' => 'children',
]);
$this->assertEquals(202, $relationship['headers']['status-code']);
// Wait for relationship column to be available
$this->assertEventually(function () use ($databaseId, $parentTableId) {
$columns = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/columns', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$columnKeys = array_column($columns['body']['columns'], 'key');
$this->assertContains('children', $columnKeys, "Relationship column 'children' not found in table {$parentTableId} of database {$databaseId}");
}, 2000, 200);
// ID too long (>36 chars) should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 1',
'children' => [
[
'$id' => 'this_id_is_way_too_long_and_should_fail_validation_check',
'title' => 'Child 1',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// ID with invalid characters should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 2',
'children' => [
[
'$id' => 'invalid@id#with$special%chars',
'title' => 'Child 2',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// ID starting with underscore should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 3',
'children' => [
[
'$id' => '_startsWithUnderscore',
'title' => 'Child 3',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Valid ID should succeed
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 4',
'children' => [
[
'$id' => 'valid-id-123',
'title' => 'Child 4',
],
],
],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$parentRowId = $response['body']['$id'];
// Update with invalid relationship ID should fail
$response = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows/' . $parentRowId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'data' => [
'children' => [
[
'$id' => 'another@invalid#id',
'title' => 'Child 5',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Invalid string relation ID should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 6',
'children' => [
'invalid@string#id',
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Integer as relation value should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 7',
'children' => [
12345,
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// unique() as $id should succeed
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 8',
'children' => [
[
'$id' => 'unique()',
'title' => 'Child 8',
],
],
],
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Empty string as $id should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 9',
'children' => [
[
'$id' => '',
'title' => 'Child 9',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Valid ID with allowed special chars (hyphen, period) should succeed
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 10',
'children' => [
[
'$id' => 'valid.id-with_chars',
'title' => 'Child 10',
],
],
],
]);
$this->assertEquals(201, $response['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/