Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions app/components/Launcher.vue
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
<script setup lang="ts">
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 {
appName: string;
email?: string;
// Firebase ID token for the cloud launch; required in cloud mode.
authToken?: string;
isUserAuthenticated?: boolean;
logo?: string;
}

const { appName, email = undefined, isUserAuthenticated = false, logo = "" } = defineProps<Props>();
const {
appName,
authToken = undefined,
isUserAuthenticated = false,
logo = "",
} = defineProps<Props>();

const infraStore = useInfraStore();
// 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);
infraStore.create_backend(authToken);
}

function cloudCreateBackend(): Promise<void> {
return infraStore.create_backend(email);
return infraStore.create_backend(authToken);
}
</script>

Expand Down
89 changes: 0 additions & 89 deletions app/components/Recaptcha.vue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On en a plus besoin ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, mnt l'auth est obligatoire

This file was deleted.

23 changes: 12 additions & 11 deletions app/stores/api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -8,7 +8,7 @@ interface ApiSchema {
[key: string]: unknown;
}

type ApiCallbacks = RequestHandlers & {
type ApiCallbacks<Response = unknown> = RequestHandlers<Response> & {
skip_feedback_error?: boolean;
};

Expand All @@ -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<TResult = unknown>(
// 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 extends ApiSchema>(
{
schema,
params = {},
params,
headers = {},
}: { schema: ApiSchema; params?: Record<string, unknown>; headers?: Record<string, string> },
callbacks: ApiCallbacks = {},
): Promise<TResult> {
}: { schema: Schema; params?: ParamsOf<Schema>; headers?: Record<string, string> },
callbacks: ApiCallbacks<ResponseOf<Schema>> = {},
): Promise<ResponseOf<Schema>> {
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) => {
Expand All @@ -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<Schema>);
}
},
},
);
// oxlint-disable-next-line no-unsafe-type-assertion -- trusted API boundary; see comment above.
return result as TResult;
return result as ResponseOf<Schema>;
}
return {
base_url,
Expand Down
32 changes: 14 additions & 18 deletions app/stores/cloud.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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<RunCloudResponse>(
{ 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 });
Expand Down
4 changes: 2 additions & 2 deletions app/stores/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
25 changes: 17 additions & 8 deletions tests/unit/stores/cloud.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand All @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/stores/infra.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
});
});
Expand Down
Loading