@soulbatical/tetra-dev-toolkit 1.20.11 → 1.20.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Project Health Scanner
3
3
  *
4
- * Orchestrates all 28 health checks and produces a HealthReport.
4
+ * Orchestrates all 29 health checks and produces a HealthReport.
5
5
  * This is the main entry point — consumers call scanProjectHealth().
6
6
  */
7
7
 
@@ -37,6 +37,7 @@ import { check as checkSecurityLayers } from './security-layers.js'
37
37
  import { check as checkSmokeReadiness } from './smoke-readiness.js'
38
38
  import { check as checkReleasePipeline } from './release-pipeline.js'
39
39
  import { check as checkTestStructure } from './test-structure.js'
40
+ import { check as checkSentryMonitoring } from './sentry-monitoring.js'
40
41
  import { calculateHealthStatus } from './types.js'
41
42
 
42
43
  /**
@@ -82,7 +83,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
82
83
  checkSecurityLayers(projectPath),
83
84
  checkSmokeReadiness(projectPath),
84
85
  checkReleasePipeline(projectPath),
85
- checkTestStructure(projectPath)
86
+ checkTestStructure(projectPath),
87
+ checkSentryMonitoring(projectPath)
86
88
  ])
87
89
 
88
90
  const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Health Check: Sentry Error Monitoring
3
+ *
4
+ * Checks: @sentry/node in backend, @sentry/nextjs or @sentry/react in frontend,
5
+ * instrument.ts exists, SENTRY_DSN referenced via env var (not hardcoded).
6
+ * Score: 0-3 (backend setup, frontend setup, no hardcoded DSN)
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'fs'
10
+ import { join } from 'path'
11
+ import { createCheck } from './types.js'
12
+
13
+ export async function check(projectPath) {
14
+ const result = createCheck('sentry-monitoring', 3, {
15
+ backend: { hasSentryDep: false, hasInstrumentFile: false, importedFirst: false },
16
+ frontend: { hasSentryDep: false, sentryPackage: null },
17
+ hardcodedDsn: false,
18
+ dsnFiles: []
19
+ })
20
+
21
+ // --- Check 1: Backend Sentry setup (0-1 point) ---
22
+ const backendPkgPath = join(projectPath, 'backend', 'package.json')
23
+ if (existsSync(backendPkgPath)) {
24
+ try {
25
+ const pkg = JSON.parse(readFileSync(backendPkgPath, 'utf-8'))
26
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
27
+ if (allDeps['@sentry/node']) {
28
+ result.details.backend.hasSentryDep = true
29
+ }
30
+ } catch { /* ignore */ }
31
+
32
+ // Check instrument.ts or instrument.js
33
+ for (const ext of ['ts', 'js']) {
34
+ const instrumentPath = join(projectPath, 'backend', 'src', `instrument.${ext}`)
35
+ if (existsSync(instrumentPath)) {
36
+ result.details.backend.hasInstrumentFile = true
37
+
38
+ // Check if it's imported first in index.ts/index.js
39
+ for (const indexExt of ['ts', 'js']) {
40
+ const indexPath = join(projectPath, 'backend', 'src', `index.${indexExt}`)
41
+ if (existsSync(indexPath)) {
42
+ try {
43
+ const content = readFileSync(indexPath, 'utf-8')
44
+ const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/**'))
45
+ const firstImport = lines.find(l => l.includes('import '))
46
+ if (firstImport && firstImport.includes('instrument')) {
47
+ result.details.backend.importedFirst = true
48
+ }
49
+ } catch { /* ignore */ }
50
+ }
51
+ }
52
+ break
53
+ }
54
+ }
55
+
56
+ if (result.details.backend.hasSentryDep && result.details.backend.hasInstrumentFile) {
57
+ result.score += 1
58
+ } else if (result.details.backend.hasSentryDep) {
59
+ result.score += 0.5
60
+ }
61
+ } else {
62
+ // No backend = skip this point, adjust maxScore
63
+ result.maxScore -= 1
64
+ }
65
+
66
+ // --- Check 2: Frontend Sentry setup (0-1 point) ---
67
+ let hasAnyFrontend = false
68
+ const frontendDirs = ['frontend']
69
+ // Check for multi-frontend setups
70
+ try {
71
+ const { readdirSync } = await import('fs')
72
+ for (const entry of readdirSync(projectPath)) {
73
+ if (entry.startsWith('frontend-') && existsSync(join(projectPath, entry, 'package.json'))) {
74
+ frontendDirs.push(entry)
75
+ }
76
+ }
77
+ } catch { /* ignore */ }
78
+
79
+ for (const feDir of frontendDirs) {
80
+ const fePkgPath = join(projectPath, feDir, 'package.json')
81
+ if (!existsSync(fePkgPath)) continue
82
+ hasAnyFrontend = true
83
+
84
+ try {
85
+ const pkg = JSON.parse(readFileSync(fePkgPath, 'utf-8'))
86
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
87
+ if (allDeps['@sentry/nextjs']) {
88
+ result.details.frontend.hasSentryDep = true
89
+ result.details.frontend.sentryPackage = '@sentry/nextjs'
90
+ } else if (allDeps['@sentry/react']) {
91
+ result.details.frontend.hasSentryDep = true
92
+ result.details.frontend.sentryPackage = '@sentry/react'
93
+ }
94
+ } catch { /* ignore */ }
95
+ }
96
+
97
+ if (!hasAnyFrontend) {
98
+ result.maxScore -= 1
99
+ } else if (result.details.frontend.hasSentryDep) {
100
+ result.score += 1
101
+ }
102
+
103
+ // --- Check 3: No hardcoded DSN (0-1 point) ---
104
+ // Scan for hardcoded Sentry DSN patterns in src files
105
+ const DSN_PATTERN = /https:\/\/[a-f0-9]{32}@[a-z0-9.]+\.sentry\.io\/\d+/
106
+ const filesToCheck = []
107
+
108
+ function collectFiles(dir, depth = 0) {
109
+ if (depth > 3) return
110
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage'])
111
+ try {
112
+ const { readdirSync, statSync } = require('fs')
113
+ for (const entry of readdirSync(dir)) {
114
+ if (SKIP.has(entry)) continue
115
+ const full = join(dir, entry)
116
+ try {
117
+ const stat = statSync(full)
118
+ if (stat.isDirectory()) collectFiles(full, depth + 1)
119
+ else if (/\.(ts|js|tsx|jsx)$/.test(entry) && !entry.endsWith('.d.ts')) {
120
+ filesToCheck.push(full)
121
+ }
122
+ } catch { /* ignore */ }
123
+ }
124
+ } catch { /* ignore */ }
125
+ }
126
+
127
+ for (const dir of ['backend/src', 'frontend/src']) {
128
+ const fullDir = join(projectPath, dir)
129
+ if (existsSync(fullDir)) collectFiles(fullDir)
130
+ }
131
+
132
+ for (const file of filesToCheck) {
133
+ try {
134
+ const content = readFileSync(file, 'utf-8')
135
+ if (DSN_PATTERN.test(content)) {
136
+ result.details.hardcodedDsn = true
137
+ const relPath = file.replace(projectPath + '/', '')
138
+ result.details.dsnFiles.push(relPath)
139
+ }
140
+ } catch { /* ignore */ }
141
+ }
142
+
143
+ if (!result.details.hardcodedDsn) {
144
+ result.score += 1
145
+ } else {
146
+ result.status = 'warning'
147
+ }
148
+
149
+ // Finalize
150
+ result.score = Math.min(result.score, result.maxScore)
151
+
152
+ if (result.maxScore > 0 && result.score === 0) {
153
+ result.status = 'warning'
154
+ result.details.message = 'No Sentry error monitoring configured'
155
+ } else if (result.score < result.maxScore) {
156
+ result.status = 'warning'
157
+ const issues = []
158
+ if (!result.details.backend.hasSentryDep) issues.push('no @sentry/node in backend')
159
+ if (result.details.backend.hasSentryDep && !result.details.backend.hasInstrumentFile) issues.push('missing instrument.ts')
160
+ if (hasAnyFrontend && !result.details.frontend.hasSentryDep) issues.push('no Sentry in frontend')
161
+ if (result.details.hardcodedDsn) issues.push(`hardcoded DSN in ${result.details.dsnFiles.join(', ')}`)
162
+ if (!result.details.backend.importedFirst && result.details.backend.hasInstrumentFile) issues.push('instrument.ts not imported first')
163
+ result.details.message = issues.join(', ')
164
+ }
165
+
166
+ return result
167
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /**
8
- * @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'} HealthCheckType
8
+ * @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'|'sentry-monitoring'} HealthCheckType
9
9
  *
10
10
  * @typedef {'ok'|'warning'|'error'} HealthStatus
11
11
  *
@@ -53,16 +53,31 @@ export async function run(config, projectRoot) {
53
53
  results.passed = false
54
54
  results.findings.push({
55
55
  type: 'ci-missing',
56
- severity: 'high',
57
- message: 'No CI/CD configuration found',
58
- fix: 'Add .github/workflows/ with CI configuration'
56
+ severity: 'critical',
57
+ message: 'No CI/CD configuration found — PRs have no quality gate',
58
+ fix: 'Run: tetra-setup ci'
59
59
  })
60
- results.summary.high++
60
+ results.summary.critical++
61
61
  results.summary.total++
62
62
  return results
63
63
  }
64
64
 
65
- // Check for essential CI steps
65
+ // Check if using Tetra reusable PR Quality Gate workflow
66
+ // If so, all essential checks are covered — skip individual pattern checks
67
+ const usesTetraQualityGate = ciContent.includes('pr-quality.yml')
68
+
69
+ if (usesTetraQualityGate) {
70
+ results.info = {
71
+ ciProvider: foundCi,
72
+ tetraQualityGate: true,
73
+ workflowCount: foundCi === 'GitHub Actions'
74
+ ? readdirSync(join(projectRoot, '.github/workflows')).filter(f => f.endsWith('.yml')).length
75
+ : 1
76
+ }
77
+ return results
78
+ }
79
+
80
+ // Check for essential CI steps (only when NOT using reusable workflow)
66
81
  const essentialChecks = [
67
82
  {
68
83
  name: 'install-dependencies',
@@ -109,7 +124,7 @@ export async function run(config, projectRoot) {
109
124
  type: `ci-missing-${name}`,
110
125
  severity,
111
126
  message: `CI pipeline missing: ${name}`,
112
- fix: `Add ${name} step to your CI workflow`
127
+ fix: `Add ${name} step to your CI workflow, or use the Tetra reusable workflow: tetra-setup ci --force`
113
128
  })
114
129
  results.summary[severity]++
115
130
  results.summary.total++
@@ -0,0 +1,30 @@
1
+ #!/bin/bash
2
+ # doppler-guard.sh — PreToolUse hook
3
+ # Blocks creating/editing .env files with secrets.
4
+ # Installed by: tetra-setup hooks
5
+
6
+ INPUT=$(cat)
7
+
8
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
9
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
10
+
11
+ if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
12
+ exit 0
13
+ fi
14
+
15
+ [[ -z "$FILE_PATH" ]] && exit 0
16
+
17
+ BASENAME=$(basename "$FILE_PATH")
18
+
19
+ [[ "$BASENAME" != *".env"* ]] && exit 0
20
+
21
+ # Allowed: .env.example, .env.local, frontend/.env
22
+ [[ "$BASENAME" == ".env.example" ]] && exit 0
23
+ [[ "$BASENAME" == ".env.local" ]] && exit 0
24
+ [[ "$FILE_PATH" == *"frontend/.env" && "$BASENAME" == ".env" ]] && exit 0
25
+
26
+ echo '{
27
+ "decision": "block",
28
+ "reason": "DOPPLER GUARD: .env files with secrets are NOT allowed.\n\nUse Doppler for secret management — no .env files on disk.\n\n- Add secrets: doppler secrets set KEY=value\n- Start server: doppler run -- npm run dev\n\nAllowed exceptions:\n .env.example (template)\n .env.local (machine-specific ports)\n frontend/.env (public VITE_*/NEXT_PUBLIC_* keys)"
29
+ }'
30
+ exit 2
@@ -0,0 +1,75 @@
1
+ #!/bin/bash
2
+ # ╔══════════════════════════════════════════════════════════════════╗
3
+ # ║ WORKTREE GUARD — Block concurrent edits on shared repos ║
4
+ # ║ Installed by: tetra-setup hooks ║
5
+ # ║ ║
6
+ # ║ PreToolUse hook for Write/Edit/Bash ║
7
+ # ║ Blocks file mutations when multiple Claude sessions work on ║
8
+ # ║ the same repo WITHOUT worktree isolation. ║
9
+ # ╚══════════════════════════════════════════════════════════════════╝
10
+
11
+ INPUT=$(cat)
12
+
13
+ TOOL=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.tool_name||'')}catch{}" 2>/dev/null)
14
+ FILE_PATH=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));const i=d.tool_input||{};console.log(i.file_path||i.command||'')}catch{}" 2>/dev/null)
15
+
16
+ case "$TOOL" in
17
+ Write|Edit|MultiEdit|NotebookEdit) ;;
18
+ Bash)
19
+ case "$FILE_PATH" in
20
+ *git\ checkout*|*git\ restore*|*git\ reset*|*git\ stash*|*rm\ *|*mv\ *) ;;
21
+ *) exit 0 ;;
22
+ esac
23
+ ;;
24
+ *) exit 0 ;;
25
+ esac
26
+
27
+ case "$FILE_PATH" in
28
+ *@fix_plan.md*|*fix_plan.md*) exit 0 ;;
29
+ esac
30
+
31
+ # Temporary bypass — touch /tmp/worktree-guard-bypass-<PID>
32
+ for bf in /tmp/worktree-guard-bypass-*; do
33
+ [ -f "$bf" ] && pid="${bf##*-}" && kill -0 "$pid" 2>/dev/null && exit 0
34
+ done
35
+
36
+ CWD=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.cwd||'')}catch{}" 2>/dev/null)
37
+ [ -z "$CWD" ] && CWD=$(pwd)
38
+
39
+ GIT_DIR=$(cd "$CWD" && git rev-parse --git-dir 2>/dev/null)
40
+ GIT_COMMON=$(cd "$CWD" && git rev-parse --git-common-dir 2>/dev/null)
41
+ if [ -n "$GIT_DIR" ] && [ -n "$GIT_COMMON" ] && [ "$GIT_DIR" != "$GIT_COMMON" ]; then
42
+ exit 0
43
+ fi
44
+ [ -f "$CWD/.git" ] && exit 0
45
+
46
+ MY_PID=$$
47
+ REPO_PATH=$(cd "$CWD" && git rev-parse --show-toplevel 2>/dev/null)
48
+ [ -z "$REPO_PATH" ] && exit 0
49
+
50
+ CONCURRENT=0
51
+ while IFS= read -r pid; do
52
+ [ -z "$pid" ] && continue
53
+ [ "$pid" = "$MY_PID" ] && continue
54
+ OTHER_CWD=$(lsof -p "$pid" 2>/dev/null | grep cwd | awk '{print $NF}')
55
+ [ -z "$OTHER_CWD" ] && continue
56
+ OTHER_REPO=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)
57
+ if [ "$OTHER_REPO" = "$REPO_PATH" ]; then
58
+ OTHER_GIT_DIR=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --git-dir 2>/dev/null)
59
+ OTHER_GIT_COMMON=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null)
60
+ [ "$OTHER_GIT_DIR" = "$OTHER_GIT_COMMON" ] && CONCURRENT=$((CONCURRENT + 1))
61
+ fi
62
+ done < <(pgrep -f "claude" 2>/dev/null)
63
+
64
+ if [ "$CONCURRENT" -gt 0 ]; then
65
+ REPO_NAME=$(basename "$REPO_PATH")
66
+ cat <<EOF
67
+ {
68
+ "decision": "block",
69
+ "reason": "WORKTREE GUARD: ${CONCURRENT} other Claude session(s) working on ${REPO_NAME} without worktree isolation. Use 'claude -w <name>' or Agent tool with isolation: 'worktree' to prevent file conflicts."
70
+ }
71
+ EOF
72
+ exit 2
73
+ fi
74
+
75
+ exit 0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * CRUD E2E tests — auto-generated from FeatureConfigs.
3
+ * Tetra golden standard — tests list, create, read, update, delete for each resource.
4
+ *
5
+ * CUSTOMIZE: Add your project's resources to allConfigs below.
6
+ * Use the testing section from each FeatureConfig as a guide.
7
+ */
8
+ import { describe, it, expect, beforeAll } from 'vitest';
9
+ import { get, post, put, patch, del } from './helpers/api-client';
10
+ import { getTestContext, type TestContext } from './helpers/test-users';
11
+
12
+ let ctx: TestContext;
13
+
14
+ beforeAll(async () => {
15
+ ctx = await getTestContext();
16
+ }, 60000);
17
+
18
+ /* ---------- helpers ---------- */
19
+
20
+ function resolveBody(body: Record<string, unknown>): Record<string, unknown> {
21
+ const ts = Date.now();
22
+ const resolved: Record<string, unknown> = {};
23
+ for (const [k, v] of Object.entries(body)) {
24
+ resolved[k] = typeof v === 'string' ? v.replace('$timestamp', String(ts)) : v;
25
+ }
26
+ return resolved;
27
+ }
28
+
29
+ /* ---------- resource configs ---------- */
30
+ // CUSTOMIZE: Add entries for each FeatureConfig in your project.
31
+ // Set skip* flags for resources that don't support direct CRUD (system-created, etc.)
32
+
33
+ interface CrudConfig {
34
+ name: string;
35
+ restBasePath: string;
36
+ createBody: Record<string, unknown>;
37
+ updateBody?: Record<string, unknown>;
38
+ updateMethod?: 'PATCH' | 'PUT';
39
+ requiredFields?: string[];
40
+ skipCreate?: boolean;
41
+ skipUpdate?: boolean;
42
+ skipDelete?: boolean;
43
+ skipList?: boolean;
44
+ skipSingle?: boolean;
45
+ }
46
+
47
+ const allConfigs: CrudConfig[] = [
48
+ // TODO: Add your project's resources here
49
+ // Example:
50
+ // {
51
+ // name: 'projects',
52
+ // restBasePath: '/api/projects',
53
+ // createBody: { name: 'E2E Project $timestamp' },
54
+ // updateBody: { name: 'E2E Project Updated $timestamp' },
55
+ // updateMethod: 'PATCH',
56
+ // requiredFields: ['name'],
57
+ // },
58
+ ];
59
+
60
+ /* ---------- tests ---------- */
61
+
62
+ for (const cfg of allConfigs) {
63
+ describe(`CRUD: ${cfg.name}`, () => {
64
+ let createdId: string | undefined;
65
+
66
+ // LIST
67
+ if (!cfg.skipList) {
68
+ it(`GET ${cfg.restBasePath} returns data`, async () => {
69
+ const res = await get<any>(cfg.restBasePath, ctx.admin.token);
70
+ expect(res.status).toBe(200);
71
+ const body = res.data;
72
+ const items = Array.isArray(body) ? body
73
+ : body?.data && Array.isArray(body.data) ? body.data
74
+ : typeof body === 'object' ? body : null;
75
+ expect(items !== null).toBe(true);
76
+ });
77
+ }
78
+
79
+ // CREATE
80
+ if (!cfg.skipCreate) {
81
+ it(`POST ${cfg.restBasePath} creates resource`, async () => {
82
+ const body = resolveBody(cfg.createBody);
83
+ const res = await post<any>(cfg.restBasePath, body, ctx.admin.token);
84
+ expect([200, 201]).toContain(res.status);
85
+ createdId = res.data?.data?.id || res.data?.id;
86
+ expect(createdId).toBeTruthy();
87
+ });
88
+
89
+ // READ single
90
+ if (!cfg.skipSingle) {
91
+ it(`GET ${cfg.restBasePath}/:id reads resource`, async () => {
92
+ if (!createdId) return;
93
+ const res = await get<any>(`${cfg.restBasePath}/${createdId}`, ctx.admin.token);
94
+ expect(res.status).toBe(200);
95
+ });
96
+ }
97
+ }
98
+
99
+ // UPDATE
100
+ if (!cfg.skipCreate && !cfg.skipUpdate && cfg.updateBody) {
101
+ it(`${cfg.updateMethod || 'PATCH'} ${cfg.restBasePath}/:id updates`, async () => {
102
+ if (!createdId) return;
103
+ const body = resolveBody(cfg.updateBody!);
104
+ const method = cfg.updateMethod || 'PATCH';
105
+ const res = method === 'PUT'
106
+ ? await put<any>(`${cfg.restBasePath}/${createdId}`, body, ctx.admin.token)
107
+ : await patch<any>(`${cfg.restBasePath}/${createdId}`, body, ctx.admin.token);
108
+ expect(res.status).toBe(200);
109
+ });
110
+ }
111
+
112
+ // DELETE
113
+ if (!cfg.skipCreate && !cfg.skipDelete) {
114
+ it(`DELETE ${cfg.restBasePath}/:id removes resource`, async () => {
115
+ if (!createdId) return;
116
+ const res = await del<any>(`${cfg.restBasePath}/${createdId}`, ctx.admin.token);
117
+ expect([200, 204]).toContain(res.status);
118
+ });
119
+
120
+ it(`GET ${cfg.restBasePath}/:id returns 404 or empty after delete`, async () => {
121
+ if (!createdId) return;
122
+ const res = await get<any>(`${cfg.restBasePath}/${createdId}`, ctx.admin.token);
123
+ expect([200, 404]).toContain(res.status);
124
+ });
125
+ }
126
+
127
+ // REQUIRED FIELDS
128
+ if (!cfg.skipCreate && cfg.requiredFields?.length) {
129
+ it(`POST ${cfg.restBasePath} rejects empty body`, async () => {
130
+ const res = await post<any>(cfg.restBasePath, {}, ctx.admin.token);
131
+ expect([400, 422]).toContain(res.status);
132
+ });
133
+ }
134
+ });
135
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Permissions E2E tests — auth walls, role access, public routes.
3
+ * Tetra golden standard — verifies every protected route requires auth.
4
+ *
5
+ * CUSTOMIZE: Update protectedRoutes with ALL your admin/user API endpoints.
6
+ * Use tetra-test-audit to find routes missing from this list.
7
+ */
8
+ import { describe, it, expect, beforeAll } from 'vitest';
9
+ import { get, post, api } from './helpers/api-client';
10
+ import { getTestContext, type TestContext } from './helpers/test-users';
11
+
12
+ let ctx: TestContext;
13
+
14
+ beforeAll(async () => {
15
+ ctx = await getTestContext();
16
+ }, 60000);
17
+
18
+ /* ---------- Protected routes require auth ---------- */
19
+ // CUSTOMIZE: Add ALL protected API routes here.
20
+ // Run tetra-test-audit to find any you missed.
21
+
22
+ const protectedRoutes: string[] = [
23
+ // TODO: Add your project's protected routes
24
+ // '/api/admin/users',
25
+ // '/api/admin/projects',
26
+ ];
27
+
28
+ describe('Permissions: Protected routes require auth', () => {
29
+ for (const route of protectedRoutes) {
30
+ it(`GET ${route} returns 401 without token`, async () => {
31
+ const res = await get(route);
32
+ expect(res.status).toBe(401);
33
+ });
34
+ }
35
+ });
36
+
37
+ /* ---------- Admin can access protected routes ---------- */
38
+
39
+ describe('Permissions: Admin can access protected routes', () => {
40
+ for (const route of protectedRoutes) {
41
+ it(`GET ${route} returns 200 for admin`, async () => {
42
+ const res = await get(route, ctx.admin.token);
43
+ expect(res.status).toBe(200);
44
+ });
45
+ }
46
+ });
47
+
48
+ /* ---------- Superadmin routes forbidden for regular admin ---------- */
49
+ // CUSTOMIZE: Add superadmin-only routes
50
+
51
+ const superadminRoutes: string[] = [
52
+ // '/api/superadmin/organizations',
53
+ ];
54
+
55
+ describe('Permissions: Admin cannot access superadmin routes', () => {
56
+ for (const route of superadminRoutes) {
57
+ it(`GET ${route} returns 401 or 403 for admin`, async () => {
58
+ const res = await get(route, ctx.admin.token);
59
+ expect([401, 403]).toContain(res.status);
60
+ });
61
+ }
62
+ });
63
+
64
+ /* ---------- Public routes accessible without auth ---------- */
65
+
66
+ describe('Permissions: Public routes accessible without auth', () => {
67
+ it('GET /api/health is public', async () => {
68
+ const res = await get('/api/health');
69
+ expect(res.status).toBe(200);
70
+ });
71
+
72
+ it('POST /api/public/auth/login is public (rejects missing fields)', async () => {
73
+ const res = await post('/api/public/auth/login', {});
74
+ expect([400, 401, 422]).toContain(res.status);
75
+ });
76
+ });
77
+
78
+ /* ---------- Invalid tokens ---------- */
79
+
80
+ describe('Permissions: Invalid tokens rejected', () => {
81
+ it('rejects malformed JWT', async () => {
82
+ const firstRoute = protectedRoutes[0] || '/api/health';
83
+ const res = await get(firstRoute, 'eyJhbGciOiJIUzI1NiJ9.fake.fake');
84
+ expect(res.status).toBe(401);
85
+ });
86
+
87
+ it('rejects empty Authorization header', async () => {
88
+ const firstRoute = protectedRoutes[0] || '/api/health';
89
+ const res = await api(firstRoute, { headers: { 'Authorization': '' } });
90
+ expect(res.status).toBe(401);
91
+ });
92
+
93
+ it('rejects Bearer with no token', async () => {
94
+ const firstRoute = protectedRoutes[0] || '/api/health';
95
+ const res = await api(firstRoute, { headers: { 'Authorization': 'Bearer ' } });
96
+ expect(res.status).toBe(401);
97
+ });
98
+ });
99
+
100
+ /* ---------- RFC 7807 error format ---------- */
101
+
102
+ describe('Permissions: RFC 7807 error format', () => {
103
+ it('401 returns structured error', async () => {
104
+ const firstRoute = protectedRoutes[0] || '/api/health';
105
+ const res = await get<any>(firstRoute);
106
+ if (res.status === 401) {
107
+ expect(res.data.type || res.data.error || res.data.message).toBeTruthy();
108
+ }
109
+ });
110
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Business flows E2E tests — end-to-end user journeys.
3
+ * Tetra golden standard — tests complete workflows, not just CRUD.
4
+ *
5
+ * CUSTOMIZE: Replace with your project's actual business flows.
6
+ * Each describe block should test a complete user journey.
7
+ */
8
+ import { describe, it, expect, beforeAll } from 'vitest';
9
+ import { get, post, put, patch, del } from './helpers/api-client';
10
+ import { getTestContext, type TestContext } from './helpers/test-users';
11
+
12
+ let ctx: TestContext;
13
+
14
+ beforeAll(async () => {
15
+ ctx = await getTestContext();
16
+ }, 60000);
17
+
18
+ /* ---------- Flow 1: Main resource lifecycle ---------- */
19
+ // CUSTOMIZE: Replace with your primary resource flow
20
+
21
+ describe('Flow: Resource lifecycle (create → update → delete)', () => {
22
+ let resourceId: string;
23
+
24
+ it('creates a resource', async () => {
25
+ // TODO: POST to your main resource endpoint
26
+ // const res = await post<any>('/api/resources', { name: `E2E ${Date.now()}` }, ctx.admin.token);
27
+ // expect([200, 201]).toContain(res.status);
28
+ // resourceId = res.data?.data?.id || res.data?.id;
29
+ // expect(resourceId).toBeTruthy();
30
+ });
31
+
32
+ it('reads the resource', async () => {
33
+ if (!resourceId) return;
34
+ // const res = await get<any>(`/api/resources/${resourceId}`, ctx.admin.token);
35
+ // expect(res.status).toBe(200);
36
+ });
37
+
38
+ it('updates the resource', async () => {
39
+ if (!resourceId) return;
40
+ // const res = await patch<any>(`/api/resources/${resourceId}`, { name: 'Updated' }, ctx.admin.token);
41
+ // expect(res.status).toBe(200);
42
+ });
43
+
44
+ it('deletes the resource', async () => {
45
+ if (!resourceId) return;
46
+ // const res = await del<any>(`/api/resources/${resourceId}`, ctx.admin.token);
47
+ // expect([200, 204]).toContain(res.status);
48
+ });
49
+
50
+ it('confirms resource is gone', async () => {
51
+ if (!resourceId) return;
52
+ // const res = await get<any>(`/api/resources/${resourceId}`, ctx.admin.token);
53
+ // expect([200, 404]).toContain(res.status);
54
+ });
55
+ });
56
+
57
+ /* ---------- Edge cases ---------- */
58
+
59
+ describe('Flow: Edge cases', () => {
60
+ it('nonexistent resource returns 404 or empty', async () => {
61
+ // const res = await get<any>('/api/resources/00000000-0000-0000-0000-000000000000', ctx.admin.token);
62
+ // expect([200, 404]).toContain(res.status);
63
+ });
64
+ });