@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.
- package/bin/tetra-init-tests.js +185 -66
- package/bin/tetra-init.js +67 -3
- package/bin/tetra-setup.js +152 -30
- package/bin/tetra-setup.js.tmp +0 -0
- package/bin/tetra-test-audit.js +83 -0
- package/lib/audits/test-coverage-audit.js +646 -0
- package/lib/checks/health/index.js +2 -1
- package/lib/checks/health/rpc-param-mismatch.js +21 -0
- package/lib/checks/health/scanner.js +4 -2
- package/lib/checks/health/sentry-monitoring.js +167 -0
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/stability/ci-pipeline.js +21 -6
- package/lib/templates/hooks/doppler-guard.sh +30 -0
- package/lib/templates/hooks/worktree-guard.sh +75 -0
- package/lib/templates/tests/02-crud-resources.test.ts.tmpl +135 -0
- package/lib/templates/tests/03-permissions.test.ts.tmpl +110 -0
- package/lib/templates/tests/04-business-flows.test.ts.tmpl +64 -0
- package/lib/templates/tests/05-security.test.ts.tmpl +82 -0
- package/lib/templates/tests/global-setup.ts.tmpl +73 -0
- package/package.json +3 -2
- package/lib/templates/tests/07-security.test.ts.tmpl +0 -93
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Health Scanner
|
|
3
3
|
*
|
|
4
|
-
* Orchestrates all
|
|
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: '
|
|
57
|
-
message: 'No CI/CD configuration found',
|
|
58
|
-
fix: '
|
|
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.
|
|
60
|
+
results.summary.critical++
|
|
61
61
|
results.summary.total++
|
|
62
62
|
return results
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// Check
|
|
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
|
+
});
|