diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index ac9dd0fcb8..95f47cdabc 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -18,46 +18,19 @@ import Realm, { AppConfiguration } from "realm"; -import { AppConfig, AppImporter, Credentials } from "@realm/app-importer"; +import { AppConfig } from "@realm/app-importer"; import { mongodbServiceType } from "../utils/ExtendedAppConfigBuilder"; import { printWarningBox } from "../utils/print-warning-box"; +import { baasAppImporter } from "../utils/baas-app-importer"; const REALM_LOG_LEVELS = ["all", "trace", "debug", "detail", "info", "warn", "error", "fatal", "off"]; -const { - syncLogLevel = "warn", - baseUrl = "http://localhost:9090", - reuseApp = false, - username = "unique_user@domain.com", - password = "password", - publicKey, - privateKey, - missingServer, -} = environment; +const { syncLogLevel = "warn", baseUrl = "http://localhost:9090", missingServer } = environment; export { baseUrl }; const allowSkippingServerTests = typeof environment.baseUrl === "undefined" && missingServer !== false; -const credentials: Credentials = - typeof publicKey === "string" && typeof privateKey === "string" - ? { - kind: "api-key", - publicKey, - privateKey, - } - : { - kind: "username-password", - username, - password, - }; - -const importer = new AppImporter({ - baseUrl, - credentials, - reuseApp, -}); - function isConnectionRefused(err: unknown) { return ( err instanceof Error && @@ -105,7 +78,7 @@ export function importAppBefore( throw new Error("Unexpected app on context, use only one importAppBefore per test"); } else { try { - const { appId } = await importer.importApp(config); + const { appId } = await baasAppImporter.importApp(config); this.app = new Realm.App({ id: appId, baseUrl, ...sdkConfig }); } catch (err) { if (isConnectionRefused(err) && allowSkippingServerTests) { diff --git a/integration-tests/tests/src/tests/sync/client-reset.ts b/integration-tests/tests/src/tests/sync/client-reset.ts index 8a3a4bf850..d0e53106bd 100644 --- a/integration-tests/tests/src/tests/sync/client-reset.ts +++ b/integration-tests/tests/src/tests/sync/client-reset.ts @@ -32,6 +32,7 @@ import { DogSchema, PersonSchema } from "../../schemas/person-and-dog-with-objec import { expectClientResetError } from "../../utils/expect-sync-error"; import { createPromiseHandle } from "../../utils/promise-handle"; import { buildAppConfig } from "../../utils/build-app-config"; +import { baasAdminClient } from "../../utils/baas-admin-api"; const FlexiblePersonSchema = { ...PersonSchema, properties: { ...PersonSchema.properties, nonQueryable: "string?" } }; const FlexibleDogSchema = { ...DogSchema, properties: { ...DogSchema.properties, nonQueryable: "string?" } }; @@ -53,18 +54,16 @@ function getPartitionValue() { return new BSON.UUID().toHexString(); } -async function triggerClientReset(app: App, user: User): Promise { - const maxAttempts = 5; - let deleted = false; - let count = maxAttempts; - while (count > 0) { - deleted = (await user.functions.triggerClientReset(app.id, user.id)) as boolean; - if (deleted) { - return; - } - count--; +async function triggerClientReset(app: App, syncSession: Realm.App.Sync.Session): Promise { + const { fileIdent } = syncSession as unknown as Record; + if (typeof fileIdent !== "bigint") { + throw new Error("Expected the internal file ident"); } - throw new Error(`Cannot trigger client reset in ${maxAttempts} attempts`); + await baasAdminClient.ensureLogIn(); + const { _id } = await baasAdminClient.getAppByClientAppId(app.id); + syncSession.pause(); + await baasAdminClient.forceSyncReset(_id, Number(fileIdent)); + syncSession.resume(); } async function waitServerSideClientResetDiscardUnsyncedChangesCallbacks( @@ -109,8 +108,13 @@ async function waitServerSideClientResetDiscardUnsyncedChangesCallbacks( addSubscriptions(realm); } - await realm.syncSession?.uploadAllLocalChanges(); - await triggerClientReset(app, user); + const { syncSession } = realm; + if (!syncSession) { + throw new Error("Expected a sync session"); + } + + await syncSession.uploadAllLocalChanges(); + await triggerClientReset(app, syncSession); await resetHandle; } @@ -157,8 +161,13 @@ async function waitServerSideClientResetRecoveryCallbacks( addSubscriptions(realm); } - await realm.syncSession?.uploadAllLocalChanges(); - await triggerClientReset(app, user); + const { syncSession } = realm; + if (!syncSession) { + throw new Error("Expected a sync session"); + } + + await syncSession.uploadAllLocalChanges(); + await triggerClientReset(app, syncSession); await resetHandle; } @@ -300,8 +309,8 @@ function getSchema(useFlexibleSync: boolean) { this.longTimeout(); // client reset with flexible sync can take quite some time importAppBefore( useFlexibleSync - ? buildAppConfig("with-flx").anonAuth().flexibleSync() /* .triggerClientResetFunction() */ - : buildAppConfig("with-pbs").anonAuth().partitionBasedSync() /* .triggerClientResetFunction() */, + ? buildAppConfig("with-flx").anonAuth().flexibleSync() + : buildAppConfig("with-pbs").anonAuth().partitionBasedSync(), ); authenticateUserBefore(); @@ -510,7 +519,7 @@ function getSchema(useFlexibleSync: boolean) { ); }); - it.skip(`handles discard local client reset with ${getPartialTestTitle( + it(`handles discard local client reset with ${getPartialTestTitle( useFlexibleSync, )} sync enabled`, async function (this: RealmContext) { // (i) using a client reset in "DiscardUnsyncedChanges" mode, a fresh copy @@ -536,7 +545,7 @@ function getSchema(useFlexibleSync: boolean) { ); }); - it.skip(`handles recovery client reset with ${getPartialTestTitle( + it(`handles recovery client reset with ${getPartialTestTitle( useFlexibleSync, )} sync enabled`, async function (this: RealmContext) { // (i) using a client reset in "Recovery" mode, a fresh copy diff --git a/integration-tests/tests/src/utils/baas-admin-api.ts b/integration-tests/tests/src/utils/baas-admin-api.ts new file mode 100644 index 0000000000..22d0b0b4c6 --- /dev/null +++ b/integration-tests/tests/src/utils/baas-admin-api.ts @@ -0,0 +1,42 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import { AdminApiClient, Credentials } from "@realm/app-importer"; + +const { + baseUrl = "http://localhost:9090", + username = "unique_user@domain.com", + password = "password", + publicKey, + privateKey, +} = environment; + +export const credentials: Credentials = + typeof publicKey === "string" && typeof privateKey === "string" + ? { + kind: "api-key", + publicKey, + privateKey, + } + : { + kind: "username-password", + username, + password, + }; + +export const baasAdminClient = new AdminApiClient({ baseUrl, credentials }); diff --git a/integration-tests/tests/src/utils/baas-app-importer.ts b/integration-tests/tests/src/utils/baas-app-importer.ts new file mode 100644 index 0000000000..51dd80ced1 --- /dev/null +++ b/integration-tests/tests/src/utils/baas-app-importer.ts @@ -0,0 +1,28 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import { AppImporter } from "@realm/app-importer"; + +import { baasAdminClient } from "./baas-admin-api"; + +const { reuseApp = false } = environment; + +export const baasAppImporter = new AppImporter({ + client: baasAdminClient, + reuseApp, +}); diff --git a/packages/realm-app-importer/src/AdminApiClient.ts b/packages/realm-app-importer/src/AdminApiClient.ts index b548bde237..53f5684f7e 100644 --- a/packages/realm-app-importer/src/AdminApiClient.ts +++ b/packages/realm-app-importer/src/AdminApiClient.ts @@ -53,6 +53,7 @@ export type Credentials = export type AuthenticationMode = "access" | "refresh" | "none"; type ClientFetchRequest = Omit & { + baseRoute?: string; route: string[]; headers?: Record; authentication?: AuthenticationMode; @@ -61,6 +62,11 @@ type ClientFetchRequest = Omit & { type AdminApiClientConfig = { baseUrl: string; + + /** + * Administrative credentials to use when authenticating against the server. + */ + credentials: Credentials; }; /** @@ -68,12 +74,13 @@ type AdminApiClientConfig = { */ export class AdminApiClient { private static readonly baseRoute = "api/admin/v3.0"; + private static readonly privateBaseRoute = "api/private/v1.0"; private accessToken: string | null = null; private refreshToken: string | null = null; private _groupId: Promise | null = null; - constructor(private config: AdminApiClientConfig) {} + constructor(public readonly config: AdminApiClientConfig) {} public get groupId(): Promise { if (!this._groupId) { @@ -114,9 +121,9 @@ export class AdminApiClient { } } - public async ensureLogIn(credentials: Credentials) { + public async ensureLogIn() { if (!this.accessToken) { - await this.logIn(credentials); + await this.logIn(this.config.credentials); } } @@ -341,6 +348,14 @@ export class AdminApiClient { }); } + public async forceSyncReset(appId: string, fileIdent: number) { + await this.fetch({ + route: ["groups", await this.groupId, "apps", appId, "sync", "force_reset"], + method: "PUT", + body: { file_ident: fileIdent }, + }); + } + public async applyAllowedRequestOrigins(appId: string, origins: string[]) { await this.fetch({ route: ["groups", await this.groupId, "apps", appId, "security", "allowed_request_origins"], @@ -363,8 +378,15 @@ export class AdminApiClient { } private async fetch(request: ClientFetchRequest): Promise { - const { route, body, headers = {}, authentication = "access", ...rest } = request; - const url = [this.config.baseUrl, AdminApiClient.baseRoute, ...route].join("/"); + const { + baseRoute = AdminApiClient.baseRoute, + route, + body, + headers = {}, + authentication = "access", + ...rest + } = request; + const url = [this.config.baseUrl, baseRoute, ...route].join("/"); try { if (authentication === "access") { if (!this.accessToken) { @@ -394,7 +416,7 @@ export class AdminApiClient { const error = isErrorResponse(json) ? json.error : "No error message"; throw new Error(`Failed to fetch ${url}: ${error} (${response.status} ${response.statusText})`); } else { - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText})`); + throw new Error(`Failed to fetch ${url} (${response.status} ${response.statusText})`); } } catch (err) { if (err instanceof Error && err.message.includes("invalid session: access token expired")) { diff --git a/packages/realm-app-importer/src/AppImporter.ts b/packages/realm-app-importer/src/AppImporter.ts index e9e0978c52..877054f0ab 100644 --- a/packages/realm-app-importer/src/AppImporter.ts +++ b/packages/realm-app-importer/src/AppImporter.ts @@ -43,13 +43,9 @@ type App = { }; export interface AppImporterOptions { /** - * The server's URL. + * The client to use when importing. */ - baseUrl: string; - /** - * Administrative credentials to use when authenticating against the server. - */ - credentials: Credentials; + client: AdminApiClient; /** * Re-use a single app instead of importing individual apps. * This will redeploy a previously known "clean" version of the app (to revert any configurations) and delete secrets off the app, @@ -64,20 +60,16 @@ export interface AppImporterOptions { } export class AppImporter { - private readonly baseUrl: string; - private readonly credentials: Credentials; private readonly reuseApp: boolean; private readonly awaitDeployments: boolean; private readonly client: AdminApiClient; private initialDeployment: Deployment | null = null; private reusedApp: App | null = null; - constructor({ baseUrl, credentials, reuseApp = false, awaitDeployments = false }: AppImporterOptions) { - this.baseUrl = baseUrl; - this.credentials = credentials; + constructor({ client, reuseApp = false, awaitDeployments = false }: AppImporterOptions) { this.reuseApp = reuseApp; this.awaitDeployments = awaitDeployments; - this.client = new AdminApiClient({ baseUrl }); + this.client = client; } public async createOrReuseApp(name: string) { @@ -126,7 +118,7 @@ export class AppImporter { * @returns A promise of an object containing the app id. */ public async importApp(config: AppConfig): Promise { - await this.client.ensureLogIn(this.credentials); + await this.client.ensureLogIn(); const app = await this.createOrReuseApp(config.name); try { @@ -141,7 +133,7 @@ export class AppImporter { } debug(`The application ${app.client_app_id} was successfully deployed:`); - debug(`${this.baseUrl}/groups/${await this.client.groupId}/apps/${app._id}/dashboard`); + debug(`${this.client.config.baseUrl}/groups/${await this.client.groupId}/apps/${app._id}/dashboard`); return { appName: config.name, appId: app.client_app_id }; } catch (err) { diff --git a/packages/realm-app-importer/src/index.ts b/packages/realm-app-importer/src/index.ts index 391c7c15a7..d986fcbf5b 100644 --- a/packages/realm-app-importer/src/index.ts +++ b/packages/realm-app-importer/src/index.ts @@ -31,5 +31,7 @@ export type { CustomTokenAuthMetadataField, EmailPasswordAuthConfig, } from "./AppConfigBuilder"; + +export { AdminApiClient } from "./AdminApiClient"; export { AppImporter } from "./AppImporter"; export { AppConfigBuilder } from "./AppConfigBuilder"; diff --git a/packages/realm-react/src/__tests__/helpers.ts b/packages/realm-react/src/__tests__/helpers.ts index 5c03118827..93f3f4b699 100644 --- a/packages/realm-react/src/__tests__/helpers.ts +++ b/packages/realm-react/src/__tests__/helpers.ts @@ -19,7 +19,7 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -import { AppConfig, AppImporter, Credentials } from "@realm/app-importer"; +import { AdminApiClient, AppConfig, AppImporter, Credentials } from "@realm/app-importer"; import { act, waitFor } from "@testing-library/react-native"; const { @@ -47,25 +47,22 @@ export async function testAuthOperation({ }); } -function getCredentials(): Credentials { - if (typeof publicKey === "string" && typeof privateKey === "string") { - return { - kind: "api-key", - publicKey, - privateKey, - }; - } else { - return { - kind: "username-password", - username, - password, - }; - } -} - const importer = new AppImporter({ - baseUrl, - credentials: getCredentials(), + client: new AdminApiClient({ + baseUrl, + credentials: + typeof publicKey === "string" && typeof privateKey === "string" + ? { + kind: "api-key", + publicKey, + privateKey, + } + : { + kind: "username-password", + username, + password, + }, + }), }); export async function importApp(config: AppConfig): Promise<{ appId: string }> { diff --git a/packages/realm-web-integration-tests/harness/import-realm-app.ts b/packages/realm-web-integration-tests/harness/import-realm-app.ts index 4f0cebf71c..9a56c15419 100644 --- a/packages/realm-web-integration-tests/harness/import-realm-app.ts +++ b/packages/realm-web-integration-tests/harness/import-realm-app.ts @@ -16,7 +16,7 @@ // //////////////////////////////////////////////////////////////////////////// -import { AppImporter, AppConfigBuilder } from "@realm/app-importer"; +import { AppImporter, AppConfigBuilder, AdminApiClient } from "@realm/app-importer"; const { BAAS_BASE_URL = "http://localhost:9090", @@ -33,12 +33,14 @@ export async function importRealmApp() { return { appId: BAAS_APP_ID, baseUrl }; } else { const importer = new AppImporter({ - baseUrl, - credentials: { - kind: "username-password", - username: BAAS_USERNAME, - password: BAAS_PASSWORD, - }, + client: new AdminApiClient({ + baseUrl, + credentials: { + kind: "username-password", + username: BAAS_USERNAME, + password: BAAS_PASSWORD, + }, + }), }); const builder = new AppConfigBuilder("my-test-app") .security({ allowed_request_origins: ["http://localhost:8080"] })