diff --git a/app/components/Launcher.vue b/app/components/Launcher.vue index 9e68e186d..d6747d3c2 100644 --- a/app/components/Launcher.vue +++ b/app/components/Launcher.vue @@ -3,6 +3,7 @@ import Loading from "@ogw_front/components/Loading.vue"; import Recaptcha from "@ogw_front/components/Recaptcha.vue"; import { Status } from "@ogw_front/utils/status"; import { appMode } from "@ogw_shared/app_mode"; +import { getCloudUrlParam } from "@ogw_front/utils/cloud"; import { useInfraStore } from "@ogw_front/stores/infra"; interface Props { @@ -15,8 +16,10 @@ interface Props { const { appName, email = undefined, isUserAuthenticated = false, logo = "" } = defineProps(); const infraStore = useInfraStore(); -if (infraStore.app_mode !== appMode.CLOUD) { - infraStore.create_backend(); +// In cloud mode, wait for the user to authenticate and load the app, unless a `?cloud_url=` link +// Points at an already running service: that one is connected straight away, without authentication. +if (infraStore.app_mode !== appMode.CLOUD || getCloudUrlParam() !== undefined) { + infraStore.create_backend(email); } function cloudCreateBackend(): Promise { diff --git a/app/stores/back.ts b/app/stores/back.ts index d9b340aed..69987d195 100644 --- a/app/stores/back.ts +++ b/app/stores/back.ts @@ -29,12 +29,16 @@ export const useBackStore = defineStore("back", { return getRestApiPort(this.default_local_port); }, base_url(): string { - const infraStore = useInfraStore(); - let back_url = `${this.protocol}://${infraStore.domain_name}:${this.port}`; - if (isCloudMode()) { - back_url += `/geode`; - } - return back_url; + return this.base_url_for(useInfraStore().domain_name); + }, + base_url_for(): (domain_name: string) => string { + return (domain_name: string) => { + let back_url = `${this.protocol}://${domain_name}:${this.port}`; + if (isCloudMode()) { + back_url += `/geode`; + } + return back_url; + }; }, is_busy(): boolean { return this.request_counter > 0; diff --git a/app/stores/cloud.ts b/app/stores/cloud.ts index dfbed975f..cbf49fc5c 100644 --- a/app/stores/cloud.ts +++ b/app/stores/cloud.ts @@ -1,6 +1,10 @@ +import { clearCloudUrlParam, getCloudUrlParam } from "@ogw_front/utils/cloud"; import { Status } from "@ogw_front/utils/status"; +import { api_fetch } from "@ogw_internal/utils/api_fetch"; +import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_schemas.json"; import { setAppBaseUrl } from "@ogw_shared/scripts"; import { useAPIStore } from "@ogw_front/stores/api"; +import { useFeedbackStore } from "@ogw_front/stores/feedback"; const run_cloud_schema = { $id: "/cloud/run", @@ -20,13 +24,24 @@ export const useCloudStore = defineStore("cloud", { status: Status.NOT_CONNECTED, }), actions: { + // Reuses the running service given by `?cloud_url=` when present, otherwise launches a new one. + // The param is single use: it is cleared whatever the outcome, so a retry launches a new service. + async start(email: string) { + const existing_host = getCloudUrlParam(); + if (existing_host === undefined) { + await this.launch(email); + return; + } + try { + await this.connect_existing(existing_host); + } finally { + clearCloudUrlParam(); + } + }, async launch(email: string) { this.status = Status.CONNECTING; const { PROJECT, BRANCH } = useRuntimeConfig().public; const params = { email, project: PROJECT, branch: BRANCH }; - const { useAppStore } = await import("./app"); - const { useFeedbackStore } = await import("./feedback"); - const appStore = useAppStore(); const feedbackStore = useFeedbackStore(); const APIStore = useAPIStore(); const result = await APIStore.request( @@ -45,18 +60,7 @@ export const useCloudStore = defineStore("cloud", { ) { return; } - const { url } = response; - feedbackStore.$patch({ server_error: false }); - this.status = Status.CONNECTED; - const { useInfraStore } = await import("./infra"); - const infraStore = useInfraStore(); - infraStore.$patch({ - domain_name: url, - }); - setAppBaseUrl(appStore.base_url).catch(() => undefined); - appStore.$patch({ - projectFolderPath: "/project", - }); + await this.on_connected(response.url); }, response_error_function: () => { feedbackStore.$patch({ server_error: true }); @@ -66,6 +70,46 @@ export const useCloudStore = defineStore("cloud", { ); return result; }, + async connect_existing(host: string) { + this.status = Status.CONNECTING; + const { useBackStore } = await import("./back"); + const backStore = useBackStore(); + // Probe the candidate host directly so domain_name is only updated once the service answers. + try { + await api_fetch( + { + $id: backStore.$id, + base_url: backStore.base_url_for(host), + start_request: () => { + backStore.start_request(); + }, + stop_request: () => { + backStore.stop_request(); + }, + }, + { schema: back_schemas.opengeodeweb_back.ping }, + ); + } catch (error) { + useFeedbackStore().$patch({ server_error: true }); + this.status = Status.NOT_CONNECTED; + throw error; + } + await this.on_connected(host); + }, + async on_connected(url: string) { + const { useAppStore } = await import("./app"); + const { useInfraStore } = await import("./infra"); + const appStore = useAppStore(); + useFeedbackStore().$patch({ server_error: false }); + this.status = Status.CONNECTED; + useInfraStore().$patch({ + domain_name: url, + }); + appStore.$patch({ + projectFolderPath: "/project", + }); + await setAppBaseUrl(appStore.base_url); + }, connect() { this.status = Status.CONNECTED; }, diff --git a/app/stores/infra.ts b/app/stores/infra.ts index e42642b10..206b17a47 100644 --- a/app/stores/infra.ts +++ b/app/stores/infra.ts @@ -55,7 +55,13 @@ export const useInfraStore = defineStore("infra", { this.status = Status.CREATING; if (this.app_mode === appMode.CLOUD) { const cloudStore = useCloudStore(); - await cloudStore.launch(email ?? ""); + try { + await cloudStore.start(email ?? ""); + } catch (error) { + // Back to the "Load the app" button so the user is not stuck on the loading screen. + this.status = Status.NOT_CREATED; + throw error; + } } else { const { useAppStore } = await import("@ogw_front/stores/app"); const appStore = useAppStore(); diff --git a/app/utils/cloud.ts b/app/utils/cloud.ts new file mode 100644 index 000000000..a55cbdd9f --- /dev/null +++ b/app/utils/cloud.ts @@ -0,0 +1,35 @@ +const CLOUD_URL_PARAM = "cloud_url"; +const CLOUD_RUN_HOST_SUFFIX = ".run.app"; + +// Hostname of an already running Cloud Run service passed as `?cloud_url=`, restricted to Cloud Run hosts so a crafted link cannot point the app at an arbitrary server. +function getCloudUrlParam(): string | undefined { + const value = new URLSearchParams(globalThis.location.search).get(CLOUD_URL_PARAM); + if (value === null || value === "") { + return undefined; + } + const url_string = value.includes("://") ? value : `https://${value}`; + let hostname = ""; + try { + ({ hostname } = new URL(url_string)); + } catch { + console.warn(`[Cloud] Invalid ${CLOUD_URL_PARAM}:`, value); + return undefined; + } + if (!hostname.endsWith(CLOUD_RUN_HOST_SUFFIX)) { + console.warn(`[Cloud] Ignoring ${CLOUD_URL_PARAM}, not a Cloud Run host:`, hostname); + return undefined; + } + return hostname; +} + +// Drops `?cloud_url=` from the address bar, keeping the router's history state, so a later "Load the app" launches a new service. +function clearCloudUrlParam(): void { + const url = new URL(globalThis.location.href); + if (!url.searchParams.has(CLOUD_URL_PARAM)) { + return; + } + url.searchParams.delete(CLOUD_URL_PARAM); + globalThis.history.replaceState(globalThis.history.state, "", url); +} + +export { clearCloudUrlParam, getCloudUrlParam }; diff --git a/tests/unit/stores/cloud.nuxt.test.ts b/tests/unit/stores/cloud.nuxt.test.ts index 99d09fe7c..a18bf84bb 100644 --- a/tests/unit/stores/cloud.nuxt.test.ts +++ b/tests/unit/stores/cloud.nuxt.test.ts @@ -2,10 +2,11 @@ // oxlint-disable vitest/require-mock-type-parameters // Third party imports import { $fetch, type FetchContext, type FetchResponse } from "ofetch"; -import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; // Local imports import { Status } from "@ogw_front/utils/status"; +import { appMode } from "@ogw_shared/app_mode"; import { setupActivePinia } from "@ogw_tests/utils"; import { useCloudStore } from "@ogw_front/stores/cloud"; import { useFeedbackStore } from "@ogw_front/stores/feedback"; @@ -14,7 +15,11 @@ import { useInfraStore } from "@ogw_front/stores/infra"; // A hand-built `$Fetch`: a mock function plus the `raw`/`native`/`create` members the real // `ofetch` export carries, so it satisfies the real type directly (no unsafe cast needed). vi.mock(import("ofetch"), () => ({ - $fetch: Object.assign(vi.fn(), { raw: vi.fn(), native: vi.fn(), create: vi.fn() }), + $fetch: Object.assign(vi.fn(), { + raw: vi.fn(), + native: vi.fn(), + create: vi.fn(), + }), })); const mockedFetch = vi.mocked($fetch); @@ -26,6 +31,12 @@ const CLOUD_API_URL = "https://api.example.com"; const EMAIL = "noreply@example.com"; const RESPONSE_OK_STATUS = 200; const RESPONSE_ERROR_STATUS = 500; +const CLOUD_RUN_HOST = "vease-abc123.europe-west1.run.app"; + +function setCloudUrlParam(value?: string): void { + const search = value === undefined ? "" : `?cloud_url=${encodeURIComponent(value)}`; + globalThis.history.replaceState(undefined, "", `/${search}`); +} function setupConfig(): void { const config = useRuntimeConfig(); @@ -49,7 +60,9 @@ function buildFetchResponse( responseData: ResponseData, status: number, ): FetchResponse { - const response: FetchResponse = Response.json(responseData, { status }); + const response: FetchResponse = Response.json(responseData, { + status, + }); response._data = responseData; return response; } @@ -121,7 +134,10 @@ describe("cloud store", () => { const cloudStore = useCloudStore(); const feedbackStore = useFeedbackStore(); - const error = createError({ statusCode: 500, statusMessage: "500 Internal Server Error" }); + const error = createError({ + statusCode: 500, + statusMessage: "500 Internal Server Error", + }); mockedFetch.mockImplementation((_route, options) => { const onResponseErrorHooks = toHookArray(options?.onResponseError); @@ -143,6 +159,130 @@ describe("cloud store", () => { }); }); + describe("connect_existing", () => { + beforeEach(() => { + mockedFetch.mockReset(); + useInfraStore().app_mode = appMode.CLOUD; + }); + + test("successful connect_existing", async () => { + const cloudStore = useCloudStore(); + const feedbackStore = useFeedbackStore(); + + mockedFetch.mockImplementation(async (_route, options) => { + const data = {}; + const onResponseHooks = toHookArray(options?.onResponse); + await Promise.all( + onResponseHooks.map(async (hook) => { + await hook(buildFetchContext(data, RESPONSE_OK_STATUS)); + }), + ); + return data; + }); + + await cloudStore.connect_existing(CLOUD_RUN_HOST); + + expect(mockedFetch).toHaveBeenCalledWith( + "opengeodeweb_back/ping", + expect.objectContaining({ + baseURL: `https://${CLOUD_RUN_HOST}:443/geode`, + }), + ); + expect(mockedFetch).not.toHaveBeenCalledWith("/cloud/run", expect.anything()); + expect(cloudStore.status).toBe(Status.CONNECTED); + expect(feedbackStore.server_error).toBe(false); + expect(useInfraStore().domain_name).toBe(CLOUD_RUN_HOST); + }); + + test("failed connect_existing - unreachable service", async () => { + const cloudStore = useCloudStore(); + const feedbackStore = useFeedbackStore(); + const infraStore = useInfraStore(); + const previous_domain_name = infraStore.domain_name; + + const error = createError({ + statusCode: 500, + statusMessage: "500 Internal Server Error", + }); + mockedFetch.mockImplementation((_route, options) => { + const onResponseErrorHooks = toHookArray(options?.onResponseError); + for (const hook of onResponseErrorHooks) { + void hook( + buildFetchContext( + { name: "Error", description: "500 Internal Server Error" }, + RESPONSE_ERROR_STATUS, + ), + ); + } + throw error; + }); + + await expect(cloudStore.connect_existing(CLOUD_RUN_HOST)).rejects.toThrow( + "500 Internal Server Error", + ); + + expect(cloudStore.status).toBe(Status.NOT_CONNECTED); + expect(feedbackStore.server_error).toBe(true); + expect(infraStore.domain_name).toBe(previous_domain_name); + }); + }); + + describe("start", () => { + beforeEach(() => { + mockedFetch.mockReset(); + useInfraStore().app_mode = appMode.CLOUD; + mockedFetch.mockImplementation(async (_route, options) => { + const data = { url: "test.com" }; + const onResponseHooks = toHookArray(options?.onResponse); + await Promise.all( + onResponseHooks.map(async (hook) => { + await hook(buildFetchContext(data, RESPONSE_OK_STATUS)); + }), + ); + return data; + }); + }); + afterEach(() => { + setCloudUrlParam(); + }); + + test("without cloud_url launches a new service", async () => { + setupConfig(); + setCloudUrlParam(); + await useCloudStore().start(EMAIL); + + expect(mockedFetch).toHaveBeenCalledWith("/cloud/run", expect.anything()); + expect(useInfraStore().domain_name).toBe("test.com"); + }); + + test("with cloud_url connects to the existing service", async () => { + setupConfig(); + setCloudUrlParam(CLOUD_RUN_HOST); + await useCloudStore().start(EMAIL); + + expect(mockedFetch).not.toHaveBeenCalledWith("/cloud/run", expect.anything()); + expect(mockedFetch).toHaveBeenCalledWith("opengeodeweb_back/ping", expect.anything()); + expect(useInfraStore().domain_name).toBe(CLOUD_RUN_HOST); + expect(globalThis.location.search).toBe(""); + }); + + test("failed cloud_url is cleared so a retry launches a new service", async () => { + setupConfig(); + setCloudUrlParam(CLOUD_RUN_HOST); + const cloudStore = useCloudStore(); + mockedFetch.mockRejectedValueOnce(new Error("unreachable")); + + await expect(cloudStore.start(EMAIL)).rejects.toThrow("unreachable"); + expect(globalThis.location.search).toBe(""); + + mockedFetch.mockClear(); + await cloudStore.start(EMAIL); + expect(mockedFetch).toHaveBeenCalledWith("/cloud/run", expect.anything()); + expect(mockedFetch).not.toHaveBeenCalledWith("opengeodeweb_back/ping", expect.anything()); + expect(useInfraStore().domain_name).toBe("test.com"); + }); + }); + describe("connect", () => { test("successful connect", () => { const cloudStore = useCloudStore(); diff --git a/tests/unit/stores/infra.nuxt.test.ts b/tests/unit/stores/infra.nuxt.test.ts index 308af3849..885c2f123 100644 --- a/tests/unit/stores/infra.nuxt.test.ts +++ b/tests/unit/stores/infra.nuxt.test.ts @@ -271,5 +271,15 @@ describe("infra store", () => { expect(backStore.status).toBe(Status.NOT_CONNECTED); expect(viewerStore.status).toBe(Status.NOT_CONNECTED); }); + + test("cloud start failure resets status", async () => { + const infraStore = useInfraStore(); + + infraStore.app_mode = appMode.CLOUD; + vi.mocked($fetch).mockRejectedValue(new Error("unreachable")); + + await expect(infraStore.create_backend("noreply@example.com")).rejects.toThrow("unreachable"); + expect(infraStore.status).toBe(Status.NOT_CREATED); + }); }); }); diff --git a/tests/unit/utils/cloud.nuxt.test.ts b/tests/unit/utils/cloud.nuxt.test.ts new file mode 100644 index 000000000..270670035 --- /dev/null +++ b/tests/unit/utils/cloud.nuxt.test.ts @@ -0,0 +1,62 @@ +// Third party imports +import { afterEach, describe, expect, test } from "vitest"; + +// Local imports +import { clearCloudUrlParam, getCloudUrlParam } from "@ogw_front/utils/cloud"; + +// CONSTANTS +const CLOUD_RUN_HOST = "vease-abc123.europe-west1.run.app"; + +function setCloudUrlParam(value?: string): void { + const search = value === undefined ? "" : `?cloud_url=${encodeURIComponent(value)}`; + globalThis.history.replaceState(undefined, "", `/${search}`); +} + +describe("cloud_url query param parsing", () => { + afterEach(() => { + setCloudUrlParam(); + }); + + test("missing", () => { + setCloudUrlParam(); + expect(getCloudUrlParam()).toBeUndefined(); + }); + + test("bare Cloud Run host", () => { + setCloudUrlParam(CLOUD_RUN_HOST); + expect(getCloudUrlParam()).toBe(CLOUD_RUN_HOST); + }); + + test("full Cloud Run URL", () => { + setCloudUrlParam(`https://${CLOUD_RUN_HOST}/some/path?query=1`); + expect(getCloudUrlParam()).toBe(CLOUD_RUN_HOST); + }); + + test("non Cloud Run host", () => { + setCloudUrlParam("evil.com"); + expect(getCloudUrlParam()).toBeUndefined(); + }); + + test("host only ending like Cloud Run", () => { + setCloudUrlParam("evilrun.app"); + expect(getCloudUrlParam()).toBeUndefined(); + }); + + test("invalid URL", () => { + setCloudUrlParam("https://"); + expect(getCloudUrlParam()).toBeUndefined(); + }); +}); + +describe("cloud_url query param clearing", () => { + afterEach(() => { + setCloudUrlParam(); + }); + + test("removes cloud_url and keeps other params", () => { + globalThis.history.replaceState(undefined, "", `/?foo=1&cloud_url=${CLOUD_RUN_HOST}`); + clearCloudUrlParam(); + expect(globalThis.location.search).toBe("?foo=1"); + expect(getCloudUrlParam()).toBeUndefined(); + }); +});