diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..db76088 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +VITE_AUTH0_DOMAIN= +VITE_AUTH0_CLIENT_ID= +VITE_AUTH0_AUDIENCE= +VITE_API_URL= diff --git a/deployments/stitch-frontend/package-lock.json b/deployments/stitch-frontend/package-lock.json index 1a23ae5..1fdce74 100644 --- a/deployments/stitch-frontend/package-lock.json +++ b/deployments/stitch-frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "stitch-frontend", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "^2.15.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", @@ -113,6 +114,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth0/auth0-auth-js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.4.0.tgz", + "integrity": "sha512-ShA7KT4KvcBEtxsXZTcrmoNxai5q1JXhB2aEBFnZD1L6LNLzzmiUWiFTtGMsaaITCylr8TJ/onEQk6XZmUHXbg==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.8", + "openid-client": "^6.8.0" + } + }, + "node_modules/@auth0/auth0-react": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.15.0.tgz", + "integrity": "sha512-LbRU87U54/YW/N3UHtNVoj3mCBBz+iYAdAByQjbXOkpI6IYnjMBwIwDusW3N23XNXq9WnihD57Dyi2R3/Q9btw==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.16.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.16.0.tgz", + "integrity": "sha512-UTP45NqjC2jVc/WaWh+iYOZt6FajpTJc+3WzljbXBiv2f76wDw4Mt9hW/aShBovsRmvKEIHaCifD3c/Gxmo2ZQ==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-auth-js": "^1.4.0", + "browser-tabs-lock": "^1.2.15", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2388,6 +2424,16 @@ "concat-map": "0.0.1" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2682,6 +2728,15 @@ "license": "MIT", "peer": true }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2729,6 +2784,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -3481,6 +3542,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3877,6 +3947,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4051,6 +4127,28 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/deployments/stitch-frontend/package.json b/deployments/stitch-frontend/package.json index 22d99f5..01b22a4 100644 --- a/deployments/stitch-frontend/package.json +++ b/deployments/stitch-frontend/package.json @@ -16,6 +16,7 @@ "test:coverage": "vitest --coverage" }, "dependencies": { + "@auth0/auth0-react": "^2.15.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", diff --git a/deployments/stitch-frontend/src/App.jsx b/deployments/stitch-frontend/src/App.jsx index 7c25b62..0568759 100644 --- a/deployments/stitch-frontend/src/App.jsx +++ b/deployments/stitch-frontend/src/App.jsx @@ -1,9 +1,13 @@ import ResourcesView from "./components/ResourcesView"; import ResourceView from "./components/ResourceView"; +import { LogoutButton } from "./components/LogoutButton"; function App() { return (
+
+ +
diff --git a/deployments/stitch-frontend/src/auth/AuthGate.jsx b/deployments/stitch-frontend/src/auth/AuthGate.jsx new file mode 100644 index 0000000..19cefd0 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.jsx @@ -0,0 +1,30 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import LoginPage from "../components/LoginPage"; + +export default function AuthGate({ children }) { + const { isLoading, isAuthenticated, error } = useAuth0(); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

+ Authentication error: {error.message} +

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return children; +} diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx new file mode 100644 index 0000000..7b39d3f --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import AuthGate from "./AuthGate"; + +describe("AuthGate", () => { + it("shows loading indicator while auth is loading", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isLoading: true, + isAuthenticated: false, + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + }); + + it("shows error message when auth fails", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isAuthenticated: false, + error: new Error("Something went wrong"), + }); + + render( + +
App Content
+
, + ); + + expect( + screen.getByText("Authentication error: Something went wrong"), + ).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + }); + + it("renders LoginPage when unauthenticated", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isAuthenticated: false, + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("Stitch")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /log in to continue/i }), + ).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + }); +}); diff --git a/deployments/stitch-frontend/src/auth/api.js b/deployments/stitch-frontend/src/auth/api.js new file mode 100644 index 0000000..80ba100 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/api.js @@ -0,0 +1,21 @@ +import config from "../config/env"; + +/** + * Returns a `fetch`-compatible function that automatically attaches a Bearer + * token to every request. The token is acquired on each call via Auth0's + * `getAccessTokenSilently`, so callers never handle tokens directly. + * + * @param {Function} getAccessTokenSilently - Auth0 SDK method for obtaining + * an access token without user interaction. + * @returns {Function} An async `(url, options?) => Response` fetcher. + */ +export function createAuthenticatedFetcher(getAccessTokenSilently) { + return async (url, options = {}) => { + const token = await getAccessTokenSilently({ + authorizationParams: { audience: config.auth0.audience }, + }); + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${token}`); + return fetch(url, { ...options, headers }); + }; +} diff --git a/deployments/stitch-frontend/src/auth/api.test.js b/deployments/stitch-frontend/src/auth/api.test.js new file mode 100644 index 0000000..3cbf848 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/api.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createAuthenticatedFetcher } from "./api"; + +describe("createAuthenticatedFetcher", () => { + let getAccessTokenSilently; + let fetcher; + + beforeEach(() => { + getAccessTokenSilently = vi.fn().mockResolvedValue("test-token"); + fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }))), + ); + }); + + it("attaches Bearer token to request", async () => { + await fetcher("http://api.test/data"); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("passes audience to getAccessTokenSilently", async () => { + await fetcher("http://api.test/data"); + + expect(getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { audience: "https://test-api" }, + }); + }); + + it("preserves caller headers", async () => { + await fetcher("http://api.test/data", { + headers: { "Content-Type": "application/json" }, + }); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Content-Type")).toBe("application/json"); + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("overrides caller Authorization with token", async () => { + await fetcher("http://api.test/data", { + headers: { Authorization: "Bearer old-token" }, + }); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("propagates token acquisition errors", async () => { + getAccessTokenSilently.mockRejectedValue(new Error("token error")); + fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + + await expect(fetcher("http://api.test/data")).rejects.toThrow( + "token error", + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("propagates fetch errors", async () => { + fetch.mockRejectedValue(new Error("network error")); + + await expect(fetcher("http://api.test/data")).rejects.toThrow( + "network error", + ); + }); +}); diff --git a/deployments/stitch-frontend/src/components/LoginPage.jsx b/deployments/stitch-frontend/src/components/LoginPage.jsx new file mode 100644 index 0000000..74bfa82 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LoginPage.jsx @@ -0,0 +1,22 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import Button from "./Button"; + +export default function LoginPage() { + const { loginWithRedirect } = useAuth0(); + + return ( +
+
+

Stitch

+

+ Oil & Gas Asset Data Platform +

+

+ Integrate diverse datasets, apply AI-driven enrichment with human + review, and deliver curated, trustworthy data. +

+ +
+
+ ); +} diff --git a/deployments/stitch-frontend/src/components/LoginPage.test.jsx b/deployments/stitch-frontend/src/components/LoginPage.test.jsx new file mode 100644 index 0000000..931e44a --- /dev/null +++ b/deployments/stitch-frontend/src/components/LoginPage.test.jsx @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import LoginPage from "./LoginPage"; + +describe("LoginPage", () => { + it("renders app name and description", () => { + render(); + + expect(screen.getByText("Stitch")).toBeInTheDocument(); + expect( + screen.getByText("Oil & Gas Asset Data Platform"), + ).toBeInTheDocument(); + }); + + it("calls loginWithRedirect on button click", async () => { + const loginWithRedirect = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + loginWithRedirect, + }); + + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole("button", { name: /log in to continue/i }), + ); + + expect(loginWithRedirect).toHaveBeenCalled(); + }); +}); diff --git a/deployments/stitch-frontend/src/components/LogoutButton.jsx b/deployments/stitch-frontend/src/components/LogoutButton.jsx new file mode 100644 index 0000000..4942842 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LogoutButton.jsx @@ -0,0 +1,13 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import Button from "./Button"; + +export const LogoutButton = () => { + const { isAuthenticated, logout: authLogout } = useAuth0(); + + const logout = () => + authLogout({ + openUrl: () => window.location.assign(window.location.origin), + }); + + return isAuthenticated && ; +}; diff --git a/deployments/stitch-frontend/src/components/LogoutButton.test.jsx b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx new file mode 100644 index 0000000..197ce5a --- /dev/null +++ b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import { LogoutButton } from "./LogoutButton"; + +describe("LogoutButton", () => { + it("renders when authenticated", () => { + render(); + expect( + screen.getByRole("button", { name: /log out/i }), + ).toBeInTheDocument(); + }); + + it("does not render when not authenticated", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isAuthenticated: false, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("calls logout with returnTo on click", async () => { + const logout = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, logout }); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /log out/i })); + + expect(logout).toHaveBeenCalledWith({ + openUrl: expect.any(Function), + }); + }); +}); diff --git a/deployments/stitch-frontend/src/components/ResourceView.jsx b/deployments/stitch-frontend/src/components/ResourceView.jsx index 65ba525..b393a79 100644 --- a/deployments/stitch-frontend/src/components/ResourceView.jsx +++ b/deployments/stitch-frontend/src/components/ResourceView.jsx @@ -6,6 +6,7 @@ import ClearCacheButton from "./ClearCacheButton"; import JsonView from "./JsonView"; import Input from "./Input"; import { resourceKeys } from "../queries/resources"; +import config from "../config/env"; export default function ResourceView({ className, endpoint }) { const queryClient = useQueryClient(); @@ -29,7 +30,7 @@ export default function ResourceView({ className, endpoint }) {
- {import.meta.env.VITE_API_URL} + {config.apiBaseUrl} {endpoint}
diff --git a/deployments/stitch-frontend/src/config/env.js b/deployments/stitch-frontend/src/config/env.js new file mode 100644 index 0000000..6adef6c --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.js @@ -0,0 +1,30 @@ +const REQUIRED_KEYS = [ + "VITE_AUTH0_DOMAIN", + "VITE_AUTH0_CLIENT_ID", + "VITE_AUTH0_AUDIENCE", +]; + +function loadConfig() { + const missing = REQUIRED_KEYS.filter((key) => !import.meta.env[key]); + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(", ")}. ` + + `Check your .env file or deployment config.`, + ); + } + + return Object.freeze({ + auth0: Object.freeze({ + domain: import.meta.env.VITE_AUTH0_DOMAIN, + clientId: import.meta.env.VITE_AUTH0_CLIENT_ID, + audience: import.meta.env.VITE_AUTH0_AUDIENCE, + }), + apiBaseUrl: import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1", + }); +} + +// Optional named export for `import { config } from "./config/env.js"` +export const config = loadConfig(); + +export default config; diff --git a/deployments/stitch-frontend/src/config/env.test.js b/deployments/stitch-frontend/src/config/env.test.js new file mode 100644 index 0000000..3e45544 --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +describe("config/env", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns correct config when all vars are present", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com"); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id"); + vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api"); + vi.stubEnv("VITE_API_URL", "http://localhost:9000/api/v1"); + + const { default: config } = await import("./env.js"); + + expect(config.auth0.domain).toBe("my.auth0.com"); + expect(config.auth0.clientId).toBe("my-client-id"); + expect(config.auth0.audience).toBe("https://my-api"); + expect(config.apiBaseUrl).toBe("http://localhost:9000/api/v1"); + }); + + it("throws when required vars are missing", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", ""); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", ""); + vi.stubEnv("VITE_AUTH0_AUDIENCE", ""); + + await expect(() => import("./env.js")).rejects.toThrow("VITE_AUTH0_DOMAIN"); + }); + + it("uses default API URL when VITE_API_URL is unset", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com"); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id"); + vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api"); + vi.stubEnv("VITE_API_URL", ""); + + const { default: config } = await import("./env.js"); + + expect(config.apiBaseUrl).toBe("http://localhost:8000/api/v1"); + }); +}); diff --git a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js new file mode 100644 index 0000000..32d0398 --- /dev/null +++ b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuth0 } from "@auth0/auth0-react"; +import { createAuthenticatedFetcher } from "../auth/api"; + +/** + * Wraps `useQuery` so that every request carries a valid Auth0 bearer token. + * + * Callers provide a `queryFn(fetcher)` instead of a plain `queryFn()`. The + * hook builds an authenticated fetcher (via `createAuthenticatedFetcher`) and + * passes it into `queryFn`, keeping token acquisition out of individual query + * functions. + * + * @param {object} queryOptions - Standard TanStack Query options, except + * `queryFn` receives an authenticated `fetcher` as its first argument. + */ +export function useAuthenticatedQuery(queryOptions) { + const { getAccessTokenSilently } = useAuth0(); + const fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + const { queryFn, ...rest } = queryOptions; + return useQuery({ ...rest, queryFn: () => queryFn(fetcher) }); +} diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js index 5957e20..dad38b3 100644 --- a/deployments/stitch-frontend/src/hooks/useResources.js +++ b/deployments/stitch-frontend/src/hooks/useResources.js @@ -1,10 +1,10 @@ -import { useQuery } from "@tanstack/react-query"; +import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; import { resourceQueries } from "../queries/resources"; export function useResources() { - return useQuery(resourceQueries.list()); + return useAuthenticatedQuery(resourceQueries.list()); } export function useResource(id) { - return useQuery(resourceQueries.detail(id)); + return useAuthenticatedQuery(resourceQueries.detail(id)); } diff --git a/deployments/stitch-frontend/src/main.jsx b/deployments/stitch-frontend/src/main.jsx index d489c37..e4221ad 100644 --- a/deployments/stitch-frontend/src/main.jsx +++ b/deployments/stitch-frontend/src/main.jsx @@ -1,8 +1,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Auth0Provider } from "@auth0/auth0-react"; import "./index.css"; import App from "./App.jsx"; +import AuthGate from "./auth/AuthGate"; +import config from "./config/env"; // Set global defaults for QueryClient const queryClient = new QueryClient({ @@ -16,8 +19,20 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")).render( - - - + + + + + + + , ); diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index d585eb5..91f54d9 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -1,9 +1,8 @@ -const API_BASE_URL = - import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1"; +import config from "../config/env"; -export async function getResources() { - const url = `${API_BASE_URL}/resources/`; - const response = await fetch(url); +export async function getResources(fetcher) { + const url = `${config.apiBaseUrl}/resources/`; + const response = await fetcher(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -11,9 +10,9 @@ export async function getResources() { return data; } -export async function getResource(id) { - const url = `${API_BASE_URL}/resources/${id}`; - const response = await fetch(url); +export async function getResource(id, fetcher) { + const url = `${config.apiBaseUrl}/resources/${id}`; + const response = await fetcher(url); if (!response.ok) { const error = new Error(`HTTP error! status: ${response.status}`); error.status = response.status; diff --git a/deployments/stitch-frontend/src/queries/api.test.js b/deployments/stitch-frontend/src/queries/api.test.js index 54425a3..3645043 100644 --- a/deployments/stitch-frontend/src/queries/api.test.js +++ b/deployments/stitch-frontend/src/queries/api.test.js @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getResources, getResource } from "./api"; describe("API Functions", () => { + let mockFetcher; + beforeEach(() => { - global.fetch = vi.fn(); + mockFetcher = vi.fn(); }); describe("getResources", () => { @@ -13,33 +15,35 @@ describe("API Functions", () => { { id: 2, name: "Resource 2", type: "test" }, ]; - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResources, }); - const result = await getResources(); + const result = await getResources(mockFetcher); - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetcher).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/", ); expect(result).toEqual(mockResources); }); it("throws error when response is not ok", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 500, }); - await expect(getResources()).rejects.toThrow("HTTP error! status: 500"); + await expect(getResources(mockFetcher)).rejects.toThrow( + "HTTP error! status: 500", + ); }); it("throws error on network failure", async () => { - global.fetch.mockRejectedValueOnce(new Error("Network error")); + mockFetcher.mockRejectedValueOnce(new Error("Network error")); - await expect(getResources()).rejects.toThrow("Network error"); + await expect(getResources(mockFetcher)).rejects.toThrow("Network error"); }); }); @@ -47,28 +51,28 @@ describe("API Functions", () => { it("fetches and returns a single resource successfully", async () => { const mockResource = { id: 42, name: "Test Resource", type: "example" }; - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResource, }); - const result = await getResource(42); + const result = await getResource(42, mockFetcher); - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetcher).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/42", ); expect(result).toEqual(mockResource); }); it("throws error with status when response is not ok", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 404, }); try { - await getResource(999); + await getResource(999, mockFetcher); expect.fail("Should have thrown an error"); } catch (error) { expect(error.message).toBe("HTTP error! status: 404"); @@ -77,33 +81,35 @@ describe("API Functions", () => { }); it("includes status code in error object for 404", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 404, }); - await expect(getResource(123)).rejects.toMatchObject({ + await expect(getResource(123, mockFetcher)).rejects.toMatchObject({ message: "HTTP error! status: 404", status: 404, }); }); it("includes status code in error object for 500", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 500, }); - await expect(getResource(1)).rejects.toMatchObject({ + await expect(getResource(1, mockFetcher)).rejects.toMatchObject({ message: "HTTP error! status: 500", status: 500, }); }); it("throws error on network failure", async () => { - global.fetch.mockRejectedValueOnce(new Error("Failed to fetch")); + mockFetcher.mockRejectedValueOnce(new Error("Failed to fetch")); - await expect(getResource(1)).rejects.toThrow("Failed to fetch"); + await expect(getResource(1, mockFetcher)).rejects.toThrow( + "Failed to fetch", + ); }); }); }); diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js index a2b497d..3ff7413 100644 --- a/deployments/stitch-frontend/src/queries/resources.js +++ b/deployments/stitch-frontend/src/queries/resources.js @@ -13,13 +13,13 @@ export const resourceKeys = { export const resourceQueries = { list: () => ({ queryKey: resourceKeys.lists(), - queryFn: getResources, + queryFn: (fetcher) => getResources(fetcher), enabled: false, }), detail: (id) => ({ queryKey: resourceKeys.detail(id), - queryFn: () => getResource(id), + queryFn: (fetcher) => getResource(id, fetcher), enabled: false, }), }; diff --git a/deployments/stitch-frontend/src/test/setup.js b/deployments/stitch-frontend/src/test/setup.js index 971941d..9478604 100644 --- a/deployments/stitch-frontend/src/test/setup.js +++ b/deployments/stitch-frontend/src/test/setup.js @@ -1,4 +1,4 @@ -import { expect, afterEach } from "vitest"; +import { expect, afterEach, vi } from "vitest"; import { cleanup } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; @@ -7,3 +7,16 @@ expect.extend(matchers); afterEach(() => { cleanup(); }); + +vi.mock("@auth0/auth0-react", () => ({ + useAuth0: vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + error: null, + user: { sub: "test-user-id", email: "test@example.com" }, + getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"), + loginWithRedirect: vi.fn(), + logout: vi.fn(), + }), + Auth0Provider: ({ children }) => children, +})); diff --git a/deployments/stitch-frontend/src/test/utils.jsx b/deployments/stitch-frontend/src/test/utils.jsx index cc13656..cc69045 100644 --- a/deployments/stitch-frontend/src/test/utils.jsx +++ b/deployments/stitch-frontend/src/test/utils.jsx @@ -1,6 +1,23 @@ +import { vi } from "vitest"; import { render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +/** + * Default return value for the mocked `useAuth0` hook (mirrors setup.js). + * Spread and override in tests that need a different auth state: + * + * vi.mocked(useAuth0).mockReturnValue({ ...auth0Defaults, isAuthenticated: false }); + */ +export const auth0TestDefaults = { + isAuthenticated: true, + isLoading: false, + error: null, + user: { sub: "test-user-id", email: "test@example.com" }, + getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"), + loginWithRedirect: vi.fn(), + logout: vi.fn(), +}; + export function renderWithQueryClient(ui, options = {}) { const queryClient = new QueryClient({ defaultOptions: { diff --git a/deployments/stitch-frontend/vitest.config.js b/deployments/stitch-frontend/vitest.config.js index 908aa37..606ff9b 100644 --- a/deployments/stitch-frontend/vitest.config.js +++ b/deployments/stitch-frontend/vitest.config.js @@ -8,5 +8,11 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./src/test/setup.js", css: true, + env: { + VITE_AUTH0_DOMAIN: "test.auth0.com", + VITE_AUTH0_CLIENT_ID: "test-client-id", + VITE_AUTH0_AUDIENCE: "https://test-api", + VITE_API_URL: "http://localhost:8000/api/v1", + }, }, });