fix(security+ci): address code review findings

Security:
- /api/setup and /api/bootstrap now require Authorization: Bearer $SETUP_SECRET
  before executing any database seed operations

E2E:
- global.setup.ts: replace fixed 2s sleep with waitForServer() poll loop
  (60s timeout, 1s interval) so seed POST only fires when server is ready

CI pipeline:
- lint gate: remove || true so ESLint failures propagate; tighten
  error threshold from 1500 to 0 (errors are now a hard gate)
- container-build-apps: replace !failure() with explicit
  needs.container-base-tier1.result == 'success' so a failed tier-1
  build blocks Gate 2 instead of being silently skipped
- skip_tests workflow_dispatch input now wired to gate-2-start,
  test-unit, test-e2e, and test-dbal-daemon jobs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:36:23 +00:00
parent f235f67521
commit ee32934c74
4 changed files with 40 additions and 10 deletions

View File

@@ -444,16 +444,15 @@ jobs:
run: |
set -o pipefail
cd frontends/nextjs
npx eslint . 2>&1 | tee /tmp/lint-out.txt || true
# Count errors in local src/ only (skip workspace transitive errors)
npx eslint . 2>&1 | tee /tmp/lint-out.txt
# Count errors only (warnings are tolerated, errors are not)
LOCAL_ERRORS=$(grep -cE " error " /tmp/lint-out.txt 2>/dev/null || echo "0")
echo "Total lint issues: $LOCAL_ERRORS"
# Allow up to 1500 issues (pre-existing workspace type-safety warnings)
if [ "$LOCAL_ERRORS" -gt 1500 ]; then
echo "::error::Too many lint errors ($LOCAL_ERRORS > 1500 threshold)"
echo "Total lint errors: $LOCAL_ERRORS"
if [ "$LOCAL_ERRORS" -gt 0 ]; then
echo "::error::Lint errors found ($LOCAL_ERRORS errors, threshold is 0)"
exit 1
fi
echo "Lint: passed with $LOCAL_ERRORS issues (within threshold)"
echo "Lint: passed with 0 errors"
- name: Record validation result
if: always()
@@ -661,6 +660,7 @@ jobs:
name: "Gate 2: Testing - Starting"
runs-on: ubuntu-latest
needs: [gate-1-complete, container-build-apps]
if: ${{ !inputs.skip_tests }}
steps:
- name: Gate 2 checkpoint
run: |
@@ -684,6 +684,7 @@ jobs:
name: "Gate 2.1: Unit Tests"
runs-on: ubuntu-latest
needs: gate-2-start
if: ${{ !inputs.skip_tests }}
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -755,6 +756,7 @@ jobs:
name: "Gate 2.2: E2E Tests"
runs-on: ubuntu-latest
needs: gate-2-start
if: ${{ !inputs.skip_tests }}
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -813,6 +815,7 @@ jobs:
name: "Gate 2.3: DBAL Daemon E2E"
runs-on: ubuntu-latest
needs: gate-2-start
if: ${{ !inputs.skip_tests }}
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -1569,7 +1572,7 @@ jobs:
name: "Gate 7 App: ${{ matrix.image }}"
runs-on: ubuntu-latest
needs: [container-base-tier1]
if: github.event_name != 'issues' && github.event_name != 'issue_comment' && !failure()
if: github.event_name != 'issues' && github.event_name != 'issue_comment' && needs.container-base-tier1.result == 'success'
strategy:
fail-fast: false
matrix:

View File

@@ -13,6 +13,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
let environment: Awaited<ReturnType<DockerComposeEnvironment['up']>> | undefined
async function waitForServer(url: string, timeoutMs = 60000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(url, { method: 'GET' })
if (res.ok || res.status === 401 || res.status === 405) return // server is up
} catch {
// not ready yet
}
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Server at ${url} did not become ready within ${timeoutMs}ms`)
}
async function globalSetup() {
// ── 1. Start smoke stack via Testcontainers ──────────────────────────────
console.log('[setup] Starting smoke stack via Testcontainers...')
@@ -32,14 +46,14 @@ async function globalSetup() {
;(globalThis as Record<string, unknown>).__TESTCONTAINERS_ENV__ = environment
// ── 2. Wait for dev servers (started by Playwright webServer config) ─────
await new Promise(resolve => setTimeout(resolve, 2000))
// ── 3. Seed database ────────────────────────────────────────────────────
// workflowui uses basePath: '/workflowui', so the setup route is at /workflowui/api/setup
const setupUrl = process.env.PLAYWRIGHT_BASE_URL
? new URL('/workflowui/api/setup', process.env.PLAYWRIGHT_BASE_URL.replace(/\/workflowui\/?$/, '')).href
: 'http://localhost:3000/workflowui/api/setup'
await waitForServer(setupUrl)
try {
const response = await fetch(setupUrl, { method: 'POST' })
if (!response.ok) {

View File

@@ -74,6 +74,12 @@ export async function POST(request: NextRequest) {
return limitResponse
}
const setupSecret = process.env.SETUP_SECRET
const authHeader = request.headers.get('Authorization')
if (!setupSecret || authHeader !== `Bearer ${setupSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const results = { packages: 0, pages: 0, skipped: 0, errors: 0 }
// Seed InstalledPackage records

View File

@@ -4,8 +4,15 @@
*/
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { POST as bootstrap } from '@/app/api/bootstrap/route'
export async function POST(request: NextRequest) {
const setupSecret = process.env.SETUP_SECRET
const authHeader = request.headers.get('Authorization')
if (!setupSecret || authHeader !== `Bearer ${setupSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return bootstrap(request)
}