archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installBrowser(token?: string) {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: vi.fn(() => token ?? null),
},
},
})
}
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
})
describe('dashboard apiFetch', () => {
it('adds JSON headers and sends cookies for browser requests', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { ok: true } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/team', expect.objectContaining({
credentials: 'include',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}))
})
it('does not force JSON content type for FormData payloads', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await apiFetch('/uploads', { method: 'POST', body: new FormData() })
expect((fetchMock as any).mock.calls[0]?.[1].headers).not.toHaveProperty('Content-Type')
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: 'include' }))
})
it('surfaces API error details on failed responses', async () => {
installBrowser()
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({
ok: false,
status: 403,
json: async () => ({ message: 'Forbidden', error: 'FORBIDDEN' }),
})),
})
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).rejects.toMatchObject({ message: 'Forbidden', code: 'FORBIDDEN', statusCode: 403 })
})
it('uses explicit server token and disables caching for server requests', async () => {
Reflect.deleteProperty(globalThis, 'window')
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetchServer } = await import('./api')
await apiFetchServer('/fleet', 'server-token')
expect(fetchMock).toHaveBeenCalledWith('http://localhost:4000/api/v1/fleet', expect.objectContaining({
cache: 'no-store',
headers: expect.objectContaining({ Authorization: 'Bearer server-token' }),
}))
})
})
+68
View File
@@ -0,0 +1,68 @@
export const API_BASE =
typeof window === 'undefined'
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1')
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const headers: Record<string, string> = {
...(options?.headers as Record<string, string> ?? {}),
}
if (!isFormData) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
})
let json: any
try {
json = await res.json()
} catch {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
if (!res.ok) {
const err = new Error(json?.message ?? json?.error ?? `Request failed with status ${res.status}`) as any
err.code = json?.error
err.statusCode = res.status
throw err
}
return (json?.data ?? json) as T
}
export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
'Authorization': `Bearer ${token}`,
...(options?.headers as Record<string, string> ?? {}),
},
cache: 'no-store',
})
let json: any
try {
json = await res.json()
} catch {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
if (!res.ok) {
const err = new Error(json?.message ?? `Request failed with status ${res.status}`) as any
err.statusCode = res.status
throw err
}
return (json?.data ?? json) as T
}
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { resolveBrowserAppUrl } from './appUrls'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('dashboard resolveBrowserAppUrl', () => {
it('returns the fallback untouched on the server', () => {
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('http://localhost:3001/dashboard')
})
it('keeps local browser fallbacks local and removes trailing slash', () => {
installWindow('localhost')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard/')).toBe('http://localhost:3001/dashboard')
})
it('rewrites fallback origin to the current production host', () => {
installWindow('tenant.example.com', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('https://tenant.example.com/dashboard')
})
it('falls back safely when the configured value is not a URL', () => {
installWindow('tenant.example.com')
expect(resolveBrowserAppUrl('/dashboard/')).toBe('/dashboard/')
})
})
+15
View File
@@ -0,0 +1,15 @@
export function resolveBrowserAppUrl(fallback: string): string {
if (typeof window === 'undefined') return fallback
const h = window.location.hostname
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
try {
const target = new URL(fallback)
target.protocol = window.location.protocol
target.hostname = h
target.port = ''
return target.toString().replace(/\/$/, '')
} catch {
return fallback
}
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
describe('dashboard path normalization', () => {
it('collapses empty and root values to the app root', () => {
expect(toDashboardAppPath()).toBe('/')
expect(toDashboardAppPath('')).toBe('/')
expect(toDashboardAppPath('/')).toBe('/')
})
it('strips repeated dashboard base prefixes', () => {
expect(toDashboardAppPath('/dashboard')).toBe('/')
expect(toDashboardAppPath('/dashboard/fleet')).toBe('/fleet')
expect(toDashboardAppPath('/dashboard/dashboard/reservations')).toBe('/reservations')
})
it('adds the public deployment prefix exactly once', () => {
expect(toPublicDashboardPath('/')).toBe('/dashboard')
expect(toPublicDashboardPath('/dashboard/fleet')).toBe('/dashboard/fleet')
expect(toPublicDashboardPath('customers')).toBe('/dashboard/customers')
})
})
+19
View File
@@ -0,0 +1,19 @@
const DASHBOARD_BASE_PATH = '/dashboard'
export function toDashboardAppPath(path?: string | null): string {
const value = (path ?? '').trim()
if (!value || value === '/') return '/'
let normalized = value.startsWith('/') ? value : `/${value}`
while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
}
return normalized
}
export function toPublicDashboardPath(path?: string | null): string {
const appPath = toDashboardAppPath(path)
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
}
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
getScopedPreferenceCookieName,
getScopedPreferenceKey,
readCurrentUserScopedPreference,
readScopedPreference,
writeScopedPreference,
} from './preferences'
function installBrowser(cookie = '') {
const store = new Map<string, string>()
let cookieValue = cookie
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
},
},
})
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
get cookie() {
return cookieValue
},
set cookie(value: string) {
const [pair] = value.split(';')
const [name, val] = pair.split('=')
const existing = cookieValue
.split(';')
.map((chunk) => chunk.trim())
.filter(Boolean)
.filter((chunk) => !chunk.startsWith(`${name}=`))
existing.push(`${name}=${val}`)
cookieValue = existing.join('; ')
},
},
})
Object.defineProperty(globalThis, 'atob', {
configurable: true,
value: (value: string) => Buffer.from(value, 'base64').toString('binary'),
})
return { store, get cookie() { return cookieValue } }
}
beforeEach(() => {
installBrowser()
})
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
Reflect.deleteProperty(globalThis, 'atob')
})
describe('dashboard scoped preferences', () => {
it('uses base keys when no employee token is available', () => {
expect(getScopedPreferenceKey('theme')).toBe('theme')
expect(getScopedPreferenceCookieName('theme')).toBe('theme')
})
it('prefers scoped cookie/current user values before local storage', () => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const browser = installBrowser('theme=dark')
browser.store.set('theme', 'local-dark')
expect(readCurrentUserScopedPreference('theme')).toBe('dark')
expect(readScopedPreference('theme')).toBe('dark')
})
it('falls back through shared cookie, scoped local storage, base key, and legacy keys', () => {
const browser = installBrowser('theme=dark')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('dark')
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const second = installBrowser()
second.store.set('theme', 'base')
second.store.set('legacy-theme', 'legacy')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('base')
})
it('writes shared and legacy preference values without auth-token scoping', () => {
const browser = installBrowser()
writeScopedPreference('language', 'fr', ['old-language'])
expect(browser.store.get('language')).toBe('fr')
expect(browser.store.get('old-language')).toBe('fr')
expect(browser.cookie).toContain('language=fr')
})
})
+94
View File
@@ -0,0 +1,94 @@
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
function readEmployeeToken() {
return null
}
function decodeEmployeeId(token: string | null) {
if (!token) return null
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded)) as { sub?: string }
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
} catch {
return null
}
}
export function getScopedPreferenceKey(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}:${employeeId}` : baseKey
}
export function getScopedPreferenceCookieName(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}--${employeeId}` : baseKey
}
function readCookie(name: string) {
if (typeof document === 'undefined') return null
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith(`${name}=`))
?.slice(name.length + 1) ?? null
}
function writeCookie(name: string, value: string) {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
}
export function readCurrentUserScopedPreference(baseKey: string) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
}
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
const sharedCookie = readCookie(baseKey)
if (sharedCookie) return sharedCookie
const scopedKey = getScopedPreferenceKey(baseKey)
const candidates = [scopedKey, baseKey, ...legacyKeys]
for (const key of candidates) {
const value = window.localStorage.getItem(key)
if (value) return value
}
return null
}
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return
const scopedKey = getScopedPreferenceKey(baseKey)
const scopedCookie = getScopedPreferenceCookieName(baseKey)
writeCookie(baseKey, value)
if (scopedCookie !== baseKey) {
writeCookie(scopedCookie, value)
}
window.localStorage.setItem(scopedKey, value)
window.localStorage.setItem(baseKey, value)
for (const key of legacyKeys) {
window.localStorage.setItem(key, value)
}
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
async function loadUrls(env: Record<string, string | undefined> = {}) {
vi.resetModules()
for (const key of ['NEXT_PUBLIC_MARKETPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
if (env[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = env[key]
}
}
return import('./urls')
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
delete process.env.NEXT_PUBLIC_ADMIN_URL
vi.resetModules()
})
describe('dashboard cross-app URLs', () => {
it('uses default marketplace and admin bases on the server', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('http://localhost:3000')
expect(adminUrl).toBe('http://localhost:3000/admin')
})
it('strips trailing slashes from configured absolute URLs', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: 'https://market.example.com/',
NEXT_PUBLIC_ADMIN_URL: 'https://ops.example.com/admin/',
})
expect(marketplaceUrl).toBe('https://market.example.com')
expect(adminUrl).toBe('https://ops.example.com/admin')
})
it('rewrites local browser fallbacks onto the current production host', async () => {
installWindow('tenant.rentaldrivego.com', 'https:')
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('https://tenant.rentaldrivego.com')
expect(adminUrl).toBe('https://tenant.rentaldrivego.com/admin')
})
it('preserves non-URL values after normalizing their trailing slash', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: '/marketplace/',
NEXT_PUBLIC_ADMIN_URL: '/admin/',
})
expect(marketplaceUrl).toBe('/marketplace')
expect(adminUrl).toBe('/admin')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { resolveBrowserAppUrl } from '@/lib/appUrls'
function toAppBase(url: string): string {
try {
const u = new URL(url)
return (u.origin + u.pathname).replace(/\/$/, '')
} catch {
return url.replace(/\/$/, '')
}
}
export const marketplaceUrl = toAppBase(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'),
)
export const adminUrl = toAppBase(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin'),
)