2026-04-27 13:51:54 +00:00
< ? php
namespace Tests\E2E\Services\Project ;
2026-04-27 14:54:53 +00:00
use PHPUnit\Framework\Attributes\Before ;
2026-04-27 15:17:57 +00:00
use PHPUnit\Framework\Attributes\DataProvider ;
2026-04-27 14:02:19 +00:00
use Tests\E2E\Client ;
2026-05-04 14:05:56 +00:00
use Utopia\Database\Query ;
2026-04-27 14:02:19 +00:00
2026-04-27 13:51:54 +00:00
trait OAuth2Base
{
2026-04-27 14:54:53 +00:00
/**
* Reset providers we mutate in tests back to a known empty / disabled state .
* The ProjectCustom trait reuses the same project across tests in a class ,
* and the OAuth2 PATCH endpoint is additive ( omitted fields are preserved ),
* so without a reset state would leak between tests .
2026-04-27 15:27:23 +00:00
*
* Assert on the reset response so a silently broken reset ( e . g . validation
* change ) surfaces immediately rather than corrupting downstream tests .
2026-04-27 14:54:53 +00:00
*/
#[Before(priority: -1)]
protected function resetProjectOAuth2 () : void
{
2026-04-27 15:27:23 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:54:53 +00:00
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
2026-04-27 15:27:23 +00:00
$this -> assertSame (
200 ,
$response [ 'headers' ][ 'status-code' ],
'OAuth2 reset failed — downstream tests will be unreliable. Body: ' . \json_encode ( $response [ 'body' ] ? ? null ),
);
2026-04-27 14:54:53 +00:00
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// List OAuth2 providers
// =========================================================================
public function testListOAuth2Providers () : void
{
$response = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( 'total' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'providers' , $response [ 'body' ]);
$this -> assertGreaterThan ( 0 , $response [ 'body' ][ 'total' ]);
$this -> assertSame ( $response [ 'body' ][ 'total' ], \count ( $response [ 'body' ][ 'providers' ]));
}
public function testListOAuth2ProvidersIncludesKnownProviders () : void
{
$response = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$ids = \array_column ( $response [ 'body' ][ 'providers' ], '$id' );
// Spot-check a representative cross-section of providers across all
// provider shapes (plain, multi-field, sandboxed, custom param names).
$expected = [
'github' ,
'amazon' ,
'apple' ,
'auth0' ,
'authentik' ,
2026-04-28 08:43:16 +00:00
'fusionauth' ,
2026-04-27 14:02:19 +00:00
'gitlab' ,
2026-04-28 08:54:13 +00:00
'keycloak' ,
2026-04-27 14:02:19 +00:00
'oidc' ,
'okta' ,
'microsoft' ,
'dropbox' ,
'paypalSandbox' ,
'kick' ,
];
foreach ( $expected as $providerId ) {
$this -> assertContains ( $providerId , $ids , " Missing provider { $providerId } in listOAuth2Providers response " );
}
}
2026-04-27 15:27:23 +00:00
/**
* Pin the exact set of registered providers — adding or removing a
* provider must be a deliberate change to this assertion . Catches
* registration drift ( e . g . forgetting to wire a new provider into
* `Base::getProviderActions()` ) .
*/
public function testListOAuth2ProvidersExposesEntireRegistry () : void
{
$response = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$ids = \array_column ( $response [ 'body' ][ 'providers' ], '$id' );
\sort ( $ids );
$expected = [
'amazon' , 'apple' , 'auth0' , 'authentik' , 'autodesk' , 'bitbucket' ,
'bitly' , 'box' , 'dailymotion' , 'discord' , 'disqus' , 'dropbox' ,
2026-04-28 08:43:16 +00:00
'etsy' , 'facebook' , 'figma' , 'fusionauth' , 'github' , 'gitlab' ,
2026-04-28 08:54:13 +00:00
'google' , 'keycloak' , 'kick' , 'linkedin' , 'microsoft' , 'notion' ,
'oidc' , 'okta' , 'paypal' , 'paypalSandbox' , 'podio' , 'salesforce' ,
'slack' , 'spotify' , 'stripe' , 'tradeshift' , 'tradeshiftBox' ,
'twitch' , 'wordpress' , 'x' , 'yahoo' , 'yandex' , 'zoho' , 'zoom' ,
2026-04-27 15:27:23 +00:00
];
\sort ( $expected );
$this -> assertSame ( $expected , $ids , 'Registry drift — listed providers do not match the expected set.' );
}
2026-04-27 14:02:19 +00:00
public function testListOAuth2ProvidersResponseShape () : void
{
$response = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
foreach ( $response [ 'body' ][ 'providers' ] as $provider ) {
$this -> assertArrayHasKey ( '$id' , $provider );
$this -> assertArrayHasKey ( 'enabled' , $provider );
$this -> assertIsString ( $provider [ '$id' ]);
$this -> assertIsBool ( $provider [ 'enabled' ]);
}
}
public function testListOAuth2ProvidersClientSecretsNotExposed () : void
{
// Seed credentials so the list cannot trivially return empty values.
2026-04-27 14:51:01 +00:00
$this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'amzn1.application-oa2-client.testListSeed' ,
'clientSecret' => 'super-secret-must-not-leak' ,
'enabled' => false ,
]);
$response = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$matched = false ;
foreach ( $response [ 'body' ][ 'providers' ] as $provider ) {
2026-04-27 14:51:01 +00:00
if ( $provider [ '$id' ] !== 'amazon' ) {
2026-04-27 14:02:19 +00:00
continue ;
}
$matched = true ;
$this -> assertSame ( 'amzn1.application-oa2-client.testListSeed' , $provider [ 'clientId' ]);
$this -> assertSame ( '' , $provider [ 'clientSecret' ]);
}
$this -> assertTrue ( $matched , 'List did not include the seeded provider.' );
}
public function testListOAuth2ProvidersWithoutAuthentication () : void
{
$response = $this -> listOAuth2Providers ( authenticated : false );
$this -> assertSame ( 401 , $response [ 'headers' ][ 'status-code' ]);
}
2026-04-27 15:40:49 +00:00
public function testListOAuth2ProvidersExcludesUnregisteredConfigEntries () : void
{
// `mock` and `mock-unverified` exist in oAuthProviders config (enabled: true)
// but are intentionally absent from Base::getProviderActions() — they're
// internal Mock OAuth2 adapters used by other test suites, not public
// providers. XList iterates the action registry, so they must never be
// included even though config marks them enabled.
$response = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$ids = \array_column ( $response [ 'body' ][ 'providers' ], '$id' );
$this -> assertNotContains ( 'mock' , $ids );
$this -> assertNotContains ( 'mock-unverified' , $ids );
}
2026-05-04 14:05:56 +00:00
public function testListOAuth2ProvidersTotalFalse () : void
{
$response = $this -> listOAuth2Providers ( total : false );
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 0 , $response [ 'body' ][ 'total' ]);
$this -> assertGreaterThan ( 0 , \count ( $response [ 'body' ][ 'providers' ]));
}
public function testListOAuth2ProvidersWithLimit () : void
{
$response = $this -> listOAuth2Providers ([
Query :: limit ( 1 ) -> toString (),
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertCount ( 1 , $response [ 'body' ][ 'providers' ]);
$this -> assertGreaterThan ( 1 , $response [ 'body' ][ 'total' ]);
}
public function testListOAuth2ProvidersWithOffset () : void
{
$listAll = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $listAll [ 'headers' ][ 'status-code' ]);
$listOffset = $this -> listOAuth2Providers ([
Query :: offset ( 1 ) -> toString (),
]);
$this -> assertSame ( 200 , $listOffset [ 'headers' ][ 'status-code' ]);
$this -> assertCount ( \count ( $listAll [ 'body' ][ 'providers' ]) - 1 , $listOffset [ 'body' ][ 'providers' ]);
$this -> assertSame ( $listAll [ 'body' ][ 'total' ], $listOffset [ 'body' ][ 'total' ]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Get OAuth2 provider
// =========================================================================
public function testGetOAuth2Provider () : void
{
$response = $this -> getOAuth2Provider ( 'github' );
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'github' , $response [ 'body' ][ '$id' ]);
$this -> assertArrayHasKey ( 'enabled' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'clientId' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'clientSecret' , $response [ 'body' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'clientSecret' ]);
}
2026-05-04 13:40:32 +00:00
public function testGetOAuth2ProviderWithAlias () : void
{
// The action declares the canonical param name as `providerId` and
// registers `provider` as an alias so that older SDK versions that
// send the provider in the query string continue to work.
$headers = [
'content-type' => 'application/json' ,
'x-appwrite-project' => $this -> getProject ()[ '$id' ],
];
$headers = \array_merge ( $headers , $this -> getHeaders ());
// Call with `provider` in query string (legacy behaviour)
$response = $this -> client -> call (
Client :: METHOD_GET ,
'/project/oauth2/github?provider=github' ,
$headers ,
);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'github' , $response [ 'body' ][ '$id' ]);
}
2026-04-27 14:02:19 +00:00
public function testGetOAuth2ProviderClientSecretWriteOnly () : void
{
2026-04-27 14:51:01 +00:00
$this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'amzn1.application-oa2-client.getSecretCheck' ,
'clientSecret' => 'must-never-be-returned' ,
'enabled' => false ,
]);
2026-04-27 14:51:01 +00:00
$response = $this -> getOAuth2Provider ( 'amazon' );
2026-04-27 14:02:19 +00:00
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'amzn1.application-oa2-client.getSecretCheck' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'clientSecret' ]);
}
public function testGetOAuth2ProviderMatchesListEntry () : void
{
$list = $this -> listOAuth2Providers ();
$this -> assertSame ( 200 , $list [ 'headers' ][ 'status-code' ]);
2026-04-27 15:27:23 +00:00
// Drive the loop directly off the LIST result so any provider added
// to the registry is automatically checked for List/Get parity.
foreach ( $list [ 'body' ][ 'providers' ] as $listEntry ) {
$providerId = $listEntry [ '$id' ];
2026-04-27 14:02:19 +00:00
$get = $this -> getOAuth2Provider ( $providerId );
2026-04-27 15:27:23 +00:00
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ], " GET failed for { $providerId } " );
$this -> assertSame ( $listEntry , $get [ 'body' ], " List/Get drift on { $providerId } " );
2026-04-27 14:02:19 +00:00
}
}
public function testGetOAuth2ProviderUnsupported () : void
{
2026-05-04 13:49:05 +00:00
// The `providerId` param is validated by a WhiteList of registered
// OAuth2 provider keys, so an unknown value is rejected at validation
// time — before the action runs — and surfaces as a generic argument
// error rather than `project_provider_unsupported`.
2026-04-27 14:02:19 +00:00
$response = $this -> getOAuth2Provider ( 'not-a-real-provider' );
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
2026-05-04 13:49:05 +00:00
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
2026-04-27 14:02:19 +00:00
}
2026-04-27 15:40:49 +00:00
public function testGetOAuth2ProviderRegisteredInConfigButNoUpdateClass () : void
{
2026-05-04 13:49:05 +00:00
// `mock` is present in oAuthProviders config (enabled: true) but is
// NOT registered in Base::getProviderActions(). It passes the
// WhiteList validator (which only checks config membership) and
// reaches the action body, where the action-registry check throws
// `project_provider_unsupported`.
2026-04-27 15:40:49 +00:00
$response = $this -> getOAuth2Provider ( 'mock' );
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'project_provider_unsupported' , $response [ 'body' ][ 'type' ]);
}
2026-04-27 14:02:19 +00:00
public function testGetOAuth2ProviderWithoutAuthentication () : void
{
$response = $this -> getOAuth2Provider ( 'github' , authenticated : false );
$this -> assertSame ( 401 , $response [ 'headers' ][ 'status-code' ]);
}
// =========================================================================
// Update plain provider (Amazon — clientId + clientSecret, no extra fields)
// =========================================================================
public function testUpdateOAuth2Plain () : void
{
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'amzn1.application-oa2-client.test01' ,
'clientSecret' => 'test-secret-01' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
2026-04-27 14:51:01 +00:00
$this -> assertSame ( 'amazon' , $response [ 'body' ][ '$id' ]);
2026-04-27 14:02:19 +00:00
$this -> assertSame ( 'amzn1.application-oa2-client.test01' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( false , $response [ 'body' ][ 'enabled' ]);
}
public function testUpdateOAuth2PlainEnable () : void
{
// Amazon has no verifyCredentials() hook, so enabling with arbitrary
// credentials succeeds without making a real network call.
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'amzn1.application-oa2-client.test02' ,
'clientSecret' => 'test-secret-02' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( true , $response [ 'body' ][ 'enabled' ]);
}
public function testUpdateOAuth2PlainDisable () : void
{
2026-04-27 14:51:01 +00:00
$this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'amzn1.application-oa2-client.test03' ,
'clientSecret' => 'test-secret-03' ,
'enabled' => true ,
]);
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( false , $response [ 'body' ][ 'enabled' ]);
// Credentials persist across an enabled toggle.
$this -> assertSame ( 'amzn1.application-oa2-client.test03' , $response [ 'body' ][ 'clientId' ]);
}
public function testUpdateOAuth2PlainPartial () : void
{
// Seed both credentials.
2026-04-27 14:51:01 +00:00
$this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'seed-client-id' ,
'clientSecret' => 'seed-secret' ,
'enabled' => false ,
]);
// Patch only clientId.
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'updated-client-id' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'updated-client-id' , $response [ 'body' ][ 'clientId' ]);
// Read back through GET to confirm the secret is still set internally
// (write-only, so we cannot inspect the value, but enabling should still
// succeed because the secret remains non-empty).
2026-04-27 14:51:01 +00:00
$enable = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'enabled' => true ,
]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( true , $enable [ 'body' ][ 'enabled' ]);
}
public function testUpdateOAuth2PlainEnableRequiresCredentials () : void
{
// Start from a clean state with no credentials.
2026-04-27 14:51:01 +00:00
$this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'enabled' => true ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
public function testUpdateOAuth2PlainEnabledOmittedDoesNotThrow () : void
{
// With enabled omitted (null) and no credentials, the silent-validation
// branch must not surface as an error.
2026-04-27 14:51:01 +00:00
$this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'partial-only' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( false , $response [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'partial-only' , $response [ 'body' ][ 'clientId' ]);
}
public function testUpdateOAuth2PlainResponseModel () : void
{
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'amzn1.application-oa2-client.modelCheck' ,
'clientSecret' => 'model-check-secret' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( '$id' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'enabled' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'clientId' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'clientSecret' , $response [ 'body' ]);
}
public function testUpdateOAuth2WithoutAuthentication () : void
{
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'clientId' => 'no-auth' ,
'clientSecret' => 'no-auth' ,
'enabled' => false ,
], authenticated : false );
$this -> assertSame ( 401 , $response [ 'headers' ][ 'status-code' ]);
}
public function testUpdateOAuth2UnknownProvider () : void
{
// Each Update endpoint is registered at a fixed `/oauth2/{providerId}`
// path, so an unknown provider does not match any route → 404.
$response = $this -> updateOAuth2 ( 'not-a-real-provider' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'enabled' => false ,
]);
$this -> assertSame ( 404 , $response [ 'headers' ][ 'status-code' ]);
}
public function testUpdateOAuth2InvalidEnabled () : void
{
2026-04-27 14:51:01 +00:00
$response = $this -> updateOAuth2 ( 'amazon' , [
2026-04-27 14:02:19 +00:00
'enabled' => 'not-a-boolean' ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
2026-04-27 15:27:23 +00:00
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
2026-04-27 14:02:19 +00:00
}
// =========================================================================
// Update GitHub (verifyCredentials makes a real call to GitHub on enable)
2026-04-27 15:27:23 +00:00
//
// Only failure paths and the silent-on-disable branch are tested here.
// Happy-path enable would require real GitHub OAuth2 credentials, which
// CI doesn't have. Wiring, validation, and the non-enabling branch are
// sufficient to surface most regressions; success-path issues are caught
// by integration / staging environments instead.
2026-04-27 14:02:19 +00:00
// =========================================================================
public function testUpdateOAuth2GitHubInvalidCredentialsRejected () : void
{
// GitHub is the only provider with a real verifyCredentials() hook.
// Enabling with bogus credentials must surface a 400 from the wrapping
// exception, not silently succeed.
$response = $this -> updateOAuth2 ( 'github' , [
'clientId' => 'fake-client-id-' . \uniqid (),
'clientSecret' => 'fake-client-secret' ,
'enabled' => true ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
// Cleanup: ensure it's left disabled.
$this -> updateOAuth2 ( 'github' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2GitHubInvalidCredentialsSilentWhenNotEnabling () : void
{
// When `enabled` is omitted, verifyCredentials() failure is swallowed.
// The provider remains disabled but the request succeeds.
$response = $this -> updateOAuth2 ( 'github' , [
'clientId' => 'still-fake-' . \uniqid (),
'clientSecret' => 'still-fake-secret' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( false , $response [ 'body' ][ 'enabled' ]);
// Cleanup
$this -> updateOAuth2 ( 'github' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
// =========================================================================
// Update Apple (serviceId + keyId + teamId + p8File)
// =========================================================================
public function testUpdateOAuth2Apple () : void
{
$response = $this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.web' ,
'keyId' => 'P4000000N8' ,
'teamId' => 'D4000000R6' ,
'p8File' => '-----BEGIN PRIVATE KEY-----TEST-----END PRIVATE KEY-----' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'apple' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'ip.appwrite.app.web' , $response [ 'body' ][ 'serviceId' ]);
2026-04-30 08:21:38 +00:00
$this -> assertSame ( 'P4000000N8' , $response [ 'body' ][ 'keyId' ]);
$this -> assertSame ( 'D4000000R6' , $response [ 'body' ][ 'teamId' ]);
2026-04-27 16:04:22 +00:00
$this -> assertSame ( '' , $response [ 'body' ][ 'p8File' ]);
2026-04-27 14:02:19 +00:00
$this -> assertSame ( false , $response [ 'body' ][ 'enabled' ]);
// Cleanup
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2ApplePartial () : void
{
// Seed all four fields.
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.seed' ,
'keyId' => 'KEYSEED01' ,
'teamId' => 'TEAMSEED01' ,
'p8File' => '-----BEGIN PRIVATE KEY-----SEED-----END PRIVATE KEY-----' ,
'enabled' => false ,
]);
// Patch only `keyId` — others must be preserved.
$response = $this -> updateOAuth2 ( 'apple' , [
'keyId' => 'KEYUPDATED' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'ip.appwrite.app.seed' , $response [ 'body' ][ 'serviceId' ]);
2026-04-30 08:21:38 +00:00
$this -> assertSame ( 'KEYUPDATED' , $response [ 'body' ][ 'keyId' ]);
$this -> assertSame ( 'TEAMSEED01' , $response [ 'body' ][ 'teamId' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'p8File' ]);
2026-04-27 14:02:19 +00:00
// Cleanup
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2ApplePartialPreservesEachField () : void
{
// Seed all four fields, then patch each one individually and confirm
// the others survive across the chain. testUpdateOAuth2ApplePartial
// only covers `keyId`; this exercises serviceId/teamId/p8File too.
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.merge' ,
'keyId' => 'KEYMERGE01' ,
'teamId' => 'TEAMMERGE' ,
'p8File' => '-----BEGIN PRIVATE KEY-----MERGE-----END PRIVATE KEY-----' ,
'enabled' => false ,
]);
// Patch only `teamId`.
$teamOnly = $this -> updateOAuth2 ( 'apple' , [
'teamId' => 'TEAMROTATED' ,
]);
$this -> assertSame ( 200 , $teamOnly [ 'headers' ][ 'status-code' ]);
2026-04-30 08:21:38 +00:00
$this -> assertSame ( 'TEAMROTATED' , $teamOnly [ 'body' ][ 'teamId' ]);
$this -> assertSame ( 'KEYMERGE01' , $teamOnly [ 'body' ][ 'keyId' ]);
$this -> assertSame ( '' , $teamOnly [ 'body' ][ 'p8File' ]);
2026-04-27 15:40:49 +00:00
$this -> assertSame ( 'ip.appwrite.app.merge' , $teamOnly [ 'body' ][ 'serviceId' ]);
// Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob
// and must survive a top-level (non-blob) field update.
$serviceOnly = $this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.rotated' ,
]);
$this -> assertSame ( 200 , $serviceOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'ip.appwrite.app.rotated' , $serviceOnly [ 'body' ][ 'serviceId' ]);
// Patch only `p8File`. keyId/teamId/serviceId must still be set
// internally — confirm by enabling. Apple has no verifyCredentials()
// hook, so persistCredentials only checks for non-empty serviceId and
// non-empty stored secret blob.
$p8Only = $this -> updateOAuth2 ( 'apple' , [
'p8File' => '-----BEGIN PRIVATE KEY-----ROTATED-----END PRIVATE KEY-----' ,
]);
$this -> assertSame ( 200 , $p8Only [ 'headers' ][ 'status-code' ]);
$enable = $this -> updateOAuth2 ( 'apple' , [ 'enabled' => true ]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
// Cleanup
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2AppleClearAllFieldsBlocksEnable () : void
{
// Seed all four Apple fields.
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.clearAll' ,
'keyId' => 'KEYCLEARALL' ,
'teamId' => 'TEAMCLEARALL' ,
'p8File' => '-----BEGIN PRIVATE KEY-----CLEARALL-----END PRIVATE KEY-----' ,
'enabled' => false ,
]);
// Clear all credentials with empty strings. With `enabled` omitted, the
// silent-validation branch swallows the empty-credentials throw, so the
// call still succeeds — see testUpdateOAuth2PlainEnabledOmittedDoesNotThrow.
$clear = $this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
]);
$this -> assertSame ( 200 , $clear [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( '' , $clear [ 'body' ][ 'serviceId' ]);
// A subsequent `enabled => true` must now 400. Empty serviceId trips
// persistCredentials' empty(appId) guard before any provider hook runs,
// proving that the clear actually took effect on stored state.
$enable = $this -> updateOAuth2 ( 'apple' , [
'enabled' => true ,
]);
$this -> assertSame ( 400 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $enable [ 'body' ][ 'type' ]);
// Cleanup (already cleared; included for reset symmetry).
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
public function testUpdateOAuth2AppleResponseModel () : void
{
$response = $this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.shape' ,
'keyId' => 'SHAPEKEY01' ,
'teamId' => 'SHAPETEAM' ,
'p8File' => '-----BEGIN PRIVATE KEY-----SHAPE-----END PRIVATE KEY-----' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( '$id' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'enabled' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'serviceId' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'keyId' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'teamId' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'p8File' , $response [ 'body' ]);
// Apple has no clientId/clientSecret in the response model.
$this -> assertArrayNotHasKey ( 'clientId' , $response [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $response [ 'body' ]);
// Cleanup
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
'enabled' => false ,
]);
}
public function testGetOAuth2AppleSecretsWriteOnly () : void
{
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.read' ,
'keyId' => 'KEYREAD' ,
'teamId' => 'TEAMREAD' ,
'p8File' => '-----BEGIN PRIVATE KEY-----READ-----END PRIVATE KEY-----' ,
'enabled' => false ,
]);
$response = $this -> getOAuth2Provider ( 'apple' );
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'ip.appwrite.app.read' , $response [ 'body' ][ 'serviceId' ]);
2026-04-30 08:21:38 +00:00
$this -> assertSame ( 'KEYREAD' , $response [ 'body' ][ 'keyId' ]);
$this -> assertSame ( 'TEAMREAD' , $response [ 'body' ][ 'teamId' ]);
2026-04-27 14:02:19 +00:00
$this -> assertSame ( '' , $response [ 'body' ][ 'p8File' ]);
// Cleanup
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2AppleEnableAndReadBack () : void
{
// Apple has no verifyCredentials() hook, so enabling with arbitrary
// (well-formed) values succeeds without any real Apple network call.
$update = $this -> updateOAuth2 ( 'apple' , [
'serviceId' => 'ip.appwrite.app.enable' ,
'keyId' => 'ENABLEKEY' ,
'teamId' => 'ENABLETEAM' ,
'p8File' => '-----BEGIN PRIVATE KEY-----ENABLE-----END PRIVATE KEY-----' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
2026-04-30 08:21:38 +00:00
// GET must hide p8File while keeping the non-secret fields.
2026-04-27 15:27:23 +00:00
$get = $this -> getOAuth2Provider ( 'apple' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'ip.appwrite.app.enable' , $get [ 'body' ][ 'serviceId' ]);
2026-04-30 08:21:38 +00:00
$this -> assertSame ( 'ENABLEKEY' , $get [ 'body' ][ 'keyId' ]);
$this -> assertSame ( 'ENABLETEAM' , $get [ 'body' ][ 'teamId' ]);
2026-04-27 15:27:23 +00:00
$this -> assertSame ( '' , $get [ 'body' ][ 'p8File' ]);
// Cleanup
$this -> updateOAuth2 ( 'apple' , [
'serviceId' => '' ,
'keyId' => '' ,
'teamId' => '' ,
'p8File' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Update Auth0 (clientId + clientSecret + optional endpoint)
// =========================================================================
public function testUpdateOAuth2Auth0 () : void
{
$response = $this -> updateOAuth2 ( 'auth0' , [
'clientId' => 'OaOkIA000000000000000000005KLSYq' ,
'clientSecret' => 'auth0-test-secret' ,
'endpoint' => 'example.us.auth0.com' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'auth0' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'OaOkIA000000000000000000005KLSYq' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'example.us.auth0.com' , $response [ 'body' ][ 'endpoint' ]);
// Cleanup
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2Auth0PartialEndpoint () : void
{
// Seed clientSecret + endpoint.
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => 'auth0-seed-client' ,
'clientSecret' => 'auth0-seed-secret' ,
'endpoint' => 'seed.us.auth0.com' ,
'enabled' => false ,
]);
// Update only endpoint.
$response = $this -> updateOAuth2 ( 'auth0' , [
'endpoint' => 'updated.us.auth0.com' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'updated.us.auth0.com' , $response [ 'body' ][ 'endpoint' ]);
// clientId is unchanged on top-level provider state.
$this -> assertSame ( 'auth0-seed-client' , $response [ 'body' ][ 'clientId' ]);
// Cleanup
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2Auth0PartialPreservesEachField () : void
{
// testUpdateOAuth2Auth0PartialEndpoint only patches `endpoint`. Cover
// patching `clientSecret` alone (must not wipe endpoint) and `clientId`
// alone (must not wipe the JSON-blob fields).
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => 'auth0-merge-client' ,
'clientSecret' => 'auth0-merge-secret' ,
'endpoint' => 'merge.us.auth0.com' ,
'enabled' => false ,
]);
// Patch only clientSecret — clientId and endpoint must survive.
$secretOnly = $this -> updateOAuth2 ( 'auth0' , [
'clientSecret' => 'auth0-rotated-secret' ,
]);
$this -> assertSame ( 200 , $secretOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'auth0-merge-client' , $secretOnly [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'merge.us.auth0.com' , $secretOnly [ 'body' ][ 'endpoint' ]);
// Patch only clientId — endpoint must survive.
$idOnly = $this -> updateOAuth2 ( 'auth0' , [
'clientId' => 'auth0-rotated-client' ,
]);
$this -> assertSame ( 200 , $idOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'auth0-rotated-client' , $idOnly [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'merge.us.auth0.com' , $idOnly [ 'body' ][ 'endpoint' ]);
// Confirm the rotated clientSecret survived the chain by enabling.
// Auth0 has no verifyCredentials() hook; non-empty secret is enough.
$enable = $this -> updateOAuth2 ( 'auth0' , [ 'enabled' => true ]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
// Cleanup
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2Auth0EndpointAcceptsEmpty () : void
{
// Auth0's `endpoint` validator is `Nullable(Text(256, 0))`. Passing
// `''` must clear the stored value rather than leave it untouched
// (would happen if the merge fell back to existing on empty-string).
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => 'auth0-clear-client' ,
'clientSecret' => 'auth0-clear-secret' ,
'endpoint' => 'before.us.auth0.com' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'auth0' , [
'endpoint' => '' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( 'auth0-clear-client' , $response [ 'body' ][ 'clientId' ]);
// Cleanup
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2Auth0EnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'auth0' , [
'clientId' => 'auth0-enable-client' ,
'clientSecret' => 'auth0-enable-secret' ,
'endpoint' => 'enable.us.auth0.com' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide clientSecret while keeping clientId and endpoint.
$get = $this -> getOAuth2Provider ( 'auth0' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'auth0-enable-client' , $get [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'enable.us.auth0.com' , $get [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'clientSecret' ]);
// Cleanup
$this -> updateOAuth2 ( 'auth0' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
// Update Authentik (clientId + clientSecret + optional endpoint)
2026-04-27 14:02:19 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2AuthentikAllowsOmittedEndpointWhenDisabled () : void
2026-04-27 14:02:19 +00:00
{
$response = $this -> updateOAuth2 ( 'authentik' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
2026-04-30 10:48:59 +00:00
'enabled' => false ,
2026-04-27 14:02:19 +00:00
]);
2026-04-30 10:48:59 +00:00
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'authentik' , $response [ 'body' ][ '$id' ]);
// Cleanup
$this -> updateOAuth2 ( 'authentik' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
2026-04-27 14:02:19 +00:00
}
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2AuthentikEmptyEndpointRejectedWhenEnabling () : void
2026-04-27 15:40:49 +00:00
{
$response = $this -> updateOAuth2 ( 'authentik' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'endpoint' => '' ,
2026-04-30 10:48:59 +00:00
'enabled' => true ,
2026-04-27 15:40:49 +00:00
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
2026-04-27 14:02:19 +00:00
public function testUpdateOAuth2Authentik () : void
{
$response = $this -> updateOAuth2 ( 'authentik' , [
'clientId' => 'dTKOPa0000000000000000000000000000e7G8hv' ,
'clientSecret' => 'authentik-secret' ,
'endpoint' => 'example.authentik.com' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'authentik' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'dTKOPa0000000000000000000000000000e7G8hv' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'example.authentik.com' , $response [ 'body' ][ 'endpoint' ]);
// Cleanup
$this -> updateOAuth2 ( 'authentik' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2AuthentikPartialPreservesSecret () : void
{
2026-04-30 10:48:59 +00:00
// The `clientSecret` and `endpoint` live in the JSON blob and must
2026-04-27 15:40:49 +00:00
// survive when omitted on a subsequent call that only changes clientId.
$this -> updateOAuth2 ( 'authentik' , [
'clientId' => 'authentik-merge-client' ,
'clientSecret' => 'authentik-merge-secret' ,
'endpoint' => 'merge.authentik.com' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'authentik' , [
'clientId' => 'authentik-rotated-client' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'authentik-rotated-client' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'merge.authentik.com' , $response [ 'body' ][ 'endpoint' ]);
// Confirm clientSecret survived the omitted-field merge by enabling
2026-04-30 10:48:59 +00:00
// without re-sending endpoint.
2026-04-27 15:40:49 +00:00
$enable = $this -> updateOAuth2 ( 'authentik' , [
'enabled' => true ,
]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
2026-04-30 10:48:59 +00:00
// Cleanup
2026-04-27 15:40:49 +00:00
$this -> updateOAuth2 ( 'authentik' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2AuthentikEnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'authentik' , [
'clientId' => 'authentik-enable-client' ,
'clientSecret' => 'authentik-enable-secret' ,
'endpoint' => 'enable.authentik.com' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide clientSecret while keeping clientId and endpoint.
$get = $this -> getOAuth2Provider ( 'authentik' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'authentik-enable-client' , $get [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'enable.authentik.com' , $get [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'clientSecret' ]);
2026-04-30 10:48:59 +00:00
// Cleanup
2026-04-27 15:27:23 +00:00
$this -> updateOAuth2 ( 'authentik' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
2026-04-27 15:27:23 +00:00
'enabled' => false ,
]);
}
2026-04-28 08:43:16 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
// Update FusionAuth (clientId + clientSecret + optional endpoint)
2026-04-28 08:43:16 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2FusionAuthAllowsOmittedEndpointWhenDisabled () : void
2026-04-28 08:43:16 +00:00
{
$response = $this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
2026-04-30 10:48:59 +00:00
'enabled' => false ,
2026-04-28 08:43:16 +00:00
]);
2026-04-30 10:48:59 +00:00
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'fusionauth' , $response [ 'body' ][ '$id' ]);
// Cleanup
$this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
2026-04-28 08:43:16 +00:00
}
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2FusionAuthEmptyEndpointRejectedWhenEnabling () : void
2026-04-28 08:43:16 +00:00
{
$response = $this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'endpoint' => '' ,
2026-04-30 10:48:59 +00:00
'enabled' => true ,
2026-04-28 08:43:16 +00:00
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
public function testUpdateOAuth2FusionAuth () : void
{
$response = $this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => 'b2222c00-0000-0000-0000-000000862097' ,
'clientSecret' => 'fusionauth-secret' ,
'endpoint' => 'example.fusionauth.io' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'fusionauth' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'b2222c00-0000-0000-0000-000000862097' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'example.fusionauth.io' , $response [ 'body' ][ 'endpoint' ]);
// Cleanup
$this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
2026-04-28 08:43:16 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2FusionAuthPartialPreservesSecret () : void
{
2026-04-30 10:48:59 +00:00
// The `clientSecret` and `endpoint` live in the JSON blob and must
2026-04-28 08:43:16 +00:00
// survive when omitted on a subsequent call that only changes clientId.
$this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => 'fusionauth-merge-client' ,
'clientSecret' => 'fusionauth-merge-secret' ,
'endpoint' => 'merge.fusionauth.io' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => 'fusionauth-rotated-client' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'fusionauth-rotated-client' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'merge.fusionauth.io' , $response [ 'body' ][ 'endpoint' ]);
// Confirm clientSecret survived the omitted-field merge by enabling
2026-04-30 10:48:59 +00:00
// without re-sending endpoint.
2026-04-28 08:43:16 +00:00
$enable = $this -> updateOAuth2 ( 'fusionauth' , [
'enabled' => true ,
]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
2026-04-30 10:48:59 +00:00
// Cleanup
2026-04-28 08:43:16 +00:00
$this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
2026-04-28 08:43:16 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2FusionAuthEnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => 'fusionauth-enable-client' ,
'clientSecret' => 'fusionauth-enable-secret' ,
'endpoint' => 'enable.fusionauth.io' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide clientSecret while keeping clientId and endpoint.
$get = $this -> getOAuth2Provider ( 'fusionauth' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'fusionauth-enable-client' , $get [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'enable.fusionauth.io' , $get [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'clientSecret' ]);
2026-04-30 10:48:59 +00:00
// Cleanup
2026-04-28 08:43:16 +00:00
$this -> updateOAuth2 ( 'fusionauth' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
2026-04-28 08:43:16 +00:00
'enabled' => false ,
]);
}
2026-04-28 08:54:13 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
// Update Keycloak (clientId + clientSecret + optional endpoint + optional realmName)
2026-04-28 08:54:13 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2KeycloakAllowsOmittedEndpointWhenDisabled () : void
2026-04-28 08:54:13 +00:00
{
$response = $this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'realmName' => 'appwrite-realm' ,
2026-04-30 10:48:59 +00:00
'enabled' => false ,
2026-04-28 08:54:13 +00:00
]);
2026-04-30 10:48:59 +00:00
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'keycloak' , $response [ 'body' ][ '$id' ]);
// Cleanup
$this -> updateOAuth2 ( 'keycloak' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'realmName' => '' ,
'enabled' => false ,
]);
2026-04-28 08:54:13 +00:00
}
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2KeycloakEmptyEndpointRejectedWhenEnabling () : void
2026-04-28 08:54:13 +00:00
{
$response = $this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'endpoint' => '' ,
'realmName' => 'appwrite-realm' ,
2026-04-30 10:48:59 +00:00
'enabled' => true ,
2026-04-28 08:54:13 +00:00
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2KeycloakAllowsOmittedRealmNameWhenDisabled () : void
2026-04-28 08:54:13 +00:00
{
$response = $this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'endpoint' => 'keycloak.example.com' ,
2026-04-30 10:48:59 +00:00
'enabled' => false ,
2026-04-28 08:54:13 +00:00
]);
2026-04-30 10:48:59 +00:00
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'keycloak' , $response [ 'body' ][ '$id' ]);
// Cleanup
$this -> updateOAuth2 ( 'keycloak' , [
'clientId' => '' ,
'clientSecret' => '' ,
'endpoint' => '' ,
'realmName' => '' ,
'enabled' => false ,
]);
2026-04-28 08:54:13 +00:00
}
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2KeycloakEmptyRealmNameRejectedWhenEnabling () : void
2026-04-28 08:54:13 +00:00
{
$response = $this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'endpoint' => 'keycloak.example.com' ,
'realmName' => '' ,
2026-04-30 10:48:59 +00:00
'enabled' => true ,
2026-04-28 08:54:13 +00:00
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
public function testUpdateOAuth2Keycloak () : void
{
$response = $this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'appwrite-o0000000st-app' ,
'clientSecret' => 'keycloak-secret' ,
'endpoint' => 'keycloak.example.com' ,
'realmName' => 'appwrite-realm' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'keycloak' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'appwrite-o0000000st-app' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'keycloak.example.com' , $response [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( 'appwrite-realm' , $response [ 'body' ][ 'realmName' ]);
// Cleanup
$this -> updateOAuth2 ( 'keycloak' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
'realmName' => '' ,
2026-04-28 08:54:13 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2KeycloakPartialPreservesSecret () : void
{
2026-04-30 10:48:59 +00:00
// The `clientSecret`, `endpoint`, and `realmName` live in the JSON
2026-04-28 08:54:13 +00:00
// blob and must survive when omitted on a subsequent call that only
// changes clientId.
$this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'keycloak-merge-client' ,
'clientSecret' => 'keycloak-merge-secret' ,
'endpoint' => 'merge.keycloak.com' ,
'realmName' => 'merge-realm' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'keycloak-rotated-client' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'keycloak-rotated-client' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'merge.keycloak.com' , $response [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( 'merge-realm' , $response [ 'body' ][ 'realmName' ]);
// Confirm clientSecret survived the omitted-field merge by enabling
2026-04-30 10:48:59 +00:00
// without re-sending endpoint or realmName.
2026-04-28 08:54:13 +00:00
$enable = $this -> updateOAuth2 ( 'keycloak' , [
'enabled' => true ,
]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
2026-04-30 10:48:59 +00:00
// Cleanup
2026-04-28 08:54:13 +00:00
$this -> updateOAuth2 ( 'keycloak' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
'realmName' => '' ,
2026-04-28 08:54:13 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2KeycloakEnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'keycloak' , [
'clientId' => 'keycloak-enable-client' ,
'clientSecret' => 'keycloak-enable-secret' ,
'endpoint' => 'enable.keycloak.com' ,
'realmName' => 'enable-realm' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide clientSecret while keeping clientId, endpoint, realmName.
$get = $this -> getOAuth2Provider ( 'keycloak' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'keycloak-enable-client' , $get [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'enable.keycloak.com' , $get [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( 'enable-realm' , $get [ 'body' ][ 'realmName' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'clientSecret' ]);
2026-04-30 10:48:59 +00:00
// Cleanup
2026-04-28 08:54:13 +00:00
$this -> updateOAuth2 ( 'keycloak' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-30 10:48:59 +00:00
'endpoint' => '' ,
'realmName' => '' ,
2026-04-28 08:54:13 +00:00
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
// Update Microsoft (applicationId + applicationSecret + optional tenant)
2026-04-27 14:02:19 +00:00
// =========================================================================
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2MicrosoftAllowsOmittedTenantWhenDisabled () : void
2026-04-27 14:02:19 +00:00
{
$response = $this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => 'whatever' ,
'applicationSecret' => 'whatever' ,
2026-04-30 10:48:59 +00:00
'enabled' => false ,
2026-04-27 14:02:19 +00:00
]);
2026-04-30 10:48:59 +00:00
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'microsoft' , $response [ 'body' ][ '$id' ]);
// Cleanup
$this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => '' ,
'applicationSecret' => '' ,
'tenant' => '' ,
'enabled' => false ,
]);
2026-04-27 14:02:19 +00:00
}
2026-04-30 10:48:59 +00:00
public function testUpdateOAuth2MicrosoftEmptyTenantRejectedWhenEnabling () : void
2026-04-27 15:40:49 +00:00
{
$response = $this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => 'whatever' ,
'applicationSecret' => 'whatever' ,
'tenant' => '' ,
2026-04-30 10:48:59 +00:00
'enabled' => true ,
2026-04-27 15:40:49 +00:00
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
2026-04-27 14:02:19 +00:00
public function testUpdateOAuth2Microsoft () : void
{
$response = $this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => '00001111-aaaa-2222-bbbb-3333cccc4444' ,
'applicationSecret' => 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u' ,
'tenant' => 'common' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'microsoft' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( '00001111-aaaa-2222-bbbb-3333cccc4444' , $response [ 'body' ][ 'applicationId' ]);
$this -> assertSame ( 'common' , $response [ 'body' ][ 'tenant' ]);
// Custom param names: applicationId/applicationSecret, not clientId/clientSecret.
$this -> assertArrayNotHasKey ( 'clientId' , $response [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $response [ 'body' ]);
// Cleanup
$this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => '' ,
'applicationSecret' => '' ,
2026-04-30 10:48:59 +00:00
'tenant' => '' ,
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2MicrosoftPartialPreservesSecret () : void
{
// Seed full credentials.
$this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => 'seed-app-id' ,
'applicationSecret' => 'seed-app-secret' ,
'tenant' => 'common' ,
'enabled' => false ,
]);
2026-04-30 10:48:59 +00:00
// Patch with only a new applicationId, leaving applicationSecret and
// tenant omitted. The stored JSON values must not be wiped.
2026-04-27 14:02:19 +00:00
$response = $this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => 'updated-app-id' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'updated-app-id' , $response [ 'body' ][ 'applicationId' ]);
2026-04-30 10:48:59 +00:00
$this -> assertSame ( 'common' , $response [ 'body' ][ 'tenant' ]);
2026-04-27 14:02:19 +00:00
// Cleanup
$this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => '' ,
'applicationSecret' => '' ,
2026-04-30 10:48:59 +00:00
'tenant' => '' ,
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2MicrosoftEnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => 'microsoft-enable-app' ,
'applicationSecret' => 'microsoft-enable-secret' ,
'tenant' => 'common' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide applicationSecret while keeping applicationId/tenant.
$get = $this -> getOAuth2Provider ( 'microsoft' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'microsoft-enable-app' , $get [ 'body' ][ 'applicationId' ]);
$this -> assertSame ( 'common' , $get [ 'body' ][ 'tenant' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'applicationSecret' ]);
2026-04-30 10:48:59 +00:00
// Cleanup
2026-04-27 15:27:23 +00:00
$this -> updateOAuth2 ( 'microsoft' , [
'applicationId' => '' ,
'applicationSecret' => '' ,
2026-04-30 10:48:59 +00:00
'tenant' => '' ,
2026-04-27 15:27:23 +00:00
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Update Gitlab (applicationId + secret + optional endpoint, custom names)
// =========================================================================
public function testUpdateOAuth2Gitlab () : void
{
$response = $this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => 'd41ffe0000000000000000000000000000000000000000000000000000d5e252' ,
'secret' => 'gloas-838cfa00' ,
'endpoint' => 'https://gitlab.example.com' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'gitlab' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'd41ffe0000000000000000000000000000000000000000000000000000d5e252' , $response [ 'body' ][ 'applicationId' ]);
$this -> assertSame ( 'https://gitlab.example.com' , $response [ 'body' ][ 'endpoint' ]);
// Custom names — the response model exposes `applicationId`/`secret`.
$this -> assertArrayNotHasKey ( 'clientId' , $response [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $response [ 'body' ]);
2026-04-27 14:54:53 +00:00
// Cleanup
2026-04-27 14:02:19 +00:00
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => '' ,
'secret' => '' ,
2026-04-27 14:54:53 +00:00
'endpoint' => '' ,
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2GitlabInvalidEndpoint () : void
{
$response = $this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => 'whatever' ,
'secret' => 'whatever' ,
'endpoint' => 'not a url' ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
2026-04-27 15:27:23 +00:00
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
2026-04-27 14:02:19 +00:00
}
public function testUpdateOAuth2GitlabPartialEndpoint () : void
{
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => 'gitlab-seed-app' ,
'secret' => 'gitlab-seed-secret' ,
'endpoint' => 'https://seed.gitlab.com' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'gitlab' , [
'endpoint' => 'https://updated.gitlab.com' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'https://updated.gitlab.com' , $response [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( 'gitlab-seed-app' , $response [ 'body' ][ 'applicationId' ]);
2026-04-27 14:54:53 +00:00
// Cleanup
2026-04-27 14:02:19 +00:00
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => '' ,
'secret' => '' ,
2026-04-27 14:54:53 +00:00
'endpoint' => '' ,
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2GitlabPartialPreservesEachField () : void
{
// testUpdateOAuth2GitlabPartialEndpoint covers patching only `endpoint`.
// Cover patching `secret` alone (must not wipe applicationId/endpoint)
// and `applicationId` alone (must not wipe the JSON-blob endpoint).
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => 'gitlab-merge-app' ,
'secret' => 'gitlab-merge-secret' ,
'endpoint' => 'https://merge.gitlab.com' ,
'enabled' => false ,
]);
// Patch only `secret`.
$secretOnly = $this -> updateOAuth2 ( 'gitlab' , [
'secret' => 'gitlab-rotated-secret' ,
]);
$this -> assertSame ( 200 , $secretOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'gitlab-merge-app' , $secretOnly [ 'body' ][ 'applicationId' ]);
$this -> assertSame ( 'https://merge.gitlab.com' , $secretOnly [ 'body' ][ 'endpoint' ]);
// Patch only `applicationId`.
$idOnly = $this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => 'gitlab-rotated-app' ,
]);
$this -> assertSame ( 200 , $idOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'gitlab-rotated-app' , $idOnly [ 'body' ][ 'applicationId' ]);
$this -> assertSame ( 'https://merge.gitlab.com' , $idOnly [ 'body' ][ 'endpoint' ]);
// Cleanup
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => '' ,
'secret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2GitlabEnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => 'gitlab-enable-app' ,
'secret' => 'gitlab-enable-secret' ,
'endpoint' => 'https://enable.gitlab.com' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide `secret` while keeping applicationId and endpoint.
$get = $this -> getOAuth2Provider ( 'gitlab' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'gitlab-enable-app' , $get [ 'body' ][ 'applicationId' ]);
$this -> assertSame ( 'https://enable.gitlab.com' , $get [ 'body' ][ 'endpoint' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'secret' ]);
// Cleanup
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => '' ,
'secret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2GitlabEndpointAcceptsEmpty () : void
{
2026-04-27 16:21:21 +00:00
// The `endpoint` validator is `Nullable(URL(allowEmpty: true))`. Passing
2026-04-27 15:27:23 +00:00
// `''` must clear the stored value rather than 400 on URL validation.
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => 'gitlab-clear-app' ,
'secret' => 'gitlab-clear-secret' ,
'endpoint' => 'https://before.gitlab.com' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'gitlab' , [
'endpoint' => '' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'endpoint' ]);
// Cleanup
$this -> updateOAuth2 ( 'gitlab' , [
'applicationId' => '' ,
'secret' => '' ,
'endpoint' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Update OIDC (clientId + secret + wellKnownURL or 3 discovery URLs)
// =========================================================================
public function testUpdateOAuth2OidcWithWellKnown () : void
{
$response = $this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-client' ,
'clientSecret' => 'oidc-secret' ,
'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'https://idp.example.com/.well-known/openid-configuration' , $response [ 'body' ][ 'wellKnownURL' ]);
$this -> assertArrayHasKey ( 'authorizationURL' , $response [ 'body' ]);
2026-05-06 09:24:53 +00:00
$this -> assertArrayHasKey ( 'tokenURL' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'userInfoURL' , $response [ 'body' ]);
2026-04-27 14:02:19 +00:00
2026-04-27 14:54:53 +00:00
// Cleanup
2026-04-27 14:02:19 +00:00
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-27 14:54:53 +00:00
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2OidcWithDiscoveryURLs () : void
{
$response = $this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-discovery' ,
'clientSecret' => 'oidc-discovery-secret' ,
'authorizationURL' => 'https://idp.example.com/oauth2/authorize' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => 'https://idp.example.com/oauth2/token' ,
'userInfoURL' => 'https://idp.example.com/oauth2/userinfo' ,
2026-04-27 14:02:19 +00:00
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/authorize' , $response [ 'body' ][ 'authorizationURL' ]);
2026-05-06 09:24:53 +00:00
$this -> assertSame ( 'https://idp.example.com/oauth2/token' , $response [ 'body' ][ 'tokenURL' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/userinfo' , $response [ 'body' ][ 'userInfoURL' ]);
2026-04-27 14:02:19 +00:00
2026-04-27 14:54:53 +00:00
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 14:54:53 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2OidcEnableMissingURLs () : void
{
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 14:54:53 +00:00
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-no-urls' ,
'clientSecret' => 'oidc-no-urls' ,
'enabled' => true ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
2026-04-27 14:02:19 +00:00
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:54:53 +00:00
public function testUpdateOAuth2OidcEnablePartialDiscoveryFails () : void
{
// Only authorization+token, missing userInfo — must fail to enable.
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 14:54:53 +00:00
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-partial' ,
'clientSecret' => 'oidc-partial-secret' ,
'authorizationURL' => 'https://idp.example.com/oauth2/authorize' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => 'https://idp.example.com/oauth2/token' ,
2026-04-27 14:54:53 +00:00
'enabled' => true ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 14:54:53 +00:00
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2OidcEnableSucceedsWithWellKnown () : void
{
$update = $this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-enable-client' ,
'clientSecret' => 'oidc-enable-secret' ,
'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide clientSecret while keeping clientId and the URL.
$get = $this -> getOAuth2Provider ( 'oidc' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'oidc-enable-client' , $get [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'https://idp.example.com/.well-known/openid-configuration' , $get [ 'body' ][ 'wellKnownURL' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'clientSecret' ]);
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:27:23 +00:00
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown () : void
{
// Configure URLs first with `enabled: false`. Then enable in a SECOND
// request that omits all URL fields. The merge-on-enable logic in
// Oidc::handle() must see the previously-stored wellKnownEndpoint and
// allow the toggle. This is the headline feature of the merge logic.
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-split-wk-client' ,
'clientSecret' => 'oidc-split-wk-secret' ,
'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration' ,
'enabled' => false ,
]);
$enable = $this -> updateOAuth2 ( 'oidc' , [
'enabled' => true ,
]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2OidcEnableAcrossRequestsWithDiscoveryURLs () : void
{
// Reset to clean state — earlier tests in this section may have left
// partial URL state when running in any order.
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
// Request 1: configure two of the three discovery URLs.
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-split-discovery' ,
'clientSecret' => 'oidc-split-discovery-secret' ,
'authorizationURL' => 'https://idp.example.com/oauth2/authorize' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => 'https://idp.example.com/oauth2/token' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
// Request 2: send only the third URL plus enable=true. The merged
// state must include the two stored URLs + the new one to satisfy
// the all-three-discovery-URLs branch of the enable check.
$enable = $this -> updateOAuth2 ( 'oidc' , [
2026-05-06 09:24:53 +00:00
'userInfoURL' => 'https://idp.example.com/oauth2/userinfo' ,
2026-04-27 15:40:49 +00:00
'enabled' => true ,
]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
// Confirm all three URLs ended up persisted (merge wrote the new
2026-05-06 09:24:53 +00:00
// userInfoURL while preserving the previously stored two).
2026-04-27 15:40:49 +00:00
$get = $this -> getOAuth2Provider ( 'oidc' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/authorize' , $get [ 'body' ][ 'authorizationURL' ]);
2026-05-06 09:24:53 +00:00
$this -> assertSame ( 'https://idp.example.com/oauth2/token' , $get [ 'body' ][ 'tokenURL' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/userinfo' , $get [ 'body' ][ 'userInfoURL' ]);
2026-04-27 15:40:49 +00:00
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2OidcEnableFailsAfterClearingWellKnown () : void
{
// Seed wellKnownURL only (no discovery URLs).
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-clear-then-enable' ,
'clientSecret' => 'oidc-clear-then-enable-secret' ,
'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
// Clear wellKnownURL and try to enable in the same request. Merge
// sees `wellKnown=''` (the cleared empty wins over the stored value
// because the new value is non-null) and no discovery URLs → 400.
// This is the inverse of testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown:
// confirms the merge correctly *replaces* with empty rather than
// falling back to the stored non-empty value.
$response = $this -> updateOAuth2 ( 'oidc' , [
'wellKnownURL' => '' ,
'enabled' => true ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
}
public function testUpdateOAuth2OidcSwitchModesWellKnownToDiscovery () : void
{
// Configure with wellKnownURL, then switch to the three-discovery-URL
// mode in a single request: clear wellKnown, set the three URLs,
// enable. Merge sees wellKnown='' AND all three discovery URLs set →
// hasAllDiscovery branch passes.
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-switch-client' ,
'clientSecret' => 'oidc-switch-secret' ,
'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration' ,
'enabled' => false ,
]);
$switch = $this -> updateOAuth2 ( 'oidc' , [
'wellKnownURL' => '' ,
'authorizationURL' => 'https://idp.example.com/oauth2/authorize' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => 'https://idp.example.com/oauth2/token' ,
'userInfoURL' => 'https://idp.example.com/oauth2/userinfo' ,
2026-04-27 15:40:49 +00:00
'enabled' => true ,
]);
$this -> assertSame ( 200 , $switch [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $switch [ 'body' ][ 'enabled' ]);
$this -> assertSame ( '' , $switch [ 'body' ][ 'wellKnownURL' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/authorize' , $switch [ 'body' ][ 'authorizationURL' ]);
2026-05-06 09:24:53 +00:00
$this -> assertSame ( 'https://idp.example.com/oauth2/token' , $switch [ 'body' ][ 'tokenURL' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/userinfo' , $switch [ 'body' ][ 'userInfoURL' ]);
2026-04-27 15:40:49 +00:00
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:40:49 +00:00
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2OidcURLsAcceptEmpty () : void
{
2026-04-27 16:21:21 +00:00
// All four URL fields use `Nullable(URL(allowEmpty: true))`. Passing `''`
2026-04-27 15:27:23 +00:00
// for each must clear them rather than 400 on URL validation.
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => 'oidc-clear-client' ,
'clientSecret' => 'oidc-clear-secret' ,
'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration' ,
'authorizationURL' => 'https://idp.example.com/oauth2/authorize' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => 'https://idp.example.com/oauth2/token' ,
'userInfoURL' => 'https://idp.example.com/oauth2/userinfo' ,
2026-04-27 15:27:23 +00:00
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'oidc' , [
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
2026-05-06 09:24:53 +00:00
'tokenURL' => '' ,
'userInfoURL' => '' ,
2026-04-27 15:27:23 +00:00
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'wellKnownURL' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'authorizationURL' ]);
2026-05-06 09:24:53 +00:00
$this -> assertSame ( '' , $response [ 'body' ][ 'tokenURL' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'userInfoURL' ]);
2026-04-27 15:27:23 +00:00
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
2026-05-06 09:32:00 +00:00
public function testUpdateOAuth2OidcBackwardCompatibleResponseFormat () : void
{
// Reset to clean state
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'wellKnownURL' => '' ,
'authorizationURL' => '' ,
'tokenURL' => '' ,
'userInfoURL' => '' ,
'enabled' => false ,
]);
$headers = [
'content-type' => 'application/json' ,
'x-appwrite-project' => $this -> getProject ()[ '$id' ],
'x-appwrite-response-format' => '1.9.3' ,
];
$headers = \array_merge ( $headers , $this -> getHeaders ());
// Update using OLD param names (aliases must still work)
$response = $this -> client -> call (
Client :: METHOD_PATCH ,
'/project/oauth2/oidc' ,
$headers ,
[
'clientId' => 'oidc-compat-client' ,
'clientSecret' => 'oidc-compat-secret' ,
'tokenUrl' => 'https://idp.example.com/oauth2/token' ,
'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo' ,
'enabled' => false ,
],
);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( 'tokenUrl' , $response [ 'body' ]);
$this -> assertArrayHasKey ( 'userInfoUrl' , $response [ 'body' ]);
$this -> assertArrayNotHasKey ( 'tokenURL' , $response [ 'body' ]);
$this -> assertArrayNotHasKey ( 'userInfoURL' , $response [ 'body' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/token' , $response [ 'body' ][ 'tokenUrl' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/userinfo' , $response [ 'body' ][ 'userInfoUrl' ]);
// GET with 1.9.3 format must also return old param names
$get = $this -> client -> call (
Client :: METHOD_GET ,
'/project/oauth2/oidc' ,
$headers ,
);
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( 'tokenUrl' , $get [ 'body' ]);
$this -> assertArrayHasKey ( 'userInfoUrl' , $get [ 'body' ]);
$this -> assertArrayNotHasKey ( 'tokenURL' , $get [ 'body' ]);
$this -> assertArrayNotHasKey ( 'userInfoURL' , $get [ 'body' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/token' , $get [ 'body' ][ 'tokenUrl' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/userinfo' , $get [ 'body' ][ 'userInfoUrl' ]);
// LIST with 1.9.3 format must also return old param names for OIDC
$list = $this -> client -> call (
Client :: METHOD_GET ,
'/project/oauth2' ,
$headers ,
);
$this -> assertSame ( 200 , $list [ 'headers' ][ 'status-code' ]);
$oidcEntry = null ;
foreach ( $list [ 'body' ][ 'providers' ] as $provider ) {
if ( $provider [ '$id' ] === 'oidc' ) {
$oidcEntry = $provider ;
break ;
}
}
$this -> assertNotNull ( $oidcEntry , 'OIDC provider missing from listOAuth2Providers response' );
$this -> assertArrayHasKey ( 'tokenUrl' , $oidcEntry );
$this -> assertArrayHasKey ( 'userInfoUrl' , $oidcEntry );
$this -> assertArrayNotHasKey ( 'tokenURL' , $oidcEntry );
$this -> assertArrayNotHasKey ( 'userInfoURL' , $oidcEntry );
$this -> assertSame ( 'https://idp.example.com/oauth2/token' , $oidcEntry [ 'tokenUrl' ]);
$this -> assertSame ( 'https://idp.example.com/oauth2/userinfo' , $oidcEntry [ 'userInfoUrl' ]);
// Cleanup
$this -> updateOAuth2 ( 'oidc' , [
'clientId' => '' ,
'clientSecret' => '' ,
'tokenURL' => '' ,
'userInfoURL' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Update Okta (clientId + clientSecret + optional domain/authServer)
// =========================================================================
public function testUpdateOAuth2Okta () : void
{
$response = $this -> updateOAuth2 ( 'okta' , [
'clientId' => '0oa00000000000000698' ,
'clientSecret' => 'okta-secret' ,
'domain' => 'trial-6400025.okta.com' ,
'authorizationServerId' => 'aus000000000000000h7z' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'okta' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( '0oa00000000000000698' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'trial-6400025.okta.com' , $response [ 'body' ][ 'domain' ]);
$this -> assertSame ( 'aus000000000000000h7z' , $response [ 'body' ][ 'authorizationServerId' ]);
2026-04-27 14:54:53 +00:00
// Cleanup
2026-04-27 14:02:19 +00:00
$this -> updateOAuth2 ( 'okta' , [
'clientId' => '' ,
'clientSecret' => '' ,
2026-04-27 14:54:53 +00:00
'domain' => '' ,
2026-04-27 14:02:19 +00:00
'authorizationServerId' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2OktaInvalidDomain () : void
{
$response = $this -> updateOAuth2 ( 'okta' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'domain' => 'https://trial-6400025.okta.com/' ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
2026-04-27 15:27:23 +00:00
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
2026-04-27 14:02:19 +00:00
}
2026-04-27 14:54:53 +00:00
public function testUpdateOAuth2OktaEnableRequiresDomain () : void
{
$this -> updateOAuth2 ( 'okta' , [
'clientId' => '' ,
'clientSecret' => '' ,
'domain' => '' ,
'authorizationServerId' => '' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'okta' , [
'clientId' => 'okta-no-domain' ,
'clientSecret' => 'okta-no-domain-secret' ,
'enabled' => true ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
// Cleanup
$this -> updateOAuth2 ( 'okta' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2OktaEnableSucceedsWithDomain () : void
{
$update = $this -> updateOAuth2 ( 'okta' , [
'clientId' => 'okta-enable-client' ,
'clientSecret' => 'okta-enable-secret' ,
'domain' => 'enable.okta.com' ,
'authorizationServerId' => 'aus000000000000000h7z' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide clientSecret while keeping clientId, domain and authServerId.
$get = $this -> getOAuth2Provider ( 'okta' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'okta-enable-client' , $get [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'enable.okta.com' , $get [ 'body' ][ 'domain' ]);
$this -> assertSame ( 'aus000000000000000h7z' , $get [ 'body' ][ 'authorizationServerId' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'clientSecret' ]);
// Cleanup
$this -> updateOAuth2 ( 'okta' , [
'clientId' => '' ,
'clientSecret' => '' ,
'domain' => '' ,
'authorizationServerId' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2OktaPartialPreservesEachField () : void
{
// Okta has no field-by-field partial test in the existing suite. Cover
// each of `domain`, `authorizationServerId`, and `clientSecret` being
// patched alone — all three live in the same JSON blob.
$this -> updateOAuth2 ( 'okta' , [
'clientId' => 'okta-merge-client' ,
'clientSecret' => 'okta-merge-secret' ,
'domain' => 'merge.okta.com' ,
'authorizationServerId' => 'aus000000000000merge' ,
'enabled' => false ,
]);
// Patch only `domain` — others must survive.
$domainOnly = $this -> updateOAuth2 ( 'okta' , [
'domain' => 'rotated.okta.com' ,
]);
$this -> assertSame ( 200 , $domainOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'rotated.okta.com' , $domainOnly [ 'body' ][ 'domain' ]);
$this -> assertSame ( 'okta-merge-client' , $domainOnly [ 'body' ][ 'clientId' ]);
$this -> assertSame ( 'aus000000000000merge' , $domainOnly [ 'body' ][ 'authorizationServerId' ]);
// Patch only `authorizationServerId`.
$authServerOnly = $this -> updateOAuth2 ( 'okta' , [
'authorizationServerId' => 'aus000000000rotated00' ,
]);
$this -> assertSame ( 200 , $authServerOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'rotated.okta.com' , $authServerOnly [ 'body' ][ 'domain' ]);
$this -> assertSame ( 'aus000000000rotated00' , $authServerOnly [ 'body' ][ 'authorizationServerId' ]);
// Patch only `clientSecret` — domain and authServerId in the JSON blob
// must survive. Confirm the rotated secret persisted by enabling.
$secretOnly = $this -> updateOAuth2 ( 'okta' , [
'clientSecret' => 'okta-rotated-secret' ,
]);
$this -> assertSame ( 200 , $secretOnly [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'rotated.okta.com' , $secretOnly [ 'body' ][ 'domain' ]);
$this -> assertSame ( 'aus000000000rotated00' , $secretOnly [ 'body' ][ 'authorizationServerId' ]);
$enable = $this -> updateOAuth2 ( 'okta' , [ 'enabled' => true ]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $enable [ 'body' ][ 'enabled' ]);
// Cleanup
$this -> updateOAuth2 ( 'okta' , [
'clientId' => '' ,
'clientSecret' => '' ,
'domain' => '' ,
'authorizationServerId' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2OktaAuthServerIdAcceptsEmpty () : void
{
// `authorizationServerId` is `Nullable(Text(256, 0))`. Passing `''`
// must clear the stored value while leaving the rest of the JSON blob
// (clientSecret, oktaDomain) untouched.
$this -> updateOAuth2 ( 'okta' , [
'clientId' => 'okta-clear-auth-server' ,
'clientSecret' => 'okta-clear-auth-server-secret' ,
'domain' => 'authserver.okta.com' ,
'authorizationServerId' => 'aus0000000000beforeauth' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'okta' , [
'authorizationServerId' => '' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'authorizationServerId' ]);
// domain (also stored in the JSON blob) must NOT have been wiped.
$this -> assertSame ( 'authserver.okta.com' , $response [ 'body' ][ 'domain' ]);
// Cleanup
$this -> updateOAuth2 ( 'okta' , [
'clientId' => '' ,
'clientSecret' => '' ,
'domain' => '' ,
'authorizationServerId' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2OktaDomainAcceptsEmpty () : void
{
2026-04-27 16:21:21 +00:00
// The `domain` validator is `Nullable(Domain(allowEmpty: true))`. Passing
2026-04-27 15:27:23 +00:00
// `''` must clear the stored value rather than 400 on Domain validation.
$this -> updateOAuth2 ( 'okta' , [
'clientId' => 'okta-clear-client' ,
'clientSecret' => 'okta-clear-secret' ,
'domain' => 'before.okta.com' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'okta' , [
'domain' => '' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( '' , $response [ 'body' ][ 'domain' ]);
// Cleanup
$this -> updateOAuth2 ( 'okta' , [
'clientId' => '' ,
'clientSecret' => '' ,
'domain' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Update Dropbox (custom param names: appKey + appSecret)
// =========================================================================
public function testUpdateOAuth2DropboxFieldNames () : void
{
$response = $this -> updateOAuth2 ( 'dropbox' , [
'appKey' => 'jl000000000009t' ,
'appSecret' => 'g200000000000vw' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'dropbox' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'jl000000000009t' , $response [ 'body' ][ 'appKey' ]);
$this -> assertArrayHasKey ( 'appSecret' , $response [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientId' , $response [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $response [ 'body' ]);
// GET enforces write-only on the secret regardless of the custom name.
$get = $this -> getOAuth2Provider ( 'dropbox' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'jl000000000009t' , $get [ 'body' ][ 'appKey' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'appSecret' ]);
// Cleanup
$this -> updateOAuth2 ( 'dropbox' , [
'appKey' => '' ,
'appSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:17:57 +00:00
public function testUpdateOAuth2DropboxPartial () : void
{
// Seed both fields, then patch only `appKey` and verify `appSecret`
// persists by enabling — Dropbox has no verifyCredentials() hook, so
// enabling succeeds purely from local state.
$this -> updateOAuth2 ( 'dropbox' , [
'appKey' => 'dropbox-seed-key' ,
'appSecret' => 'dropbox-seed-secret' ,
'enabled' => false ,
]);
$response = $this -> updateOAuth2 ( 'dropbox' , [
'appKey' => 'dropbox-updated-key' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'dropbox-updated-key' , $response [ 'body' ][ 'appKey' ]);
$enable = $this -> updateOAuth2 ( 'dropbox' , [
'enabled' => true ,
]);
$this -> assertSame ( 200 , $enable [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( true , $enable [ 'body' ][ 'enabled' ]);
// Cleanup
$this -> updateOAuth2 ( 'dropbox' , [
'appKey' => '' ,
'appSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2DropboxEnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'dropbox' , [
'appKey' => 'dropbox-enable-key' ,
'appSecret' => 'dropbox-enable-secret' ,
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide `appSecret` while keeping `appKey`.
$get = $this -> getOAuth2Provider ( 'dropbox' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'dropbox-enable-key' , $get [ 'body' ][ 'appKey' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'appSecret' ]);
// Cleanup
$this -> updateOAuth2 ( 'dropbox' , [
'appKey' => '' ,
'appSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Update Paypal Sandbox (inherits from Paypal — independent provider ID)
// =========================================================================
public function testUpdateOAuth2PaypalSandbox () : void
{
$response = $this -> updateOAuth2 ( 'paypalSandbox' , [
'clientId' => 'paypal-sandbox-client' ,
'clientSecret' => 'paypal-sandbox-secret' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'paypalSandbox' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'paypal-sandbox-client' , $response [ 'body' ][ 'clientId' ]);
// Sandbox is independent of the regular paypal entry.
$regular = $this -> getOAuth2Provider ( 'paypal' );
$this -> assertSame ( 200 , $regular [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'paypal' , $regular [ 'body' ][ '$id' ]);
$this -> assertNotSame ( 'paypal-sandbox-client' , $regular [ 'body' ][ 'clientId' ]);
// Cleanup
$this -> updateOAuth2 ( 'paypalSandbox' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2PaypalSandboxResponseModel () : void
{
// PaypalSandbox inherits from Paypal: param/response field is
// `secretKey` instead of `clientSecret`. A regression that adds the
// default `clientSecret` to the response model would leak the
// unwritten field; pin its absence on both PATCH and GET.
$update = $this -> updateOAuth2 ( 'paypalSandbox' , [
'clientId' => 'paypal-sandbox-shape' ,
'secretKey' => 'paypal-sandbox-shape-secret' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( 'secretKey' , $update [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $update [ 'body' ]);
$get = $this -> getOAuth2Provider ( 'paypalSandbox' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( 'secretKey' , $get [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $get [ 'body' ]);
// Cleanup
$this -> updateOAuth2 ( 'paypalSandbox' , [
'clientId' => '' ,
'secretKey' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2PaypalDoesNotAffectSandbox () : void
{
// Reverse direction: writing to regular paypal must leave sandbox state intact.
$this -> updateOAuth2 ( 'paypalSandbox' , [
'clientId' => 'sandbox-untouched' ,
'clientSecret' => 'sandbox-secret' ,
'enabled' => false ,
]);
$this -> updateOAuth2 ( 'paypal' , [
'clientId' => 'paypal-prod' ,
'secretKey' => 'paypal-prod-secret' ,
'enabled' => false ,
]);
$sandbox = $this -> getOAuth2Provider ( 'paypalSandbox' );
$this -> assertSame ( 200 , $sandbox [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'sandbox-untouched' , $sandbox [ 'body' ][ 'clientId' ]);
// Cleanup both
$this -> updateOAuth2 ( 'paypal' , [
'clientId' => '' ,
'secretKey' => '' ,
'enabled' => false ,
]);
$this -> updateOAuth2 ( 'paypalSandbox' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:17:57 +00:00
// =========================================================================
// Update Tradeshift Sandbox (inherits from Tradeshift — independent provider ID)
// =========================================================================
public function testUpdateOAuth2TradeshiftBox () : void
{
$response = $this -> updateOAuth2 ( 'tradeshiftBox' , [
'oauth2ClientId' => 'tradeshift-sandbox-client' ,
'oauth2ClientSecret' => 'tradeshift-sandbox-secret' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'tradeshiftBox' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( 'tradeshift-sandbox-client' , $response [ 'body' ][ 'oauth2ClientId' ]);
// Sandbox is independent of the regular tradeshift entry.
$regular = $this -> getOAuth2Provider ( 'tradeshift' );
$this -> assertSame ( 200 , $regular [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'tradeshift' , $regular [ 'body' ][ '$id' ]);
$this -> assertNotSame ( 'tradeshift-sandbox-client' , $regular [ 'body' ][ 'oauth2ClientId' ]);
// Cleanup
$this -> updateOAuth2 ( 'tradeshiftBox' , [
'oauth2ClientId' => '' ,
'oauth2ClientSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
public function testUpdateOAuth2TradeshiftBoxResponseModel () : void
{
// TradeshiftSandbox inherits from Tradeshift: both clientId AND
// clientSecret are renamed (oauth2ClientId / oauth2ClientSecret).
// Pin that the default field names are absent from PATCH and GET
// responses so a stray addition to the response model is caught.
$update = $this -> updateOAuth2 ( 'tradeshiftBox' , [
'oauth2ClientId' => 'tradeshift-box-shape' ,
'oauth2ClientSecret' => 'tradeshift-box-shape-secret' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( 'oauth2ClientId' , $update [ 'body' ]);
$this -> assertArrayHasKey ( 'oauth2ClientSecret' , $update [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientId' , $update [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $update [ 'body' ]);
$get = $this -> getOAuth2Provider ( 'tradeshiftBox' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertArrayHasKey ( 'oauth2ClientId' , $get [ 'body' ]);
$this -> assertArrayHasKey ( 'oauth2ClientSecret' , $get [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientId' , $get [ 'body' ]);
$this -> assertArrayNotHasKey ( 'clientSecret' , $get [ 'body' ]);
// Cleanup
$this -> updateOAuth2 ( 'tradeshiftBox' , [
'oauth2ClientId' => '' ,
'oauth2ClientSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:27:23 +00:00
public function testUpdateOAuth2TradeshiftDoesNotAffectSandbox () : void
{
// Reverse direction: writing to regular tradeshift must not touch sandbox state.
$this -> updateOAuth2 ( 'tradeshiftBox' , [
'oauth2ClientId' => 'tradeshift-sandbox-untouched' ,
'oauth2ClientSecret' => 'tradeshift-sandbox-secret' ,
'enabled' => false ,
]);
$this -> updateOAuth2 ( 'tradeshift' , [
'oauth2ClientId' => 'tradeshift-prod' ,
'oauth2ClientSecret' => 'tradeshift-prod-secret' ,
'enabled' => false ,
]);
$sandbox = $this -> getOAuth2Provider ( 'tradeshiftBox' );
$this -> assertSame ( 200 , $sandbox [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'tradeshift-sandbox-untouched' , $sandbox [ 'body' ][ 'oauth2ClientId' ]);
// Cleanup both
$this -> updateOAuth2 ( 'tradeshift' , [
'oauth2ClientId' => '' ,
'oauth2ClientSecret' => '' ,
'enabled' => false ,
]);
$this -> updateOAuth2 ( 'tradeshiftBox' , [
'oauth2ClientId' => '' ,
'oauth2ClientSecret' => '' ,
'enabled' => false ,
]);
}
2026-05-10 09:11:47 +00:00
// =========================================================================
// Update Google (clientId + clientSecret + optional prompt)
// =========================================================================
2026-05-10 11:19:18 +00:00
/**
* Default prompt MUST run before any other Google test that sets a custom
* prompt value . The global resetProjectOAuth2 () only clears Amazon state ,
* so Google state leaks across tests in the same class . Running this first
* guarantees the stored JSON blob has no pre - existing " prompt " key .
*/
public function testUpdateOAuth2GoogleDefaultPrompt () : void
{
// When prompt is omitted and nothing is stored, the default is ['consent'].
$response = $this -> updateOAuth2 ( 'google' , [
'clientId' => 'google-default-client' ,
'clientSecret' => 'google-default-secret' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ([ 'consent' ], $response [ 'body' ][ 'prompt' ]);
// Cleanup
$this -> updateOAuth2 ( 'google' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
2026-05-10 09:11:47 +00:00
public function testUpdateOAuth2Google () : void
{
$response = $this -> updateOAuth2 ( 'google' , [
'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com' ,
'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj' ,
'prompt' => [ 'select_account' ],
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'google' , $response [ 'body' ][ '$id' ]);
$this -> assertSame ( '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ([ 'select_account' ], $response [ 'body' ][ 'prompt' ]);
// Cleanup
$this -> updateOAuth2 ( 'google' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2GooglePartialPreservesPrompt () : void
{
// Seed clientSecret + prompt.
$this -> updateOAuth2 ( 'google' , [
'clientId' => 'google-seed-client' ,
'clientSecret' => 'google-seed-secret' ,
'prompt' => [ 'consent' , 'select_account' ],
'enabled' => false ,
]);
// Update only clientId.
$response = $this -> updateOAuth2 ( 'google' , [
'clientId' => 'google-rotated-client' ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'google-rotated-client' , $response [ 'body' ][ 'clientId' ]);
$this -> assertSame ([ 'consent' , 'select_account' ], $response [ 'body' ][ 'prompt' ]);
// Cleanup
$this -> updateOAuth2 ( 'google' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2GooglePromptNoneAloneRejected () : void
{
$response = $this -> updateOAuth2 ( 'google' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'prompt' => [ 'none' , 'consent' ],
'enabled' => false ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
2026-05-10 10:03:05 +00:00
public function testUpdateOAuth2GooglePromptEmptyArrayRejected () : void
{
$response = $this -> updateOAuth2 ( 'google' , [
'clientId' => 'whatever' ,
'clientSecret' => 'whatever' ,
'prompt' => [],
'enabled' => false ,
]);
$this -> assertSame ( 400 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( 'general_argument_invalid' , $response [ 'body' ][ 'type' ]);
}
2026-05-10 09:11:47 +00:00
public function testUpdateOAuth2GooglePromptNoneAloneAccepted () : void
{
$response = $this -> updateOAuth2 ( 'google' , [
'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com' ,
'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj' ,
'prompt' => [ 'none' ],
'enabled' => false ,
]);
$this -> assertSame ( 200 , $response [ 'headers' ][ 'status-code' ]);
$this -> assertSame ([ 'none' ], $response [ 'body' ][ 'prompt' ]);
// Cleanup
$this -> updateOAuth2 ( 'google' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
public function testUpdateOAuth2GoogleEnableAndReadBack () : void
{
$update = $this -> updateOAuth2 ( 'google' , [
'clientId' => 'google-enable-client' ,
'clientSecret' => 'google-enable-secret' ,
'prompt' => [ 'select_account' ],
'enabled' => true ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $update [ 'body' ][ 'enabled' ]);
// GET must hide clientSecret while keeping clientId and prompt.
$get = $this -> getOAuth2Provider ( 'google' );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertTrue ( $get [ 'body' ][ 'enabled' ]);
$this -> assertSame ( 'google-enable-client' , $get [ 'body' ][ 'clientId' ]);
$this -> assertSame ([ 'select_account' ], $get [ 'body' ][ 'prompt' ]);
$this -> assertSame ( '' , $get [ 'body' ][ 'clientSecret' ]);
// Cleanup
$this -> updateOAuth2 ( 'google' , [
'clientId' => '' ,
'clientSecret' => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:17:57 +00:00
// =========================================================================
// Smoke test: every plain (clientId + clientSecret) provider
//
// Ensures each provider's Update endpoint is wired up correctly: routing,
// provider class, response model and `$id`. Custom-shaped providers
2026-04-30 10:48:59 +00:00
// (apple, auth0, authentik, fusionauth, gitlab, keycloak, microsoft, oidc,
// okta, dropbox) and sandboxes (paypalSandbox, tradeshiftSandbox) have
// dedicated tests above.
2026-04-27 15:17:57 +00:00
// Github is excluded because its `verifyCredentials()` hook is exercised
// separately.
// =========================================================================
/**
* Provider , ID - field , secret - field . Many providers rename one or both of
* the two credential params ( `clientId` / `clientSecret` ) to match the
* upstream provider ' s terminology , so the smoke test parameterises both .
*
* @ return array < string , array < string >>
*/
public static function plainProviders () : array
{
return [
'discord' => [ 'discord' , 'clientId' , 'clientSecret' ],
'figma' => [ 'figma' , 'clientId' , 'clientSecret' ],
'dailymotion' => [ 'dailymotion' , 'apiKey' , 'apiSecret' ],
'bitbucket' => [ 'bitbucket' , 'key' , 'secret' ],
'bitly' => [ 'bitly' , 'clientId' , 'clientSecret' ],
'box' => [ 'box' , 'clientId' , 'clientSecret' ],
'autodesk' => [ 'autodesk' , 'clientId' , 'clientSecret' ],
'google' => [ 'google' , 'clientId' , 'clientSecret' ],
'zoom' => [ 'zoom' , 'clientId' , 'clientSecret' ],
'zoho' => [ 'zoho' , 'clientId' , 'clientSecret' ],
'yandex' => [ 'yandex' , 'clientId' , 'clientSecret' ],
'x' => [ 'x' , 'customerKey' , 'secretKey' ],
'wordpress' => [ 'wordpress' , 'clientId' , 'clientSecret' ],
'twitch' => [ 'twitch' , 'clientId' , 'clientSecret' ],
'stripe' => [ 'stripe' , 'clientId' , 'apiSecretKey' ],
'spotify' => [ 'spotify' , 'clientId' , 'clientSecret' ],
'slack' => [ 'slack' , 'clientId' , 'clientSecret' ],
'podio' => [ 'podio' , 'clientId' , 'clientSecret' ],
'notion' => [ 'notion' , 'oauthClientId' , 'oauthClientSecret' ],
'salesforce' => [ 'salesforce' , 'customerKey' , 'customerSecret' ],
'yahoo' => [ 'yahoo' , 'clientId' , 'clientSecret' ],
'linkedin' => [ 'linkedin' , 'clientId' , 'primaryClientSecret' ],
'disqus' => [ 'disqus' , 'publicKey' , 'secretKey' ],
'etsy' => [ 'etsy' , 'keyString' , 'sharedSecret' ],
'facebook' => [ 'facebook' , 'appId' , 'appSecret' ],
'tradeshift' => [ 'tradeshift' , 'oauth2ClientId' , 'oauth2ClientSecret' ],
'paypal' => [ 'paypal' , 'clientId' , 'secretKey' ],
'kick' => [ 'kick' , 'clientId' , 'clientSecret' ],
];
}
#[DataProvider('plainProviders')]
public function testUpdateOAuth2PlainProvider ( string $providerId , string $idField , string $secretField ) : void
{
$clientId = $providerId . '-smoke-client' ;
$clientSecret = $providerId . '-smoke-secret' ;
2026-04-27 15:27:23 +00:00
$update = $this -> updateOAuth2 ( $providerId , [
2026-04-27 15:17:57 +00:00
$idField => $clientId ,
$secretField => $clientSecret ,
'enabled' => false ,
]);
2026-04-27 15:27:23 +00:00
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( $providerId , $update [ 'body' ][ '$id' ]);
$this -> assertSame ( $clientId , $update [ 'body' ][ $idField ]);
$this -> assertFalse ( $update [ 'body' ][ 'enabled' ]);
// GET round-trip — confirms the value actually persisted (catches a
// PATCH that only echoes input without writing) and that the secret
// is hidden on read.
$get = $this -> getOAuth2Provider ( $providerId );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
$this -> assertSame ( $providerId , $get [ 'body' ][ '$id' ]);
$this -> assertSame ( $clientId , $get [ 'body' ][ $idField ]);
$this -> assertSame ( '' , $get [ 'body' ][ $secretField ]);
$this -> assertFalse ( $get [ 'body' ][ 'enabled' ]);
2026-04-27 15:17:57 +00:00
// Cleanup
$this -> updateOAuth2 ( $providerId , [
$idField => '' ,
$secretField => '' ,
'enabled' => false ,
]);
}
2026-04-27 15:40:49 +00:00
/**
* For providers that rename `clientId` / `clientSecret` to a custom field
* ( e . g . `apiKey` / `apiSecret` , `customerKey` / `secretKey` , `oauthClientId` ),
* the renamed field replaces the default — the response model must NOT
* also expose the default name . Catches a regression where adding a
* custom param name forgets to remove the default from the response .
*/
#[DataProvider('plainProviders')]
public function testUpdateOAuth2PlainProviderResponseDoesNotLeakDefaultNames ( string $providerId , string $idField , string $secretField ) : void
{
if ( $idField === 'clientId' && $secretField === 'clientSecret' ) {
// Default-named provider — nothing to leak. Avoids a no-op assertion.
$this -> markTestSkipped ( " { $providerId } uses default field names. " );
}
$update = $this -> updateOAuth2 ( $providerId , [
$idField => $providerId . '-leak-check-id' ,
$secretField => $providerId . '-leak-check-secret' ,
'enabled' => false ,
]);
$this -> assertSame ( 200 , $update [ 'headers' ][ 'status-code' ]);
if ( $idField !== 'clientId' ) {
$this -> assertArrayNotHasKey ( 'clientId' , $update [ 'body' ], " PATCH response for { $providerId } leaks default `clientId` despite using ` { $idField } `. " );
}
if ( $secretField !== 'clientSecret' ) {
$this -> assertArrayNotHasKey ( 'clientSecret' , $update [ 'body' ], " PATCH response for { $providerId } leaks default `clientSecret` despite using ` { $secretField } `. " );
}
$get = $this -> getOAuth2Provider ( $providerId );
$this -> assertSame ( 200 , $get [ 'headers' ][ 'status-code' ]);
if ( $idField !== 'clientId' ) {
$this -> assertArrayNotHasKey ( 'clientId' , $get [ 'body' ], " GET response for { $providerId } leaks default `clientId` despite using ` { $idField } `. " );
}
if ( $secretField !== 'clientSecret' ) {
$this -> assertArrayNotHasKey ( 'clientSecret' , $get [ 'body' ], " GET response for { $providerId } leaks default `clientSecret` despite using ` { $secretField } `. " );
}
// Cleanup
$this -> updateOAuth2 ( $providerId , [
$idField => '' ,
$secretField => '' ,
'enabled' => false ,
]);
}
2026-04-27 14:02:19 +00:00
// =========================================================================
// Helpers
// =========================================================================
/**
* @ param array < string , mixed > $params
*/
protected function updateOAuth2 ( string $provider , array $params , bool $authenticated = true ) : mixed
{
$headers = [
'content-type' => 'application/json' ,
'x-appwrite-project' => $this -> getProject ()[ '$id' ],
];
if ( $authenticated ) {
$headers = \array_merge ( $headers , $this -> getHeaders ());
}
return $this -> client -> call (
Client :: METHOD_PATCH ,
'/project/oauth2/' . $provider ,
$headers ,
$params ,
);
}
2026-05-04 13:40:32 +00:00
protected function getOAuth2Provider ( string $providerId , bool $authenticated = true ) : mixed
2026-04-27 14:02:19 +00:00
{
$headers = [
'content-type' => 'application/json' ,
'x-appwrite-project' => $this -> getProject ()[ '$id' ],
];
if ( $authenticated ) {
$headers = \array_merge ( $headers , $this -> getHeaders ());
}
return $this -> client -> call (
Client :: METHOD_GET ,
2026-05-04 13:40:32 +00:00
'/project/oauth2/' . $providerId ,
2026-04-27 14:02:19 +00:00
$headers ,
);
}
2026-05-04 14:05:56 +00:00
protected function listOAuth2Providers ( ? array $queries = null , ? bool $total = null , bool $authenticated = true ) : mixed
2026-04-27 14:02:19 +00:00
{
2026-05-04 14:05:56 +00:00
$params = [];
if ( $queries !== null ) {
$params [ 'queries' ] = $queries ;
}
if ( $total !== null ) {
$params [ 'total' ] = $total ;
}
2026-04-27 14:02:19 +00:00
$headers = [
'content-type' => 'application/json' ,
'x-appwrite-project' => $this -> getProject ()[ '$id' ],
];
if ( $authenticated ) {
$headers = \array_merge ( $headers , $this -> getHeaders ());
}
return $this -> client -> call (
Client :: METHOD_GET ,
'/project/oauth2' ,
$headers ,
2026-05-04 14:05:56 +00:00
$params ,
2026-04-27 14:02:19 +00:00
);
}
2026-04-27 13:51:54 +00:00
}