Skip to content
Open
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
53 changes: 53 additions & 0 deletions .github/workflows/coc-edge-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: COC Edge Verify

# Live post-deploy validation of the Customer Operations Center production edge.
# Runs on GitHub-hosted runners (open egress) so it hits the real deployed URLs,
# not just configuration. Triggers on push to this branch and via manual dispatch.
on:
push:
branches: [ci/coc-edge-verify]
workflow_dispatch:

jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Verify deployed edge responses
run: |
set -uo pipefail
CUSTOM="https://customer-operations.zonforge.com"
NET="https://zonforge-customer-operations.netlify.app"
fail=0

echo "================ HTTP RESPONSE SUMMARY ================"

net_code=$(curl -s -o /dev/null -w '%{http_code}' -I "$NET/")
net_loc=$(curl -s -o /dev/null -w '%{redirect_url}' -I "$NET/")
echo "netlify.app / -> $net_code Location: $net_loc"

net_code2=$(curl -s -o /dev/null -w '%{http_code}' -I "$NET/tickets")
net_loc2=$(curl -s -o /dev/null -w '%{redirect_url}' -I "$NET/tickets")
echo "netlify.app /tickets -> $net_code2 Location: $net_loc2"

cust_code=$(curl -sL -o /tmp/page.html -w '%{http_code}' "$CUSTOM/")
echo "custom / -> $cust_code"

echo "================ CANONICAL / EXPOSURE ================="
grep -oi '<link rel="canonical"[^>]*>' /tmp/page.html || echo "(no canonical tag found)"
grep -oi '<meta property="og:url"[^>]*>' /tmp/page.html || true
if grep -qi 'netlify\.app' /tmp/page.html; then echo "netlify.app in body: YES"; else echo "netlify.app in body: NO"; fi

echo "================ ASSERTIONS ==========================="
assert() { if eval "$2"; then echo "PASS $1"; else echo "FAIL $1"; fail=1; fi; }

assert "netlify.app / returns 301" '[ "$net_code" = "301" ] || [ "$net_code" = "308" ]'
assert "redirect target is custom domain" 'echo "$net_loc" | grep -qi "customer-operations.zonforge.com"'
assert "netlify.app /tickets redirects" '[ "$net_code2" = "301" ] || [ "$net_code2" = "308" ]'
assert "deep redirect preserves /tickets" 'echo "$net_loc2" | grep -qi "/tickets"'
assert "custom domain returns 200" '[ "$cust_code" = "200" ]'
assert "canonical = custom domain" 'grep -qi "rel=\"canonical\"[^>]*customer-operations.zonforge.com" /tmp/page.html'
assert "no netlify.app in served HTML" '! grep -qi "netlify\.app" /tmp/page.html'

echo "======================================================"
if [ "$fail" -ne 0 ]; then echo "EDGE VERIFICATION: FAILED"; exit 1; fi
echo "EDGE VERIFICATION: PASSED"
4 changes: 4 additions & 0 deletions apps/customer-operations/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Playwright E2E artifacts
/test-results/
/playwright-report/
/playwright/.cache/
204 changes: 204 additions & 0 deletions apps/customer-operations/e2e/coc-e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { test, expect, type Page, type Route } from '@playwright/test'

/**
* Customer Operations Center — production-behavior E2E.
*
* Runs against the built bundle (vite preview). The platform API is mocked so
* we can deterministically drive healthy / degraded / unavailable / locked and
* assert that status is unified across header + content, the SSO bearer flow
* works, no raw infrastructure leaks, every route renders, and the layout has
* no horizontal overflow at any breakpoint.
*/

// JWT-shaped token with a payload that has no `exp` (treated as non-expired).
const PAYLOAD = Buffer.from(JSON.stringify({ sub: 'admin', role: 'PLATFORM_ADMIN' })).toString('base64url')
const TOKEN = `eyJhbGciOiJIUzI1NiJ9.${PAYLOAD}.sig`

const ROUTES: Array<{ path: string; label: string }> = [
{ path: '/', label: 'Operations Dashboard' },
{ path: '/ai-agents', label: 'AI Agents' },
{ path: '/inbox', label: 'Support Inbox' },
{ path: '/tickets', label: 'Ticket Center' },
{ path: '/conversations', label: 'Live Conversations' },
{ path: '/escalations', label: 'Human Escalations' },
{ path: '/customer-success', label: 'Customer Success' },
{ path: '/billing', label: 'Billing Operations' },
{ path: '/health', label: 'Customer Health' },
{ path: '/onboarding', label: 'Onboarding' },
{ path: '/renewals', label: 'Renewals' },
{ path: '/knowledge', label: 'Knowledge Base' },
{ path: '/sla', label: 'SLA Monitoring' },
{ path: '/analytics', label: 'Analytics' },
{ path: '/workflow', label: 'Workflow Studio' },
{ path: '/omnichannel', label: 'Omnichannel' },
{ path: '/intelligence', label: 'Customer Intelligence' },
{ path: '/governance', label: 'Governance' },
{ path: '/workforce', label: 'Workforce Management' },
]

const OVERVIEW = {
total: 248,
active: 230,
at_risk_count: 4,
critical_count: 1,
avg_health_score: 92,
renewing_soon: 6,
open_tickets: 128,
ai_resolution_rate: 0.94,
}

type ApiMode = 'healthy' | 'degraded' | 'unavailable'

/** Mock every platform API call; capture the Authorization header seen. */
async function mockApi(page: Page, mode: ApiMode, seen: { auth?: string }) {
// Avoid external font fetches hanging in a sandboxed browser. Use glob
// matchers (not a hostname regex) so no unanchored URL regex is introduced.
await page.route('**/fonts.googleapis.com/**', (r: Route) => r.abort())
await page.route('**/fonts.gstatic.com/**', (r: Route) => r.abort())
await page.route('**/v1/**', (route: Route) => {
const auth = route.request().headers()['authorization']
if (auth) seen.auth = auth
if (mode === 'unavailable') return route.abort('failed')
if (mode === 'degraded') return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' })
const url = route.request().url()
const body = url.includes('overview') ? OVERVIEW : { items: [] }
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
})
}

const FORBIDDEN = [
'api.zonforge.com',
'/v1/admin',
'netlify.app',
'Bearer',
'HTTP 401',
'HTTP 403',
'HTTP 500',
]

async function assertNoExposure(page: Page) {
const text = await page.evaluate(() => document.body.innerText)
for (const needle of FORBIDDEN) {
expect(text, `UI must not render "${needle}"`).not.toContain(needle)
}
}

/** SSO sign-in: hit the launcher with a token, land on the dashboard. */
async function ssoSignIn(page: Page) {
await page.goto(`/auth/sso?token=${TOKEN}`)
await page.waitForURL((u) => new URL(u).pathname === '/', { timeout: 15_000 })
}

test.describe('Customer Operations — production behavior', () => {
test('canonical metadata points only at the custom domain', async ({ page }) => {
await mockApi(page, 'healthy', {})
await page.goto('/')
const canonical = await page.getAttribute('link[rel="canonical"]', 'href')
expect(canonical).toBe('https://customer-operations.zonforge.com/')
const ogUrl = await page.getAttribute('meta[property="og:url"]', 'content')
expect(ogUrl).toBe('https://customer-operations.zonforge.com/')
const html = await page.content()
expect(html).not.toContain('netlify.app')
await expect(page).toHaveTitle(/ZonForge Customer Operations/)
})

test('direct unauthenticated access shows a safe locked state (no raw errors)', async ({ page }) => {
const seen: { auth?: string } = {}
await mockApi(page, 'healthy', seen)
await page.goto('/')
await expect(page.getByText('Sign-in required')).toBeVisible()
await expect(page.getByText('API Unavailable')).toBeVisible()
// No probe should have been issued without a token.
expect(seen.auth).toBeUndefined()
await assertNoExposure(page)
})

test('SSO captures the token, scrubs the URL, attaches Bearer, shows API Healthy', async ({ page }) => {
const seen: { auth?: string } = {}
await mockApi(page, 'healthy', seen)
await ssoSignIn(page)
// URL scrubbed — no token left in the address bar.
expect(page.url()).not.toContain('token=')
await expect(page.getByText('API Healthy')).toBeVisible()
// Bearer header attached to the platform API call.
expect(seen.auth).toBe(`Bearer ${TOKEN}`)
// No service banner when healthy.
await expect(page.getByText('Sign-in required')).toHaveCount(0)
await expect(page.getByText('temporarily unavailable')).toHaveCount(0)
await assertNoExposure(page)
})

test('unified status: header and content agree when degraded', async ({ page }) => {
await mockApi(page, 'degraded', {})
await ssoSignIn(page)
// Header badge degraded AND the page service banner present — they match.
await expect(page.getByText('API Degraded')).toBeVisible()
await expect(page.getByText('temporarily unavailable')).toBeVisible()
await assertNoExposure(page)
})

test('unified status: header and content agree when unavailable', async ({ page }) => {
await mockApi(page, 'unavailable', {})
await ssoSignIn(page)
await expect(page.getByText('API Unavailable')).toBeVisible()
await expect(page.getByText('temporarily unavailable')).toBeVisible()
await assertNoExposure(page)
})

test('header and sidebar are fixed; only content scrolls', async ({ page }) => {
await mockApi(page, 'healthy', {})
await ssoSignIn(page)
const header = page.locator('header.zf-cx-cmdhead')
const sidebar = page.locator('aside.zf-cx-sidebar')
const headerBefore = await header.boundingBox()
const sidebarBefore = await sidebar.boundingBox()
await page.locator('main.zf-cx-content').evaluate((el) => {
el.scrollTop = el.scrollHeight
})
await page.waitForTimeout(150)
const headerAfter = await header.boundingBox()
const sidebarAfter = await sidebar.boundingBox()
expect(Math.abs((headerAfter?.y ?? 0) - (headerBefore?.y ?? 0))).toBeLessThanOrEqual(1)
expect(Math.abs((sidebarAfter?.y ?? 0) - (sidebarBefore?.y ?? 0))).toBeLessThanOrEqual(1)
// The content container actually scrolled.
const scrolled = await page.locator('main.zf-cx-content').evaluate((el) => el.scrollTop)
expect(scrolled).toBeGreaterThan(0)
})

test('unified status persists across SPA navigation', async ({ page }) => {
await mockApi(page, 'healthy', {})
await ssoSignIn(page)
for (const label of ['Ticket Center', 'Customer Intelligence']) {
await page.getByRole('link', { name: label }).first().click()
await expect(page.getByText('API Healthy')).toBeVisible()
await expect(page.getByText('temporarily unavailable')).toHaveCount(0)
await assertNoExposure(page)
}
})

test('every sidebar route renders safely on direct access (no crash, no leak)', async ({ page }) => {
await mockApi(page, 'degraded', {}) // direct access → locked regardless
for (const route of ROUTES) {
await page.goto(route.path)
await expect(page.locator('section.zf-cx-page')).toBeVisible()
await expect(page.locator('header.zf-cx-cmdhead')).toBeVisible()
await assertNoExposure(page)
}
})

for (const vp of [
{ name: 'mobile', width: 390, height: 844 },
{ name: 'tablet', width: 820, height: 1180 },
{ name: 'desktop', width: 1440, height: 900 },
{ name: '4k', width: 3840, height: 2160 },
]) {
test(`no horizontal overflow at ${vp.name} (${vp.width}px)`, async ({ page }) => {
await mockApi(page, 'healthy', {})
await page.setViewportSize({ width: vp.width, height: vp.height })
await ssoSignIn(page)
await expect(page.locator('header.zf-cx-cmdhead')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow, 'no horizontal overflow').toBeLessThanOrEqual(1)
})
}
})
4 changes: 3 additions & 1 deletion apps/customer-operations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
"test": "vitest run --passWithNoTests",
"e2e": "playwright test"
},
"dependencies": {
"@sentry/react": "^8.0.0",
Expand All @@ -19,6 +20,7 @@
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.0",
Expand Down
32 changes: 32 additions & 0 deletions apps/customer-operations/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test'

/**
* E2E config for the Customer Operations Center.
*
* Runs against the production build served by `vite preview` so the tests
* exercise the same bundle that ships. The platform API is mocked per-test via
* page.route, so no backend is required. Uses the pre-installed Chromium.
*/
const PORT = 4173
const EXECUTABLE = process.env.PW_CHROMIUM_PATH

export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: [['list']],
timeout: 30_000,
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
...(EXECUTABLE ? { launchOptions: { executablePath: EXECUTABLE } } : {}),
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: `npx vite preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}/`,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
})
17 changes: 9 additions & 8 deletions apps/customer-operations/src/components/CocHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import { Bell, Command, Menu, Search, ShieldCheck, X } from 'lucide-react'
import { resolveSuperAdminUrl } from '../lib/runtime-config'
import { useCocApi } from '../lib/coc-api'
import { useServiceStatus } from '../lib/coc-status'
import { ZonForgeLogo } from './ZonForgeLogo'

type Overview = { total?: number; at_risk_count?: number; critical_count?: number }

function openPalette() {
window.dispatchEvent(new CustomEvent('coc:cmdk'))
}

/**
* Enterprise command header — brand, command-palette search, and live
* production / AI-target / API & SLA health status, notifications, Super Admin.
*
* API + SLA status come from the unified service-status model so the header can
* never contradict the page content.
*/
export function CocHeader({ onMenu, navOpen }: { onMenu?: () => void; navOpen?: boolean } = {}) {
// Reuse the overview call as a lightweight API-health + SLA-risk signal.
const { data, status, isLoading } = useCocApi<Overview>('/v1/admin/customers/overview')
const slaRisk = (data?.at_risk_count ?? 0) + (data?.critical_count ?? 0)
const { badgeStatus, slaRisk, isLoading } = useServiceStatus()

const apiLabel = status === 'healthy' ? 'API Healthy' : status === 'degraded' ? 'API Degraded' : 'API Unavailable'
const apiColor = status === 'healthy' ? 'var(--success)' : status === 'degraded' ? 'var(--warning)' : 'var(--critical)'
const apiLabel =
badgeStatus === 'healthy' ? 'API Healthy' : badgeStatus === 'degraded' ? 'API Degraded' : 'API Unavailable'
const apiColor =
badgeStatus === 'healthy' ? 'var(--success)' : badgeStatus === 'degraded' ? 'var(--warning)' : 'var(--critical)'

return (
<header className="zf-cx-cmdhead">
Expand Down
21 changes: 12 additions & 9 deletions apps/customer-operations/src/layout/CocShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CocHeader } from '../components/CocHeader'
import { CocSidebar } from '../components/CocSidebar'
import { CommandPalette } from '../components/CommandPalette'
import { AiCopilotPanel } from '../components/AiCopilotPanel'
import { CocServiceStatusProvider } from '../lib/coc-status'

export function CocShell({ children }: { children: ReactNode }) {
const [navOpen, setNavOpen] = useState(false)
Expand All @@ -24,15 +25,17 @@ export function CocShell({ children }: { children: ReactNode }) {
}, [navOpen])

return (
<div className={`zf-cx-app-shell${navOpen ? ' nav-open' : ''}`}>
<CocSidebar />
<div className="zf-cx-nav-scrim" onClick={() => setNavOpen(false)} aria-hidden="true" />
<div className="zf-cx-main">
<CocHeader onMenu={() => setNavOpen((v) => !v)} navOpen={navOpen} />
<main className="zf-cx-content">{children}</main>
<CocServiceStatusProvider>
<div className={`zf-cx-app-shell${navOpen ? ' nav-open' : ''}`}>
<CocSidebar />
<div className="zf-cx-nav-scrim" onClick={() => setNavOpen(false)} aria-hidden="true" />
<div className="zf-cx-main">
<CocHeader onMenu={() => setNavOpen((v) => !v)} navOpen={navOpen} />
<main className="zf-cx-content">{children}</main>
</div>
<CommandPalette />
<AiCopilotPanel />
</div>
<CommandPalette />
<AiCopilotPanel />
</div>
</CocServiceStatusProvider>
)
}
Loading