feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
436
test/setup-hooks.test.ts
Normal file
436
test/setup-hooks.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* test/setup-hooks.test.ts — T-hooks-installer tests
|
||||
*
|
||||
* Tests the exported pure functions from scripts/setup-hooks.mjs:
|
||||
* - computeMaxTimeSec: L5/SEC-M4 --max-time computation
|
||||
* - buildPermCommand: held-permission curl with dynamic max-time
|
||||
* - buildNtfyCommand: ntfy bridge curl (SEC-C6: no token literal)
|
||||
* - buildStatusLineCommand: absolute path to statusline.mjs
|
||||
* - FF_EVENTS: must include PostToolUse (H1/AC-A4.5)
|
||||
* - applyHooks: integration — install/remove idempotency, ntfy, statusLine
|
||||
*
|
||||
* Acceptance criteria covered:
|
||||
* AC-A1.7 ntfy command has correct priority; settings.json has NO token literal
|
||||
* AC-A4.5 FF_EVENTS contains PostToolUse
|
||||
* AC-B2.1 statusLine segment: present after install, idempotent, --remove cleans
|
||||
* SEC-C6 token not in settings.json/argv
|
||||
* SEC-M4 --max-time > permTimeoutMs/1000
|
||||
* L5 --max-time computed from PERM_TIMEOUT_MS env, not hardcoded
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore — vitest handles .mjs; tsc does not check test/ files
|
||||
import {
|
||||
computeMaxTimeSec,
|
||||
buildPermCommand,
|
||||
buildNtfyCommand,
|
||||
buildStatusLineCommand,
|
||||
FF_EVENTS,
|
||||
FF_COMMAND,
|
||||
PERM_COMMAND,
|
||||
MARKER,
|
||||
STATUSLINE_MARKER,
|
||||
applyHooks,
|
||||
} from '../scripts/setup-hooks.mjs'
|
||||
|
||||
// Path to the scripts directory (for absolute path assertions)
|
||||
const SCRIPTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../scripts')
|
||||
|
||||
// ── computeMaxTimeSec ────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeMaxTimeSec', () => {
|
||||
it('adds CURL_BUFFER_SEC (5) on top of ceil(permTimeoutMs/1000)', () => {
|
||||
// 300000ms = 300s + 5 = 305
|
||||
expect(computeMaxTimeSec(300_000)).toBe(305)
|
||||
})
|
||||
|
||||
it('handles custom permTimeoutMs correctly', () => {
|
||||
// 60000ms = 60s + 5 = 65
|
||||
expect(computeMaxTimeSec(60_000)).toBe(65)
|
||||
// 90001ms = ceil(90.001) = 91 + 5 = 96
|
||||
expect(computeMaxTimeSec(90_001)).toBe(96)
|
||||
})
|
||||
|
||||
it('uses default (300000ms) when permTimeoutMs is 0 (invalid)', () => {
|
||||
expect(computeMaxTimeSec(0)).toBe(305)
|
||||
})
|
||||
|
||||
it('uses default when permTimeoutMs is negative', () => {
|
||||
expect(computeMaxTimeSec(-1000)).toBe(305)
|
||||
})
|
||||
|
||||
it('uses default when called with no arguments', () => {
|
||||
expect(computeMaxTimeSec()).toBe(305)
|
||||
})
|
||||
|
||||
it('returns value > permTimeoutMs/1000 (L5/SEC-M4)', () => {
|
||||
const permTimeoutMs = 180_000
|
||||
const maxTime = computeMaxTimeSec(permTimeoutMs)
|
||||
expect(maxTime).toBeGreaterThan(permTimeoutMs / 1000)
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildPermCommand ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildPermCommand', () => {
|
||||
it('includes the provided maxTimeSec in --max-time', () => {
|
||||
const cmd = buildPermCommand(305)
|
||||
expect(cmd).toContain('--max-time 305')
|
||||
})
|
||||
|
||||
it('does NOT contain the old hardcoded value 350', () => {
|
||||
const cmd = buildPermCommand(305)
|
||||
expect(cmd).not.toContain('350')
|
||||
})
|
||||
|
||||
it('references WEBTERM_HOOK_URL/permission', () => {
|
||||
const cmd = buildPermCommand(305)
|
||||
expect(cmd).toContain('WEBTERM_HOOK_URL')
|
||||
expect(cmd).toContain('/permission')
|
||||
})
|
||||
|
||||
it('sends body via --data-binary @- (reads stdin)', () => {
|
||||
const cmd = buildPermCommand(305)
|
||||
expect(cmd).toContain('--data-binary @-')
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildNtfyCommand ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildNtfyCommand', () => {
|
||||
it('high-priority command contains "Priority: high"', () => {
|
||||
const cmd = buildNtfyCommand('high')
|
||||
expect(cmd).toContain('Priority: high')
|
||||
})
|
||||
|
||||
it('low-priority command contains "Priority: low"', () => {
|
||||
const cmd = buildNtfyCommand('low')
|
||||
expect(cmd).toContain('Priority: low')
|
||||
})
|
||||
|
||||
it('uses shell variable references for URL, TOPIC, and TOKEN (SEC-C6)', () => {
|
||||
const cmd = buildNtfyCommand('high')
|
||||
expect(cmd).toContain('WEBTERM_NTFY_URL')
|
||||
expect(cmd).toContain('WEBTERM_NTFY_TOPIC')
|
||||
expect(cmd).toContain('WEBTERM_NTFY_TOKEN')
|
||||
})
|
||||
|
||||
it('does NOT contain any literal token value (SEC-C6)', () => {
|
||||
// The token must be referenced via env var, never as a literal string
|
||||
const cmd = buildNtfyCommand('high')
|
||||
// No hardcoded token patterns (Bearer followed by non-$ string)
|
||||
expect(cmd).not.toMatch(/Bearer [^$]/)
|
||||
})
|
||||
|
||||
it('contains MARKER (WEBTERM_HOOK_URL) for cleanup idempotency', () => {
|
||||
// The no-op guard references WEBTERM_HOOK_URL, ensuring cleanup logic catches it
|
||||
const cmd = buildNtfyCommand('high')
|
||||
expect(cmd).toContain(MARKER) // MARKER = 'WEBTERM_HOOK_URL'
|
||||
})
|
||||
|
||||
it('is a no-op outside web-terminal (guard: [ -n "$WEBTERM_HOOK_URL" ])', () => {
|
||||
const cmd = buildNtfyCommand('low')
|
||||
expect(cmd).toContain('WEBTERM_HOOK_URL')
|
||||
// Command has a conditional that makes it a no-op when WEBTERM_HOOK_URL is absent
|
||||
expect(cmd).toMatch(/\[ -n .+WEBTERM_HOOK_URL/)
|
||||
})
|
||||
|
||||
it('always ends with || true (fire-and-forget, no hook failure)', () => {
|
||||
expect(buildNtfyCommand('high')).toContain('|| true')
|
||||
expect(buildNtfyCommand('low')).toContain('|| true')
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildStatusLineCommand ────────────────────────────────────────────────────
|
||||
|
||||
describe('buildStatusLineCommand', () => {
|
||||
it('includes the STATUSLINE_MARKER (statusline.mjs)', () => {
|
||||
const cmd = buildStatusLineCommand(SCRIPTS_DIR)
|
||||
expect(cmd).toContain(STATUSLINE_MARKER) // 'statusline.mjs'
|
||||
})
|
||||
|
||||
it('uses an absolute path to statusline.mjs', () => {
|
||||
const cmd = buildStatusLineCommand(SCRIPTS_DIR)
|
||||
// Command should contain an absolute path
|
||||
const scriptPath = path.join(SCRIPTS_DIR, 'statusline.mjs')
|
||||
expect(cmd).toContain(scriptPath)
|
||||
})
|
||||
|
||||
it('starts with "node " (runs the script with Node)', () => {
|
||||
const cmd = buildStatusLineCommand(SCRIPTS_DIR)
|
||||
expect(cmd).toMatch(/^node /)
|
||||
})
|
||||
})
|
||||
|
||||
// ── FF_EVENTS ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('FF_EVENTS', () => {
|
||||
it('contains PostToolUse (H1/AC-A4.5)', () => {
|
||||
expect(FF_EVENTS).toContain('PostToolUse')
|
||||
})
|
||||
|
||||
it('contains PreToolUse', () => {
|
||||
expect(FF_EVENTS).toContain('PreToolUse')
|
||||
})
|
||||
|
||||
it('contains Stop and SessionEnd (DONE signals)', () => {
|
||||
expect(FF_EVENTS).toContain('Stop')
|
||||
expect(FF_EVENTS).toContain('SessionEnd')
|
||||
})
|
||||
|
||||
it('contains UserPromptSubmit', () => {
|
||||
expect(FF_EVENTS).toContain('UserPromptSubmit')
|
||||
})
|
||||
|
||||
it('contains Notification', () => {
|
||||
expect(FF_EVENTS).toContain('Notification')
|
||||
})
|
||||
})
|
||||
|
||||
// ── FF_COMMAND / PERM_COMMAND constants ───────────────────────────────────────
|
||||
|
||||
describe('FF_COMMAND', () => {
|
||||
it('contains MARKER for cleanup identification', () => {
|
||||
expect(FF_COMMAND).toContain(MARKER)
|
||||
})
|
||||
|
||||
it('is a fire-and-forget (redirects to /dev/null, || true)', () => {
|
||||
expect(FF_COMMAND).toContain('>/dev/null')
|
||||
expect(FF_COMMAND).toContain('|| true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PERM_COMMAND (module-level constant)', () => {
|
||||
it('does NOT contain the old hardcoded 350', () => {
|
||||
// L5: --max-time must be computed, not a bare literal 350
|
||||
expect(PERM_COMMAND).not.toContain(' 350 ')
|
||||
expect(PERM_COMMAND).not.toMatch(/--max-time 350\b/)
|
||||
})
|
||||
|
||||
it('contains --max-time with a computed numeric value', () => {
|
||||
expect(PERM_COMMAND).toMatch(/--max-time \d+/)
|
||||
})
|
||||
|
||||
it('computed --max-time > default permTimeoutMs/1000 (SEC-M4)', () => {
|
||||
const match = PERM_COMMAND.match(/--max-time (\d+)/)
|
||||
expect(match).toBeTruthy()
|
||||
const maxTime = Number(match![1])
|
||||
// Default permTimeoutMs = 300000ms = 300s; max-time must be > 300
|
||||
expect(maxTime).toBeGreaterThan(300)
|
||||
})
|
||||
})
|
||||
|
||||
// ── applyHooks (integration tests) ───────────────────────────────────────────
|
||||
|
||||
describe('applyHooks', () => {
|
||||
let tmpDir: string
|
||||
let settingsPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(tmpdir(), 'webterm-setup-hooks-test-'))
|
||||
settingsPath = path.join(tmpDir, 'settings.json')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('creates settings.json when it does not exist', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
expect(existsSync(settingsPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('installs hook entry for every FF_EVENT', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
for (const ev of FF_EVENTS) {
|
||||
expect(settings.hooks[ev], `hook for ${ev} should exist`).toBeDefined()
|
||||
expect(Array.isArray(settings.hooks[ev])).toBe(true)
|
||||
expect(settings.hooks[ev].length).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('installs PostToolUse hook (H1/AC-A4.5)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.hooks['PostToolUse']).toBeDefined()
|
||||
const groups = settings.hooks['PostToolUse']
|
||||
expect(groups.some((g: { hooks?: Array<{ command?: string }> }) =>
|
||||
g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('installs PermissionRequest hook with computed --max-time (L5)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 60_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.hooks['PermissionRequest']).toBeDefined()
|
||||
const allCommands: string[] = settings.hooks['PermissionRequest']
|
||||
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
|
||||
g.hooks?.map((h) => h.command ?? '') ?? []
|
||||
)
|
||||
const permCmd = allCommands.find((c) => c.includes('/permission'))
|
||||
expect(permCmd).toBeDefined()
|
||||
// permTimeoutMs=60000 → ceil(60)+5=65
|
||||
expect(permCmd).toContain('--max-time 65')
|
||||
// must NOT contain old hardcoded value
|
||||
expect(permCmd).not.toContain(' 350 ')
|
||||
})
|
||||
|
||||
it('installs statusLine handler (B2/AC-B2.1)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.statusLine).toBeDefined()
|
||||
expect(settings.statusLine).toContain(STATUSLINE_MARKER)
|
||||
expect(settings.statusLine).toContain(path.join(SCRIPTS_DIR, 'statusline.mjs'))
|
||||
})
|
||||
|
||||
it('is idempotent: running twice produces same result (B2/AC-B2.1)', () => {
|
||||
const opts = { settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }
|
||||
applyHooks(opts)
|
||||
const first = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
applyHooks(opts)
|
||||
const second = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('--remove removes all web-terminal hook entries (AC-B2.1)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
// All hook events added by us should be gone
|
||||
for (const ev of [...FF_EVENTS, 'PermissionRequest']) {
|
||||
const groups = settings.hooks?.[ev]
|
||||
if (groups) {
|
||||
const hasOurHook = groups.some((g: { hooks?: Array<{ command?: string }> }) =>
|
||||
g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
|
||||
)
|
||||
expect(hasOurHook).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('--remove removes statusLine if it was ours (AC-B2.1)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.statusLine).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves user non-web-terminal hooks during install', () => {
|
||||
const existing = { hooks: { SomeOtherEvent: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } }
|
||||
writeFileSync(settingsPath, JSON.stringify(existing))
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.hooks['SomeOtherEvent']).toBeDefined()
|
||||
expect(settings.hooks['SomeOtherEvent'][0].hooks[0].command).toBe('echo hi')
|
||||
})
|
||||
|
||||
it('preserves user non-web-terminal hooks during --remove', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settingsAfterInstall = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
// Simulate user adding their own hook
|
||||
settingsAfterInstall.hooks['UserOwnEvent'] = [{ hooks: [{ type: 'command', command: 'echo mine' }] }]
|
||||
writeFileSync(settingsPath, JSON.stringify(settingsAfterInstall))
|
||||
applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.hooks?.['UserOwnEvent']).toBeDefined()
|
||||
})
|
||||
|
||||
it('preserves user statusLine if not ours', () => {
|
||||
const existing = { statusLine: 'node my-custom-statusline.js' }
|
||||
writeFileSync(settingsPath, JSON.stringify(existing))
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
// User's statusLine was NOT overwritten (it doesn't contain STATUSLINE_MARKER)
|
||||
expect(settings.statusLine).toBe('node my-custom-statusline.js')
|
||||
})
|
||||
|
||||
it('creates a .webterm-bak before writing when settings exists', () => {
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: {} }))
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
expect(existsSync(`${settingsPath}.webterm-bak`)).toBe(true)
|
||||
})
|
||||
|
||||
// ── ntfy bridge tests (H3, SEC-C6) ─────────────────────────────────────────
|
||||
|
||||
it('without ntfy: PermissionRequest has exactly 1 hook group', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.hooks['PermissionRequest'].length).toBe(1)
|
||||
})
|
||||
|
||||
it('with ntfy: PermissionRequest has 2 hook groups (main + ntfy high)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
expect(settings.hooks['PermissionRequest'].length).toBe(2)
|
||||
})
|
||||
|
||||
it('with ntfy: PermissionRequest ntfy group has high priority (AC-A1.7)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
const allCommands: string[] = settings.hooks['PermissionRequest']
|
||||
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
|
||||
g.hooks?.map((h) => h.command ?? '') ?? []
|
||||
)
|
||||
const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
|
||||
expect(ntfyCmd).toBeDefined()
|
||||
expect(ntfyCmd).toContain('Priority: high')
|
||||
})
|
||||
|
||||
it('with ntfy: Stop has ntfy group with low priority (AC-A1.7)', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
const allCommands: string[] = settings.hooks['Stop']
|
||||
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
|
||||
g.hooks?.map((h) => h.command ?? '') ?? []
|
||||
)
|
||||
const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
|
||||
expect(ntfyCmd).toBeDefined()
|
||||
expect(ntfyCmd).toContain('Priority: low')
|
||||
})
|
||||
|
||||
it('with ntfy: SessionEnd has ntfy group with low priority', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
const allCommands: string[] = settings.hooks['SessionEnd']
|
||||
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
|
||||
g.hooks?.map((h) => h.command ?? '') ?? []
|
||||
)
|
||||
const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
|
||||
expect(ntfyCmd).toBeDefined()
|
||||
expect(ntfyCmd).toContain('Priority: low')
|
||||
})
|
||||
|
||||
it('with ntfy: settings.json contains NO token literal (SEC-C6/AC-A1.7)', () => {
|
||||
// Even with ntfy enabled, the token must never appear as a literal
|
||||
// We simulate having a WEBTERM_NTFY_TOKEN in env at test time; the command
|
||||
// should reference it as $WEBTERM_NTFY_TOKEN, not the actual value
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const content = readFileSync(settingsPath, 'utf8')
|
||||
// The token variable is referenced, not expanded — grep for Bearer pattern
|
||||
// Ensure no "Bearer <actual-token>" pattern (only "Bearer $WEBTERM_NTFY_TOKEN")
|
||||
expect(content).not.toMatch(/Bearer [^$"\\]/)
|
||||
// The $ reference must be present
|
||||
expect(content).toContain('WEBTERM_NTFY_TOKEN')
|
||||
})
|
||||
|
||||
it('with ntfy: --remove removes ntfy hook groups too', () => {
|
||||
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
applyHooks({ settingsPath, remove: true, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
// After remove, no groups with MARKER should remain
|
||||
for (const ev of Object.keys(settings.hooks ?? {})) {
|
||||
const groups: Array<{ hooks?: Array<{ command?: string }> }> = settings.hooks[ev] ?? []
|
||||
const hasOurHook = groups.some((g) =>
|
||||
g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
|
||||
)
|
||||
expect(hasOurHook).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user