Merge branch '1.9.x' into feat-add-telemetry-for-ss-success-rates

This commit is contained in:
Harsh Mahajan 2026-04-16 15:23:45 +05:30 committed by GitHub
commit f167049b51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 350 additions and 56 deletions

View file

@ -474,19 +474,26 @@ Http::delete('/v1/account')
->inject('dbForProject')
->inject('queueForEvents')
->inject('queueForDeletes')
->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) {
->inject('authorization')
->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) {
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
if ($project->getId() === 'console') {
// get all memberships
$memberships = $user->getAttribute('memberships', []);
foreach ($memberships as $membership) {
// prevent deletion if at least one active membership
if ($membership->getAttribute('confirm', false)) {
throw new Exception(Exception::USER_DELETION_PROHIBITED);
if (!$membership->getAttribute('confirm', false)) {
continue;
}
$team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId'));
if ($team->isEmpty()) {
continue;
}
// Team is left as-is — we don't promote non-owner members to owner.
// Orphan teams are cleaned up later by Cloud's inactive project cleanup.
}
}

View file

@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status;
use Appwrite\Event\Event;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@ -33,8 +34,8 @@ class Update extends Action
->desc('Update project protocol status')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('event', 'protocols.[protocol].update')
->label('audits.event', 'project.protocols.[protocol].update')
->label('event', 'protocols.[protocolId].update')
->label('audits.event', 'project.protocols.[protocolId].update')
->label('audits.resource', 'project.protocols/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
@ -57,6 +58,7 @@ class Update extends Action
->inject('dbForPlatform')
->inject('project')
->inject('authorization')
->inject('queueForEvents')
->callback($this->action(...));
}
@ -66,7 +68,8 @@ class Update extends Action
Response $response,
Database $dbForPlatform,
Document $project,
Authorization $authorization
Authorization $authorization,
Event $queueForEvents,
): void {
$protocols = $project->getAttribute('apis', []);
$protocols[$protocolId] = $enabled;
@ -75,6 +78,8 @@ class Update extends Action
'apis' => $protocols,
])));
$queueForEvents->setParam('protocolId', $protocolId);
$response->dynamic($project, Response::MODEL_PROJECT);
}
}

View file

@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\Services\Status;
use Appwrite\Event\Event;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@ -33,8 +34,8 @@ class Update extends Action
->desc('Update project service status')
->groups(['api', 'project'])
->label('scope', 'project.write')
->label('event', 'services.[service].update')
->label('audits.event', 'project.services.[service].update')
->label('event', 'services.[serviceId].update')
->label('audits.event', 'project.services.[serviceId].update')
->label('audits.resource', 'project.services/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
@ -57,6 +58,7 @@ class Update extends Action
->inject('dbForPlatform')
->inject('project')
->inject('authorization')
->inject('queueForEvents')
->callback($this->action(...));
}
@ -66,7 +68,8 @@ class Update extends Action
Response $response,
Database $dbForPlatform,
Document $project,
Authorization $authorization
Authorization $authorization,
Event $queueForEvents
): void {
$services = $project->getAttribute('services', []);
$services[$serviceId] = $enabled;
@ -75,6 +78,8 @@ class Update extends Action
'services' => $services,
])));
$queueForEvents->setParam('serviceId', $serviceId);
$response->dynamic($project, Response::MODEL_PROJECT);
}
}

View file

@ -126,6 +126,7 @@ class Delete extends Action
if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) {
$membership = $dbForProject->findOne('memberships', [
Query::equal('teamInternalId', [$team->getSequence()]),
Query::equal('confirm', [true]),
]);
if (!$membership->isEmpty()) {

View file

@ -307,6 +307,7 @@ class Create extends Action
];
}
$output->setAttribute('type', $type);
$output->setAttribute('variables', $variables);
$response->dynamic($output, $type === 'framework' ? Response::MODEL_DETECTION_FRAMEWORK : Response::MODEL_DETECTION_RUNTIME);

View file

@ -263,6 +263,182 @@ abstract class Format
return $contents;
}
/**
* @param array<Model> $models
* @return array<string, mixed>|null
*/
protected function getDiscriminator(array $models, string $refPrefix): ?array
{
if (\count($models) < 2) {
return null;
}
$candidateKeys = \array_keys($models[0]->conditions);
foreach (\array_slice($models, 1) as $model) {
$candidateKeys = \array_values(\array_intersect($candidateKeys, \array_keys($model->conditions)));
}
if (empty($candidateKeys)) {
return null;
}
foreach ($candidateKeys as $key) {
$mapping = [];
$isValid = true;
foreach ($models as $model) {
$rules = $model->getRules();
$condition = $model->conditions[$key] ?? null;
if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) {
$isValid = false;
break;
}
if (!\is_array($condition)) {
if (!\is_scalar($condition)) {
$isValid = false;
break;
}
$values = [$condition];
} else {
if ($condition === []) {
$isValid = false;
break;
}
$values = $condition;
$hasInvalidValue = false;
foreach ($values as $value) {
if (!\is_scalar($value)) {
$hasInvalidValue = true;
break;
}
}
if ($hasInvalidValue) {
$isValid = false;
break;
}
}
if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) {
$values = \array_values(\array_filter(
$values,
fn (mixed $value) => \in_array($value, $rules[$key]['enum'], true)
));
}
if ($values === []) {
$isValid = false;
break;
}
$ref = $refPrefix . $model->getType();
foreach ($values as $value) {
$mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $ref) {
$isValid = false;
break;
}
$mapping[$mappingKey] = $ref;
}
if (!$isValid) {
break;
}
}
if (!$isValid || $mapping === []) {
continue;
}
return [
'propertyName' => $key,
'mapping' => $mapping,
];
}
// Single-key failed — try compound discriminator
return $this->getCompoundDiscriminator($models, $refPrefix);
}
/**
* @param array<Model> $models
* @return array<string, mixed>|null
*/
private function getCompoundDiscriminator(array $models, string $refPrefix): ?array
{
$allKeys = [];
foreach ($models as $model) {
foreach (\array_keys($model->conditions) as $key) {
if (!\in_array($key, $allKeys, true)) {
$allKeys[] = $key;
}
}
}
if (\count($allKeys) < 2) {
return null;
}
$primaryKey = $allKeys[0];
$primaryMapping = [];
$compoundMapping = [];
foreach ($models as $model) {
$rules = $model->getRules();
$conditions = [];
foreach ($model->conditions as $key => $condition) {
if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) {
return null;
}
if (!\is_scalar($condition)) {
return null;
}
$conditions[$key] = \is_bool($condition) ? ($condition ? 'true' : 'false') : (string) $condition;
}
if (empty($conditions)) {
return null;
}
$ref = $refPrefix . $model->getType();
$compoundMapping[$ref] = $conditions;
// Best-effort single-key mapping — last model with this value wins (fallback)
if (isset($conditions[$primaryKey])) {
$primaryMapping[$conditions[$primaryKey]] = $ref;
}
}
// Verify compound uniqueness
$seen = [];
foreach ($compoundMapping as $conditions) {
$sig = \json_encode($conditions, JSON_THROW_ON_ERROR);
if (isset($seen[$sig])) {
return null;
}
$seen[$sig] = true;
}
return \array_filter([
'propertyName' => $primaryKey,
'mapping' => !empty($primaryMapping) ? $primaryMapping : null,
'x-propertyNames' => $allKeys,
'x-mapping' => $compoundMapping,
]);
}
protected function getRequestEnumName(string $service, string $method, string $param): ?string
{
/* `$service` is `$namespace` */

View file

@ -316,9 +316,10 @@ class OpenAPI3 extends Format
'description' => $modelDescription,
'content' => [
$produces => [
'schema' => [
'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model)
],
'schema' => \array_filter([
'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model),
'discriminator' => $this->getDiscriminator($model, '#/components/schemas/'),
]),
],
],
];
@ -900,18 +901,30 @@ class OpenAPI3 extends Format
$rule['type'] = ($rule['type']) ? $rule['type'] : 'none';
if (\is_array($rule['type'])) {
$resolvedModels = \array_map(function (string $type) {
foreach ($this->models as $model) {
if ($model->getType() === $type) {
return $model;
}
}
throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered.");
}, $rule['type']);
if ($rule['array']) {
$items = [
$items = \array_filter([
'anyOf' => \array_map(function ($type) {
return ['$ref' => '#/components/schemas/' . $type];
}, $rule['type'])
];
}, $rule['type']),
'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'),
]);
} else {
$items = [
$items = \array_filter([
'oneOf' => \array_map(function ($type) {
return ['$ref' => '#/components/schemas/' . $type];
}, $rule['type'])
];
}, $rule['type']),
'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'),
]);
}
} else {
$items = [

View file

@ -322,11 +322,12 @@ class Swagger2 extends Format
}
$temp['responses'][(string)$response->getCode() ?? '500'] = [
'description' => $modelDescription,
'schema' => [
'schema' => \array_filter([
'x-oneOf' => \array_map(function ($m) {
return ['$ref' => '#/definitions/' . $m->getType()];
}, $model)
],
}, $model),
'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'),
]),
];
} else {
// Response definition using one type
@ -880,14 +881,27 @@ class Swagger2 extends Format
$rule['type'] = ($rule['type']) ?: 'none';
if (\is_array($rule['type'])) {
$resolvedModels = \array_map(function (string $type) {
foreach ($this->models as $model) {
if ($model->getType() === $type) {
return $model;
}
}
throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered.");
}, $rule['type']);
$xDiscriminator = $this->getDiscriminator($resolvedModels, '#/definitions/');
if ($rule['array']) {
$items = [
'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type'])
];
$items = \array_filter([
'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']),
'x-discriminator' => $xDiscriminator,
]);
} else {
$items = [
'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type'])
];
$items = \array_filter([
'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']),
'x-discriminator' => $xDiscriminator,
]);
}
} else {
$items = [

View file

@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoArgon2 extends Model
{
public array $conditions = [
'type' => 'argon2',
];
public function __construct()
{
// No options if imported. If hashed by Appwrite, following configuration is available:

View file

@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoBcrypt extends Model
{
public array $conditions = [
'type' => 'bcrypt',
];
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration

View file

@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoMd5 extends Model
{
public array $conditions = [
'type' => 'md5',
];
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration

View file

@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoPhpass extends Model
{
public array $conditions = [
'type' => 'phpass',
];
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration

View file

@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoScrypt extends Model
{
public array $conditions = [
'type' => 'scrypt',
];
public function __construct()
{
$this

View file

@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoScryptModified extends Model
{
public array $conditions = [
'type' => 'scryptMod',
];
public function __construct()
{
$this

View file

@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model;
class AlgoSha extends Model
{
public array $conditions = [
'type' => 'sha',
];
public function __construct()
{
// No options, because this can only be imported, and verifying doesnt require any configuration

View file

@ -7,9 +7,16 @@ use Appwrite\Utopia\Response\Model;
abstract class Detection extends Model
{
public function __construct()
public function __construct(string $type)
{
$this
->addRule('type', [
'type' => self::TYPE_ENUM,
'description' => 'Repository detection type.',
'default' => $type,
'example' => $type,
'enum' => [$type],
])
->addRule('variables', [
'type' => Response::MODEL_DETECTION_VARIABLE,
'description' => 'Environment variables found in .env files',

View file

@ -8,7 +8,11 @@ class DetectionFramework extends Detection
{
public function __construct()
{
parent::__construct();
$this->conditions = [
'type' => 'framework',
];
parent::__construct('framework');
$this
->addRule('framework', [

View file

@ -8,7 +8,11 @@ class DetectionRuntime extends Detection
{
public function __construct()
{
parent::__construct();
$this->conditions = [
'type' => 'runtime',
];
parent::__construct('runtime');
$this
->addRule('runtime', [

View file

@ -14,7 +14,12 @@ class AccountConsoleClientTest extends Scope
use ProjectConsole;
use SideClient;
public function testDeleteAccount(): void
/**
* Test that account deletion succeeds even with active team memberships.
* When the user is the sole owner and only member of a team, the team
* should be cleaned up automatically.
*/
public function testDeleteAccountWithMembership(): void
{
$email = uniqid() . 'user@localhost.test';
$password = 'password';
@ -46,7 +51,7 @@ class AccountConsoleClientTest extends Scope
$session = $response['cookies']['a_session_' . $this->getProject()['$id']];
// create team
// Create team — user becomes sole owner and only member
$team = $this->client->call(Client::METHOD_POST, '/teams', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
@ -58,7 +63,51 @@ class AccountConsoleClientTest extends Scope
]);
$this->assertEquals($team['headers']['status-code'], 201);
$teamId = $team['body']['$id'];
// Account deletion should succeed even with active membership
$response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]));
$this->assertEquals(204, $response['headers']['status-code']);
}
/**
* Test that account deletion works when the user has no team memberships.
*/
public function testDeleteAccountWithoutMembership(): void
{
$email = uniqid() . 'user@localhost.test';
$password = 'password';
$name = 'User Name';
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]), [
'userId' => ID::unique(),
'email' => $email,
'password' => $password,
'name' => $name,
]);
$this->assertEquals($response['headers']['status-code'], 201);
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]), [
'email' => $email,
'password' => $password,
]);
$this->assertEquals($response['headers']['status-code'], 201);
$session = $response['cookies']['a_session_' . $this->getProject()['$id']];
$response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([
'origin' => 'http://localhost',
@ -67,27 +116,7 @@ class AccountConsoleClientTest extends Scope
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]));
$this->assertEquals($response['headers']['status-code'], 400);
// DELETE TEAM
$response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]));
$this->assertEquals($response['headers']['status-code'], 204);
$this->assertEventually(function () use ($session) {
$response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]));
$this->assertEquals(204, $response['headers']['status-code']);
}, 10_000, 500);
$this->assertEquals(204, $response['headers']['status-code']);
}
public function testSessionAlert(): void