From 25ab7de442e6a48c1200e165c217efcabb906192 Mon Sep 17 00:00:00 2001 From: Arnaud Botella Date: Tue, 6 Oct 2026 10:46:28 +0200 Subject: [PATCH] fix(Cloud): add missing auth --- app/components/Launcher.vue | 15 +++-- app/components/Recaptcha.vue | 89 ---------------------------- app/stores/api.ts | 23 +++---- app/stores/cloud.ts | 32 +++++----- app/stores/infra.ts | 4 +- tests/unit/stores/cloud.nuxt.test.ts | 25 +++++--- tests/unit/stores/infra.nuxt.test.ts | 4 +- 7 files changed, 57 insertions(+), 135 deletions(-) delete mode 100644 app/components/Recaptcha.vue diff --git a/app/components/Launcher.vue b/app/components/Launcher.vue index d6747d3c2..4429df5da 100644 --- a/app/components/Launcher.vue +++ b/app/components/Launcher.vue @@ -1,6 +1,5 @@ diff --git a/app/components/Recaptcha.vue b/app/components/Recaptcha.vue deleted file mode 100644 index bb2cd9467..000000000 --- a/app/components/Recaptcha.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - - - diff --git a/app/stores/api.ts b/app/stores/api.ts index ea2693a7d..57392eb80 100644 --- a/app/stores/api.ts +++ b/app/stores/api.ts @@ -1,4 +1,4 @@ -import type { RequestHandlers } from "@ogw_shared/utils/types"; +import type { ParamsOf, RequestHandlers, ResponseOf } from "@ogw_shared/utils/types"; import { api_fetch } from "@ogw_internal/utils/api_fetch"; import { consola } from "consola"; @@ -8,7 +8,7 @@ interface ApiSchema { [key: string]: unknown; } -type ApiCallbacks = RequestHandlers & { +type ApiCallbacks = RequestHandlers & { skip_feedback_error?: boolean; }; @@ -26,21 +26,21 @@ export const useAPIStore = defineStore("api", () => { request_counter.value -= 1; } - // `TResult` is asserted, not verified: the response is only checked against `schema` at runtime - async function request( + // The response type comes from the schema's generated `response` type (see the Cloud API typed schemas), asserted at the API boundary below, not verified + async function request( { schema, - params = {}, + params, headers = {}, - }: { schema: ApiSchema; params?: Record; headers?: Record }, - callbacks: ApiCallbacks = {}, - ): Promise { + }: { schema: Schema; params?: ParamsOf; headers?: Record }, + callbacks: ApiCallbacks> = {}, + ): Promise> { consola.info("[API] Request:", schema.$id); const start = Date.now(); const result = await api_fetch( { $id: schema.$id, base_url: base_url.value, start_request, stop_request }, - { schema, params, headers }, + { schema, params: params ?? {}, headers }, { ...callbacks, response_function: async (response: unknown) => { @@ -52,13 +52,14 @@ export const useAPIStore = defineStore("api", () => { "s", ); if (callbacks.response_function) { - await callbacks.response_function(response); + // oxlint-disable-next-line no-unsafe-type-assertion -- trusted API boundary; see comment above. + await callbacks.response_function(response as ResponseOf); } }, }, ); // oxlint-disable-next-line no-unsafe-type-assertion -- trusted API boundary; see comment above. - return result as TResult; + return result as ResponseOf; } return { base_url, diff --git a/app/stores/cloud.ts b/app/stores/cloud.ts index f720bf947..0a57f26ee 100644 --- a/app/stores/cloud.ts +++ b/app/stores/cloud.ts @@ -1,9 +1,8 @@ import { clearCloudUrlParam, getCloudUrlParam } from "@ogw_front/utils/cloud"; -import type { RunCloudResponse } from "@geode/cloud-api/types"; 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_typed_schemas.js"; -import cloud_api_schemas from "@geode/cloud-api/cloud_api_schemas.json"; +import cloud_api_schemas from "@geode/cloud-api/cloud_api_typed_schemas.js"; import { setAppBaseUrl } from "@ogw_shared/scripts"; import { useAPIStore } from "@ogw_front/stores/api"; import { useFeedbackStore } from "@ogw_front/stores/feedback"; @@ -17,10 +16,13 @@ export const useCloudStore = defineStore("cloud", { 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) { + async start(authToken?: string) { const existing_host = getCloudUrlParam(); if (existing_host === undefined) { - await this.launch(email); + if (authToken === undefined) { + throw new Error("Launching a cloud service requires an authenticated user"); + } + await this.launch(authToken); return; } try { @@ -29,29 +31,23 @@ export const useCloudStore = defineStore("cloud", { clearCloudUrlParam(); } }, - async launch(email: string) { + // `authToken` is the user's Firebase ID token, checked by the Cloud API + async launch(authToken: string) { this.status = Status.CONNECTING; const { PROJECT, BRANCH } = useRuntimeConfig().public; - const params = { email, project: PROJECT, branch: BRANCH }; + const params = { project: PROJECT, branch: BRANCH }; + const headers = { Authorization: `Bearer ${authToken}` }; const feedbackStore = useFeedbackStore(); const APIStore = useAPIStore(); - const result = await APIStore.request( - { schema: run_cloud_schema, params }, + const result = await APIStore.request( + { schema: run_cloud_schema, params, headers }, { request_error_function: () => { feedbackStore.$patch({ server_error: true }); this.status = Status.NOT_CONNECTED; }, - response_function: async (response: unknown) => { - if ( - typeof response !== "object" || - response === null || - !("url" in response) || - typeof response.url !== "string" - ) { - return; - } - await this.on_connected(response.url); + response_function: async ({ url }) => { + await this.on_connected(url); }, response_error_function: () => { feedbackStore.$patch({ server_error: true }); diff --git a/app/stores/infra.ts b/app/stores/infra.ts index 84d6dc8e8..10106abfb 100644 --- a/app/stores/infra.ts +++ b/app/stores/infra.ts @@ -44,7 +44,7 @@ export const useInfraStore = defineStore("infra", { (microservice: Microservice) => microservice.$id !== microserviceId, ); }, - async create_backend(email?: string) { + async create_backend(authToken?: string) { if (this.status === Status.CREATED) { return undefined; } @@ -56,7 +56,7 @@ export const useInfraStore = defineStore("infra", { if (this.app_mode === appMode.CLOUD) { const cloudStore = useCloudStore(); try { - await cloudStore.start(email ?? ""); + await cloudStore.start(authToken); } catch (error) { // Back to the "Load the app" button so the user is not stuck on the loading screen. this.status = Status.NOT_CREATED; diff --git a/tests/unit/stores/cloud.nuxt.test.ts b/tests/unit/stores/cloud.nuxt.test.ts index 26832268e..1ae873996 100644 --- a/tests/unit/stores/cloud.nuxt.test.ts +++ b/tests/unit/stores/cloud.nuxt.test.ts @@ -28,7 +28,7 @@ const mockedFetch = vi.mocked($fetch); const PROJECT = "project"; const BRANCH = "branch"; const CLOUD_API_URL = "https://api.example.com"; -const EMAIL = "noreply@example.com"; +const TOKEN = "token"; const RESPONSE_OK_STATUS = 200; const RESPONSE_ERROR_STATUS = 500; const CLOUD_RUN_HOST = "vease-abc123.europe-west1.run.app"; @@ -114,13 +114,14 @@ describe("cloud store", () => { return data; }); - await cloudStore.launch(EMAIL); + await cloudStore.launch(TOKEN); expect(mockedFetch).toHaveBeenCalledWith( "cloud_api/cloud/run", expect.objectContaining({ baseURL: CLOUD_API_URL, - body: { email: EMAIL, project: PROJECT, branch: BRANCH }, + body: { project: PROJECT, branch: BRANCH }, + headers: { Authorization: `Bearer ${TOKEN}` }, }), ); expect(cloudStore.status).toBe(Status.CONNECTED); @@ -152,7 +153,7 @@ describe("cloud store", () => { throw error; }); - await expect(cloudStore.launch(EMAIL)).rejects.toThrow("500 Internal Server Error"); + await expect(cloudStore.launch(TOKEN)).rejects.toThrow("500 Internal Server Error"); expect(cloudStore.status).toBe(Status.NOT_CONNECTED); expect(feedbackStore.server_error).toBe(true); @@ -249,16 +250,24 @@ describe("cloud store", () => { test("without cloud_url launches a new service", async () => { setupConfig(); setCloudUrlParam(); - await useCloudStore().start(EMAIL); + await useCloudStore().start(TOKEN); expect(mockedFetch).toHaveBeenCalledWith("cloud_api/cloud/run", expect.anything()); expect(useInfraStore().domain_name).toBe("test.com"); }); + test("without cloud_url nor auth token getter rejects", async () => { + setupConfig(); + setCloudUrlParam(); + await expect(useCloudStore().start()).rejects.toThrow("requires an authenticated user"); + + expect(mockedFetch).not.toHaveBeenCalledWith("cloud_api/cloud/run", expect.anything()); + }); + test("with cloud_url connects to the existing service", async () => { setupConfig(); setCloudUrlParam(CLOUD_RUN_HOST); - await useCloudStore().start(EMAIL); + await useCloudStore().start(TOKEN); expect(mockedFetch).not.toHaveBeenCalledWith("cloud_api/cloud/run", expect.anything()); expect(mockedFetch).toHaveBeenCalledWith("opengeodeweb_back/ping", expect.anything()); @@ -272,11 +281,11 @@ describe("cloud store", () => { const cloudStore = useCloudStore(); mockedFetch.mockRejectedValueOnce(new Error("unreachable")); - await expect(cloudStore.start(EMAIL)).rejects.toThrow("unreachable"); + await expect(cloudStore.start(TOKEN)).rejects.toThrow("unreachable"); expect(globalThis.location.search).toBe(""); mockedFetch.mockClear(); - await cloudStore.start(EMAIL); + await cloudStore.start(TOKEN); expect(mockedFetch).toHaveBeenCalledWith("cloud_api/cloud/run", expect.anything()); expect(mockedFetch).not.toHaveBeenCalledWith("opengeodeweb_back/ping", expect.anything()); expect(useInfraStore().domain_name).toBe("test.com"); diff --git a/tests/unit/stores/infra.nuxt.test.ts b/tests/unit/stores/infra.nuxt.test.ts index 885c2f123..e45a17e53 100644 --- a/tests/unit/stores/infra.nuxt.test.ts +++ b/tests/unit/stores/infra.nuxt.test.ts @@ -264,7 +264,7 @@ describe("infra store", () => { return data; }); - await infraStore.create_backend("noreply@example.com"); + await infraStore.create_backend("token"); expect(infraStore.status).toBe(Status.CREATED); expect(infraStore.domain_name).toBe(url); @@ -278,7 +278,7 @@ describe("infra store", () => { infraStore.app_mode = appMode.CLOUD; vi.mocked($fetch).mockRejectedValue(new Error("unreachable")); - await expect(infraStore.create_backend("noreply@example.com")).rejects.toThrow("unreachable"); + await expect(infraStore.create_backend("token")).rejects.toThrow("unreachable"); expect(infraStore.status).toBe(Status.NOT_CREATED); }); });