diff --git a/dashboards/tracker-web/src/__tests__/product-context.test.tsx b/dashboards/tracker-web/src/__tests__/product-context.test.tsx index 6d781def..a10d8785 100644 --- a/dashboards/tracker-web/src/__tests__/product-context.test.tsx +++ b/dashboards/tracker-web/src/__tests__/product-context.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { describe, expect, it, beforeAll, beforeEach, vi } from 'vitest'; +import { describe, expect, it, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -14,6 +14,10 @@ beforeEach(() => { localStorage.clear(); }); +afterEach(() => { + vi.unstubAllGlobals(); +}); + function renderProductHarness() { const container = document.createElement('div'); document.body.appendChild(container); @@ -73,4 +77,51 @@ describe('ProductProvider', () => { cleanup(); }); + + it('does not fetch /products/mine when there is no auth token (keeps the default list)', () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const { cleanup } = renderProductHarness(); + expect(fetchSpy).not.toHaveBeenCalled(); + cleanup(); + }); + + it('replaces the list with the caller\u2019s projects from /products/mine when authed', async () => { + localStorage.setItem('tracker_token', 'tok'); + localStorage.setItem('tracker_selected_product', 'acme'); + const fetchSpy = vi.fn(async () => ({ + ok: true, + json: async () => ({ products: [{ productId: 'acme', displayName: 'Acme App' }] }), + })); + vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch); + + const container = document.createElement('div'); + document.body.appendChild(container); + let root!: Root; + function Harness() { + const { products, productName } = useProduct(); + return createElement( + 'div', + null, + createElement('span', { 'data-testid': 'names' }, products.map(p => p.name).join(',')), + createElement('span', { 'data-testid': 'name' }, productName) + ); + } + await act(async () => { + root = createRoot(container); + root.render(createElement(ProductProvider, null, createElement(Harness))); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/products/mine', + expect.objectContaining({ headers: { Authorization: 'Bearer tok' } }) + ); + expect(container.querySelector('[data-testid="names"]')?.textContent).toBe('Acme App'); + expect(container.querySelector('[data-testid="name"]')?.textContent).toBe('Acme App'); + act(() => root.unmount()); + }); }); diff --git a/dashboards/tracker-web/src/__tests__/products-proxy.test.ts b/dashboards/tracker-web/src/__tests__/products-proxy.test.ts new file mode 100644 index 00000000..9c102168 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/products-proxy.test.ts @@ -0,0 +1,77 @@ +/** + * Tests for /api/products/[...path] — products registry proxy (owner-scoped + * "my projects" via GET /api/products/mine). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { GET } from '@/app/api/products/[...path]/route'; + +function mockNextRequest( + method: string, + body?: string, + headers?: Record +): NextRequest { + const headerMap = new Map(Object.entries(headers || {})); + return { + method, + headers: { + get: (key: string) => headerMap.get(key.toLowerCase()) || headerMap.get(key) || null, + }, + nextUrl: { searchParams: new URLSearchParams() }, + text: async () => body || '', + } as unknown as NextRequest; +} + +describe('products proxy', () => { + const originalEnv = { ...process.env }; + beforeEach(() => { + vi.clearAllMocks(); + process.env.PLATFORM_API_URL = 'http://localhost:4003'; + }); + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('proxies GET /products/mine, forwarding the bearer token', async () => { + mockFetch.mockResolvedValue({ + status: 200, + text: async () => JSON.stringify({ products: [{ productId: 'acme' }] }), + }); + const req = mockNextRequest('GET', undefined, { authorization: 'Bearer tok' }); + const res = await GET(req, { params: Promise.resolve({ path: ['mine'] }) }); + + expect(res.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/products/mine'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Authorization: 'Bearer tok' }), + }) + ); + }); + + it('promotes x-tracker-token to a bearer when no Authorization is present', async () => { + mockFetch.mockResolvedValue({ status: 200, text: async () => '{}' }); + const req = mockNextRequest('GET', undefined, { 'x-tracker-token': 'ttok' }); + await GET(req, { params: Promise.resolve({ path: ['mine'] }) }); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer ttok' }), + }) + ); + }); + + it('returns 502 when the products service is unavailable', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + const req = mockNextRequest('GET'); + const res = await GET(req, { params: Promise.resolve({ path: ['mine'] }) }); + expect(res.status).toBe(502); + await expect(res.json()).resolves.toEqual({ error: 'Products service unavailable' }); + }); +}); diff --git a/dashboards/tracker-web/src/app/api/products/[...path]/route.ts b/dashboards/tracker-web/src/app/api/products/[...path]/route.ts new file mode 100644 index 00000000..6df6e78e --- /dev/null +++ b/dashboards/tracker-web/src/app/api/products/[...path]/route.ts @@ -0,0 +1,56 @@ +/** + * Catch-all proxy to platform-service products endpoints. + * Forwards /api/products/* (notably GET /api/products/mine — the caller's + * owner-scoped project list) to the platform-service registry. + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003'; + +async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + const { path } = await params; + // platform-service mounts product routes under the /api prefix. + const targetPath = `/api/products/${path.join('/')}`; + const url = new URL(targetPath, PLATFORM_API); + + req.nextUrl.searchParams.forEach((value, key) => { + url.searchParams.set(key, value); + }); + + try { + const headers: Record = {}; + const auth = req.headers.get('authorization'); + if (auth) headers['Authorization'] = auth; + + const tokenHeader = req.headers.get('x-tracker-token'); + if (tokenHeader && !auth) headers['Authorization'] = `Bearer ${tokenHeader}`; + + const productId = req.headers.get('x-product-id'); + if (productId) headers['x-product-id'] = productId; + + const fetchOptions: RequestInit = { method: req.method, headers }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + const body = await req.text(); + if (body) { + fetchOptions.body = body; + headers['Content-Type'] = 'application/json'; + } + } + + const res = await fetch(url.toString(), fetchOptions); + const data = await res.text(); + return new NextResponse(data, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch { + return NextResponse.json({ error: 'Products service unavailable' }, { status: 502 }); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/dashboards/tracker-web/src/lib/product-context.tsx b/dashboards/tracker-web/src/lib/product-context.tsx index 381a9f00..f5981a2d 100644 --- a/dashboards/tracker-web/src/lib/product-context.tsx +++ b/dashboards/tracker-web/src/lib/product-context.tsx @@ -20,8 +20,27 @@ function getInitialProduct(): string { return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID; } +/** Map the platform-service product docs to the switcher's lightweight shape. */ +function toProducts(docs: unknown): Product[] { + if (!Array.isArray(docs)) return []; + const out: Product[] = []; + for (const d of docs) { + if (!d || typeof d !== 'object') continue; + const { productId, id, displayName, name } = d as Record; + const pid = typeof productId === 'string' ? productId : typeof id === 'string' ? id : null; + if (!pid) continue; + const label = + typeof displayName === 'string' ? displayName : typeof name === 'string' ? name : pid; + out.push({ id: pid, name: label }); + } + return out; +} + export function ProductProvider({ children }: { children: ReactNode }) { const [productId, setProductIdState] = useState(getInitialProduct); + // Start from the configured list (env/default); replace with the caller's + // owner-scoped projects once fetched. Falling back keeps dev/unauth working. + const [products, setProducts] = useState(KNOWN_PRODUCTS); useEffect(() => { function syncSelectedProduct() { @@ -36,6 +55,32 @@ export function ProductProvider({ children }: { children: ReactNode }) { }; }, []); + // Fetch the authenticated user's projects ("my projects") and use them as the + // switcher list. Best-effort: any failure / empty result keeps the configured + // fallback, so an unauthenticated or offline dashboard still renders. + useEffect(() => { + if (typeof window === 'undefined') return; + const token = localStorage.getItem('tracker_token'); + if (!token) return; + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/products/mine', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const json: unknown = await res.json(); + const list = toProducts((json as { products?: unknown }).products); + if (!cancelled && list.length > 0) setProducts(list); + } catch { + /* keep the configured fallback */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + const setProductId = useCallback((id: string) => { setProductIdState(id); if (typeof window !== 'undefined') { @@ -44,13 +89,11 @@ export function ProductProvider({ children }: { children: ReactNode }) { } }, []); - const product = KNOWN_PRODUCTS.find(p => p.id === productId); + const product = products.find(p => p.id === productId); const productName = product?.name ?? productId; return ( - + {children} );