feat(insights): validate insight and project documents in CTA actions

Replace the no-op `fn () => true` validators (gated by skipValidation)
on the `insight` and `project` params with dedicated InsightDocument and
ProjectDocument validators. They check that the injected value is a
non-empty Document with the expected attributes, so a misconfigured
dispatcher or unbound injection fails fast with a useful message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-05-01 15:12:41 +12:00
co-authored by Claude Opus 4.7
parent 69c637c72d
commit 4d560bdff2
5 changed files with 213 additions and 2 deletions
@@ -7,6 +7,8 @@ use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Insights\CTA\Action;
use Appwrite\Insights\Validator\CTAParams\DatabasesCreateIndex as DatabasesCreateIndexParams;
use Appwrite\Insights\Validator\InsightDocument as InsightDocumentValidator;
use Appwrite\Insights\Validator\ProjectDocument as ProjectDocumentValidator;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
@@ -28,8 +30,8 @@ class Create extends Action
->desc('Create a database index from an insight CTA.')
->label('scope', 'collections.write')
->param('params', [], new DatabasesCreateIndexParams(), 'CTA params describing the index to create.')
->param('insight', null, fn () => true, 'Parent insight document.', skipValidation: true)
->param('project', null, fn () => true, 'Project document.', skipValidation: true)
->param('insight', null, new InsightDocumentValidator(), 'Parent insight document.')
->param('project', null, new ProjectDocumentValidator(), 'Project document.')
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForDatabase')
@@ -0,0 +1,48 @@
<?php
namespace Appwrite\Insights\Validator;
use Utopia\Database\Document;
use Utopia\Validator;
class InsightDocument extends Validator
{
protected string $message = 'Value must be a non-empty insight Document with `type` and `ctas` attributes.';
public function getDescription(): string
{
return $this->message;
}
public function isArray(): bool
{
return false;
}
public function getType(): string
{
return self::TYPE_OBJECT;
}
public function isValid($value): bool
{
if (!$value instanceof Document) {
return false;
}
if ($value->isEmpty()) {
return false;
}
$type = $value->getAttribute('type');
if (!\is_string($type) || $type === '') {
return false;
}
if (!\is_array($value->getAttribute('ctas', []))) {
return false;
}
return true;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Appwrite\Insights\Validator;
use Utopia\Database\Document;
use Utopia\Validator;
class ProjectDocument extends Validator
{
protected string $message = 'Value must be a non-empty project Document with an `$id`.';
public function getDescription(): string
{
return $this->message;
}
public function isArray(): bool
{
return false;
}
public function getType(): string
{
return self::TYPE_OBJECT;
}
public function isValid($value): bool
{
if (!$value instanceof Document) {
return false;
}
if ($value->isEmpty()) {
return false;
}
return $value->getId() !== '';
}
}
@@ -0,0 +1,69 @@
<?php
namespace Tests\Unit\Insights\Validator;
use Appwrite\Insights\Validator\InsightDocument;
use PHPUnit\Framework\TestCase;
use Utopia\Database\Document;
class InsightDocumentTest extends TestCase
{
public function testAcceptsValidInsight(): void
{
$validator = new InsightDocument();
$insight = new Document([
'$id' => 'insight-1',
'type' => 'databaseIndex',
'ctas' => [],
]);
$this->assertTrue($validator->isValid($insight));
}
public function testRejectsNonDocument(): void
{
$validator = new InsightDocument();
$this->assertFalse($validator->isValid('not a document'));
$this->assertFalse($validator->isValid(null));
$this->assertFalse($validator->isValid(['type' => 'databaseIndex']));
}
public function testRejectsEmptyDocument(): void
{
$validator = new InsightDocument();
$this->assertFalse($validator->isValid(new Document()));
}
public function testRejectsMissingType(): void
{
$validator = new InsightDocument();
$insight = new Document([
'$id' => 'insight-1',
'ctas' => [],
]);
$this->assertFalse($validator->isValid($insight));
}
public function testRejectsNonArrayCtas(): void
{
$validator = new InsightDocument();
$insight = new Document([
'$id' => 'insight-1',
'type' => 'databaseIndex',
'ctas' => 'not-an-array',
]);
$this->assertFalse($validator->isValid($insight));
}
public function testReportsObjectType(): void
{
$validator = new InsightDocument();
$this->assertSame('object', $validator->getType());
$this->assertFalse($validator->isArray());
}
}
@@ -0,0 +1,53 @@
<?php
namespace Tests\Unit\Insights\Validator;
use Appwrite\Insights\Validator\ProjectDocument;
use PHPUnit\Framework\TestCase;
use Utopia\Database\Document;
class ProjectDocumentTest extends TestCase
{
public function testAcceptsValidProject(): void
{
$validator = new ProjectDocument();
$project = new Document([
'$id' => 'project-1',
'name' => 'Test',
]);
$this->assertTrue($validator->isValid($project));
}
public function testRejectsNonDocument(): void
{
$validator = new ProjectDocument();
$this->assertFalse($validator->isValid('not a document'));
$this->assertFalse($validator->isValid(null));
$this->assertFalse($validator->isValid(['$id' => 'project-1']));
}
public function testRejectsEmptyDocument(): void
{
$validator = new ProjectDocument();
$this->assertFalse($validator->isValid(new Document()));
}
public function testRejectsMissingId(): void
{
$validator = new ProjectDocument();
$project = new Document(['name' => 'Test']);
$this->assertFalse($validator->isValid($project));
}
public function testReportsObjectType(): void
{
$validator = new ProjectDocument();
$this->assertSame('object', $validator->getType());
$this->assertFalse($validator->isArray());
}
}