This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { request, type FullConfig } from '@playwright/test'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
|
||||
export default async function globalSetup(config: FullConfig) {
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
if (!email || !password) return
|
||||
const baseURL = config.projects[0]?.use.baseURL
|
||||
if (typeof baseURL !== 'string') {
|
||||
throw new Error('PLAYWRIGHT_BASE_URL is required for authenticated setup')
|
||||
}
|
||||
const context = await request.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: { origin: new URL(baseURL).origin },
|
||||
})
|
||||
try {
|
||||
const response = await context.post('/api/auth/sign-in/email', {
|
||||
data: { email, password },
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Browser authentication failed with ${response.status()}`)
|
||||
}
|
||||
await mkdir('test-results/.auth', { recursive: true })
|
||||
await context.storageState({ path: 'test-results/.auth/m2.json' })
|
||||
} finally {
|
||||
await context.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
|
||||
function profile(name: string) {
|
||||
return {
|
||||
apiVersion: 'devrunbook.io/v1alpha1',
|
||||
kind: 'RepositoryProfile',
|
||||
metadata: { name, revision: 1, source: 'manual' },
|
||||
spec: {
|
||||
repositoryType: 'single-app',
|
||||
defaultBranch: 'main',
|
||||
stack: {
|
||||
languages: ['TypeScript'],
|
||||
frameworks: ['Next.js'],
|
||||
packageManagers: ['pnpm'],
|
||||
databases: ['PostgreSQL'],
|
||||
deploymentTypes: ['Docker'],
|
||||
testFrameworks: ['Vitest', 'Playwright'],
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
id: 'verify',
|
||||
role: 'unit-test',
|
||||
command: 'pnpm test',
|
||||
workingDirectory: '.',
|
||||
platform: 'any',
|
||||
shell: 'auto',
|
||||
source: 'manual',
|
||||
confirmed: true,
|
||||
safeForAgentSuggestion: false,
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
applicationRoots: ['apps/web'],
|
||||
testRoots: ['tests'],
|
||||
documentationRoots: ['docs'],
|
||||
generated: ['dist'],
|
||||
protected: ['runtime/secrets'],
|
||||
excluded: ['node_modules'],
|
||||
},
|
||||
policies: {
|
||||
preserveBackwardCompatibility: true,
|
||||
newDependencies: 'justify',
|
||||
gitWrite: 'none',
|
||||
migrations: 'plan-only',
|
||||
documentationRequired: true,
|
||||
networkAccess: 'forbidden',
|
||||
productionDataAccess: 'forbidden',
|
||||
requiredValidationRoles: ['unit-test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function origin(testInfo: { project: { use: { baseURL?: string } } }) {
|
||||
return new URL(String(testInfo.project.use.baseURL)).origin
|
||||
}
|
||||
|
||||
async function createRepository(
|
||||
request: APIRequestContext,
|
||||
requestOrigin: string,
|
||||
name: string,
|
||||
) {
|
||||
const response = await request.post('/api/v1/repositories', {
|
||||
headers: { origin: requestOrigin },
|
||||
data: { displayName: name, initialProfile: profile(name) },
|
||||
})
|
||||
expect(response.status()).toBe(201)
|
||||
return {
|
||||
body: (await response.json()) as {
|
||||
repository: { id: string; currentProfileRevision: number | null }
|
||||
currentProfile: {
|
||||
revision: number
|
||||
profile: ReturnType<typeof profile> & {
|
||||
metadata: { contentDigest: string }
|
||||
}
|
||||
}
|
||||
},
|
||||
etag: response.headers().etag,
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('authenticated Milestone 3 repository profiles', () => {
|
||||
test.skip(!email || !password, 'live local-account credentials are required')
|
||||
test.use({ storageState: 'test-results/.auth/m2.json' })
|
||||
|
||||
test('immutable HTTP lifecycle, conflicts, exports and re-imports are governed', async ({
|
||||
request,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'chromium', 'mutation is covered once')
|
||||
const requestOrigin = origin(testInfo)
|
||||
const name = `API evidence ${Date.now()}`
|
||||
const created = await createRepository(request, requestOrigin, name)
|
||||
const repositoryId = created.body.repository.id
|
||||
expect(created.etag).toMatch(/^"profile:1:[0-9a-f]{64}"$/u)
|
||||
expect(created.body.currentProfile.revision).toBe(1)
|
||||
|
||||
const listed = await request.get('/api/v1/repositories?source=manual')
|
||||
expect(listed.status()).toBe(200)
|
||||
expect(
|
||||
(
|
||||
(await listed.json()) as {
|
||||
items: { id: string; currentProfileRevision: number | null }[]
|
||||
}
|
||||
).items,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ id: repositoryId, currentProfileRevision: 1 }),
|
||||
)
|
||||
|
||||
const current = await request.get(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
)
|
||||
expect(current.status()).toBe(200)
|
||||
expect(current.headers().etag).toBe(created.etag)
|
||||
const currentBody = (await current.json()) as {
|
||||
revision: number
|
||||
profile: ReturnType<typeof profile>
|
||||
}
|
||||
|
||||
const noOp = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': created.etag },
|
||||
data: currentBody.profile,
|
||||
},
|
||||
)
|
||||
expect(noOp.status()).toBe(200)
|
||||
expect((await noOp.json()).revision).toBe(1)
|
||||
|
||||
const changedProfile = {
|
||||
...currentBody.profile,
|
||||
spec: {
|
||||
...currentBody.profile.spec,
|
||||
paths: {
|
||||
...currentBody.profile.spec.paths,
|
||||
protected: ['runtime/secrets', '.github/workflows'],
|
||||
},
|
||||
},
|
||||
}
|
||||
const revised = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': created.etag },
|
||||
data: changedProfile,
|
||||
},
|
||||
)
|
||||
expect(revised.status()).toBe(201)
|
||||
expect((await revised.json()).revision).toBe(2)
|
||||
const revisedEtag = revised.headers().etag
|
||||
expect(revisedEtag).toMatch(/^"profile:2:[0-9a-f]{64}"$/u)
|
||||
|
||||
const stale = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': created.etag },
|
||||
data: changedProfile,
|
||||
},
|
||||
)
|
||||
expect(stale.status()).toBe(409)
|
||||
await expect(stale.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 'repository_profile_conflict',
|
||||
details: [
|
||||
expect.objectContaining({
|
||||
currentRevision: 2,
|
||||
currentEtag: revisedEtag,
|
||||
recovery: 'reload-and-review',
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const missingPrecondition = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{ headers: { origin: requestOrigin }, data: changedProfile },
|
||||
)
|
||||
expect(missingPrecondition.status()).toBe(428)
|
||||
|
||||
for (const format of ['json', 'yaml'] as const) {
|
||||
const exported = await request.get(
|
||||
`/api/v1/repositories/${repositoryId}/profile/export?format=${format}`,
|
||||
)
|
||||
expect(exported.status()).toBe(200)
|
||||
expect(exported.headers().etag).toBe(revisedEtag)
|
||||
const reimported = await request.post('/api/v1/repositories', {
|
||||
headers: {
|
||||
origin: requestOrigin,
|
||||
'content-type':
|
||||
format === 'json' ? 'application/json' : 'application/yaml',
|
||||
},
|
||||
data: await exported.body(),
|
||||
})
|
||||
expect(reimported.status()).toBe(201)
|
||||
}
|
||||
|
||||
const invalid = {
|
||||
...changedProfile,
|
||||
spec: {
|
||||
...changedProfile.spec,
|
||||
paths: { ...changedProfile.spec.paths, protected: ['../outside'] },
|
||||
},
|
||||
}
|
||||
const rejected = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': revisedEtag },
|
||||
data: invalid,
|
||||
},
|
||||
)
|
||||
expect(rejected.status()).toBe(422)
|
||||
expect(await rejected.text()).toContain('/spec/paths/protected/0')
|
||||
|
||||
const crossOrigin = await request.post('/api/v1/repositories', {
|
||||
headers: { origin: 'https://attacker.example' },
|
||||
data: { displayName: 'Rejected', initialProfile: profile('Rejected') },
|
||||
})
|
||||
expect(crossOrigin.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('manual UI creation, immutable editing and composer context remain usable', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'chromium', 'mutation is covered once')
|
||||
const errors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
const name = `Browser evidence ${Date.now()}`
|
||||
await page.goto('/repositories/new')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Create a repository profile',
|
||||
}),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/Nothing entered here is executed/u),
|
||||
).toBeVisible()
|
||||
await page.getByLabel('Display name').fill(name)
|
||||
await page.getByRole('button', { name: 'Create repository' }).click()
|
||||
await expect(page).toHaveURL(/\/repositories\/[0-9a-f-]+$/u)
|
||||
await expect(page.getByRole('heading', { level: 1, name })).toBeVisible()
|
||||
await expect(page.getByText('Immutable profile revision 1')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/does not run repository commands/u),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Edit profile' }).click()
|
||||
await page
|
||||
.getByLabel('Protected paths')
|
||||
.fill('runtime/secrets, .github/workflows')
|
||||
await page.getByRole('button', { name: 'Save immutable revision' }).click()
|
||||
await expect(page.getByRole('status')).toContainText('Revision 2 saved')
|
||||
await page.getByRole('link', { name: 'Back to repository' }).click()
|
||||
await expect(page.getByText('Immutable profile revision 2')).toBeVisible()
|
||||
await expect(page.getByText('.github/workflows')).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Create task' }).click()
|
||||
await expect(page).toHaveURL(/\/composer\/new\?repository=/u)
|
||||
await expect(page.getByText('Repository context retained')).toBeVisible()
|
||||
await expect(page.getByText('.github/workflows')).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('narrow keyboard, theme and reduced-motion behavior stay operable', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/repositories')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Repositories' }),
|
||||
).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
),
|
||||
).toBe(true)
|
||||
await page.keyboard.press('Control+K')
|
||||
const palette = page.getByRole('dialog', { name: 'Command palette' })
|
||||
await expect(palette).toBeVisible()
|
||||
await palette.getByRole('combobox').fill('repositories')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/\/repositories$/u)
|
||||
await page.getByRole('button', { name: 'Use light theme' }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
|
||||
test.describe('authenticated Milestone 2 library', () => {
|
||||
test.skip(!email || !password, 'live local-account credentials are required')
|
||||
test.use({ storageState: 'test-results/.auth/m2.json' })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/library')
|
||||
await expect(page).toHaveURL(/\/library(?:\?.*)?$/u)
|
||||
})
|
||||
|
||||
test('search, filter, view and refresh state remain URL-backed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Library' }),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-playbook-slug]')).toHaveCount(28)
|
||||
await page.getByLabel('Search playbooks').fill('root cause')
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
||||
await expect(page).toHaveURL(/q=root(?:\+|%20)cause/u)
|
||||
await expect(
|
||||
page.locator('[data-playbook-slug="root-cause-bugfix"]'),
|
||||
).toBeVisible()
|
||||
await page.getByRole('link', { name: 'Dense' }).click()
|
||||
await expect(page).toHaveURL(/view=dense/u)
|
||||
await page.reload()
|
||||
await expect(page.locator('[data-view="dense"]')).toBeVisible()
|
||||
await expect(page.getByLabel('Search playbooks')).toHaveValue('root cause')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('favorites persist and can be recovered through the saved filter', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'chromium', 'mutation is covered once')
|
||||
const card = page.locator('[data-playbook-slug="root-cause-bugfix"]')
|
||||
const favorite = card.getByRole('button', { name: /favorites/i })
|
||||
const initiallySaved =
|
||||
(await favorite.getAttribute('aria-pressed')) === 'true'
|
||||
if (!initiallySaved) await favorite.click()
|
||||
await expect(favorite).toHaveAttribute('aria-pressed', 'true')
|
||||
await page.getByLabel('Saved playbooks only').check()
|
||||
await page.getByRole('button', { name: 'Apply filters' }).click()
|
||||
await expect(page).toHaveURL(/favorites=true/u)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: /favorites/i }).click()
|
||||
await expect(
|
||||
card.getByRole('button', { name: /favorites/i }),
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
test('detail exposes governance panels and version-bound composer handoff', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/library/root-cause-bugfix')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /root.?cause/i }),
|
||||
).toBeVisible()
|
||||
for (const panel of [
|
||||
'Intent and outcome',
|
||||
'Inputs and defaults',
|
||||
'Repository compatibility',
|
||||
'Guardrails',
|
||||
'Workflow',
|
||||
'Validation requirements',
|
||||
'Completion criteria',
|
||||
'Quality and limitations',
|
||||
'Package files',
|
||||
'Version history',
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 2, name: panel }),
|
||||
).toBeVisible()
|
||||
}
|
||||
await page.getByRole('link', { name: /Compose with this version/i }).click()
|
||||
await expect(page).toHaveURL(
|
||||
/\/composer\/new\?playbook=root-cause-bugfix&version=1\.0\.0/u,
|
||||
)
|
||||
await expect(page.getByText('root-cause-bugfix@1.0.0')).toBeVisible()
|
||||
})
|
||||
|
||||
test('command palette, theme and narrow keyboard layout are operable', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/library')
|
||||
await page.keyboard.press('Control+K')
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Command palette' }),
|
||||
).toBeVisible()
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Command palette' })
|
||||
.getByRole('combobox')
|
||||
.fill('root cause')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/\/library\/root-cause-bugfix$/u)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
await page.getByRole('button', { name: 'Use light theme' }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
await page.reload()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('catalog shell renders the verified built-in slice', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveTitle(/DevRunbook/)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /Turn intent into a verifiable change contract/i,
|
||||
}),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('28 built-ins · no Gitea required')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 3 })).toHaveCount(6)
|
||||
await expect(page.locator('[data-nextjs-dialog]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('catalog API requires a local authenticated workspace', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get('/api/v1/playbooks')
|
||||
expect(response.status()).toBe(401)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: { code: 'authentication_required' },
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy playbook links preserve the authentication boundary', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/playbooks/root-cause-bugfix')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /continue with your local account/i,
|
||||
}),
|
||||
).toBeVisible()
|
||||
expect(new URL(page.url()).pathname).toBe('/login')
|
||||
})
|
||||
|
||||
test('narrow layout has no horizontal overflow and keyboard focus is visible', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByRole('link', { name: /DevRunbook/i })).toBeFocused()
|
||||
const outline = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as Element).outlineStyle,
|
||||
)
|
||||
expect(outline).not.toBe('none')
|
||||
})
|
||||
|
||||
test('security headers are present on application pages', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get('/')
|
||||
expect(response.headers()['x-content-type-options']).toBe('nosniff')
|
||||
expect(response.headers()['x-frame-options']).toBe('DENY')
|
||||
expect(response.headers()['content-security-policy']).toContain(
|
||||
"frame-ancestors 'none'",
|
||||
)
|
||||
})
|
||||
|
||||
test('tokenless bootstrap is denied through an untrusted proxy boundary', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.post('/api/v1/instance/setup', {
|
||||
headers: { 'x-forwarded-for': '127.0.0.1' },
|
||||
data: {
|
||||
bootstrapToken: '',
|
||||
instanceName: 'Test instance',
|
||||
publicBaseUrl: 'http://127.0.0.1:3000',
|
||||
owner: {
|
||||
email: 'owner@example.test',
|
||||
displayName: 'Owner',
|
||||
password: 'not-a-real-password',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.status()).toBe(403)
|
||||
expect(await response.text()).not.toContain('not-a-real-password')
|
||||
})
|
||||
|
||||
test('setup UI exposes the database recovery state without enabling credentials', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
Boolean(process.env.DEVRUNBOOK_E2E_EMAIL),
|
||||
'recovery state requires an uninitialized validation database',
|
||||
)
|
||||
const consoleErrors: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
await page.goto('/setup')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Make this instance yours.' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('recovery required')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
await expect(page.getByLabel('Owner email')).toBeDisabled()
|
||||
await expect(page.getByText(/Gitea is optional/i)).toBeVisible()
|
||||
expect(consoleErrors).toEqual([])
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
test('local sign-in fails generically and clears credentials when auth is unavailable', async ({
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
|
||||
await page.goto('/login?returnTo=https%3A%2F%2Fattacker.example%2Fsteal')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /continue with your local account/i,
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByLabel('Email address').fill('owner@example.test')
|
||||
const password = page.getByLabel('Password', { exact: true })
|
||||
await password.fill('not-a-real-password')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await expect(page.locator('form').getByRole('alert')).toHaveText(
|
||||
'Sign-in could not be completed. Check your credentials or try again later.',
|
||||
)
|
||||
await expect(password).toHaveValue('')
|
||||
expect(new URL(page.url()).pathname).toBe('/login')
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
test('password recovery keeps its token out of HTTP and clears both credentials', async ({
|
||||
page,
|
||||
}) => {
|
||||
const token = 'a'.repeat(43)
|
||||
const requestedUrls: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('request', (request) => requestedUrls.push(request.url()))
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
|
||||
await page.goto(`/reset-password#token=${token}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /restore access/i }),
|
||||
).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/reset-password$/u)
|
||||
expect(requestedUrls.every((url) => !url.includes(token))).toBe(true)
|
||||
|
||||
const password = page.getByLabel('New password', { exact: true })
|
||||
const confirmation = page.getByLabel('Confirm new password')
|
||||
await password.fill('a secure replacement password')
|
||||
await confirmation.fill('a secure replacement password')
|
||||
await page.getByRole('button', { name: 'Update password' }).click()
|
||||
|
||||
await expect(page.locator('form').getByRole('alert')).toContainText(
|
||||
'invalid, expired, or already used',
|
||||
)
|
||||
await expect(password).toHaveValue('')
|
||||
await expect(confirmation).toHaveValue('')
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
|
||||
const criticalRoutes = [
|
||||
'/start',
|
||||
'/composer/new',
|
||||
'/repositories',
|
||||
'/runs',
|
||||
'/account',
|
||||
'/account/security',
|
||||
'/repositories/new',
|
||||
'/management',
|
||||
] as const
|
||||
|
||||
test.describe('Phase 14 accessibility regression', () => {
|
||||
test.skip(!email || !password, 'live local-account credentials are required')
|
||||
test.use({
|
||||
storageState: 'test-results/.auth/m2.json',
|
||||
reducedMotion: 'reduce',
|
||||
})
|
||||
|
||||
for (const route of criticalRoutes) {
|
||||
test(`${route} has one main and no serious accessibility violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(route)
|
||||
await expect(page.locator('main')).toHaveCount(1)
|
||||
await expect(page.locator('[id]')).toHaveCount(
|
||||
await page
|
||||
.locator('[id]')
|
||||
.evaluateAll(
|
||||
(elements) => new Set(elements.map((element) => element.id)).size,
|
||||
),
|
||||
)
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.analyze()
|
||||
expect(
|
||||
results.violations.filter(
|
||||
({ impact }) => impact === 'critical' || impact === 'serious',
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('Start remains keyboard complete, reflows and exposes touch targets', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/start')
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.locator(':focus-visible')).toHaveCount(1)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
const undersizedPrimaryTargets = await page
|
||||
.locator('main button, main a[href="/settings/integrations/gitea/new"]')
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.filter((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
(box.width < 44 || box.height < 44)
|
||||
)
|
||||
})
|
||||
.map(
|
||||
(element) =>
|
||||
element.getAttribute('aria-label') ?? element.textContent,
|
||||
),
|
||||
)
|
||||
expect(undersizedPrimaryTargets).toEqual([])
|
||||
})
|
||||
|
||||
test('critical pages reflow at a 200 percent equivalent CSS viewport', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 640, height: 720 })
|
||||
for (const route of criticalRoutes) {
|
||||
await page.goto(route)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
for (const locale of ['en', 'nl'] as const) {
|
||||
for (const mode of ['simple', 'expert'] as const) {
|
||||
test(`${locale}/${mode} presentation stays accessible`, async ({
|
||||
page,
|
||||
context,
|
||||
baseURL,
|
||||
}) => {
|
||||
const origin = new URL(baseURL ?? 'http://127.0.0.1:3000')
|
||||
await context.addCookies([
|
||||
{ name: 'devrunbook_locale', value: locale, url: origin.origin },
|
||||
{ name: 'devrunbook_mode', value: mode, url: origin.origin },
|
||||
])
|
||||
await page.goto('/start')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name:
|
||||
locale === 'nl' ? 'Wat wil je doen?' : 'What do you want to do?',
|
||||
}),
|
||||
).toBeVisible()
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.analyze()
|
||||
expect(
|
||||
results.violations.filter(
|
||||
({ impact }) => impact === 'critical' || impact === 'serious',
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const hasCredentials = Boolean(
|
||||
process.env.DEVRUNBOOK_E2E_EMAIL && process.env.DEVRUNBOOK_E2E_PASSWORD,
|
||||
)
|
||||
|
||||
test.describe('Usability recovery', () => {
|
||||
test.skip(!hasCredentials, 'live local-account credentials are required')
|
||||
test.use({ storageState: 'test-results/.auth/m2.json' })
|
||||
|
||||
test.beforeEach(async ({ context, baseURL }) => {
|
||||
if (!baseURL) throw new Error('A browser base URL is required')
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'devrunbook_locale',
|
||||
value: 'nl',
|
||||
url: baseURL,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('Task library is compact and understandable on mobile', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/library')
|
||||
|
||||
await expect(page).toHaveTitle('Taakbibliotheek · DevRunbook')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Kies een bewezen taak' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.drb-library-filters details'),
|
||||
).not.toHaveAttribute('open', '')
|
||||
await expect(page.locator('.drb-playbook-card')).toHaveCount(8)
|
||||
await expect(page.getByRole('button', { name: /Meer tonen/ })).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const undersizedNavigationTargets = await page
|
||||
.locator(
|
||||
'.drb-app-shell__header button, .drb-app-shell__header a, .drb-app-shell__mobile-tabs a',
|
||||
)
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.filter((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
(box.width < 44 || box.height < 44)
|
||||
)
|
||||
})
|
||||
.map((element) => element.textContent?.trim()),
|
||||
)
|
||||
expect(undersizedNavigationTargets).toEqual([])
|
||||
})
|
||||
|
||||
test('Dutch shell, account and role context use ordinary language', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/account')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'DevRunbook startpagina' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByLabel('Actieve werkruimte')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Jouw account' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Rol in deze werkruimte')).toBeVisible()
|
||||
|
||||
await page.goto('/account/security')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Wachtwoord en sessies' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Wachtwoord veilig herstellen' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
test('Start action does not cover mobile content', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/start')
|
||||
await expect(page).toHaveTitle('Nieuwe taak · DevRunbook')
|
||||
expect(
|
||||
await page.locator('main').evaluate((main) => {
|
||||
const stickyElements = [...main.querySelectorAll('*')].filter(
|
||||
(element) => getComputedStyle(element).position === 'sticky',
|
||||
)
|
||||
return stickyElements.length
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
createGeneratedArtifact,
|
||||
downloadGeneratedArtifact,
|
||||
} from '../../packages/application/src/index'
|
||||
import { LocalArtifactStorage } from '../../packages/artifacts/src/index'
|
||||
import {
|
||||
closeDatabase,
|
||||
DrizzleGeneratedArtifactStore,
|
||||
DrizzleWorkspaceAuthorizationLookup,
|
||||
getSqlClient,
|
||||
} from '../../packages/db/src/index'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
const integration = databaseUrl ? describe : describe.skip
|
||||
const cleanupRoots: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase()
|
||||
await Promise.all(
|
||||
cleanupRoots.splice(0).map((root) => rm(root, { recursive: true })),
|
||||
)
|
||||
})
|
||||
|
||||
integration('generated artifact persistence', () => {
|
||||
it('persists authorized metadata and restart-readable verified bytes', async () => {
|
||||
const sql = getSqlClient(databaseUrl)
|
||||
const userId = randomUUID()
|
||||
const workspaceId = randomUUID()
|
||||
const otherWorkspaceId = randomUUID()
|
||||
const playbookId = randomUUID()
|
||||
const versionId = randomUUID()
|
||||
const runId = randomUUID()
|
||||
const artifactId = randomUUID()
|
||||
const namespace = `artifact-integration-${randomUUID()}`
|
||||
const artifactRoot = await mkdtemp(
|
||||
path.join(tmpdir(), 'devrunbook-artifact-integration-'),
|
||||
)
|
||||
cleanupRoots.push(artifactRoot)
|
||||
|
||||
await sql.begin(async (transaction) => {
|
||||
await transaction`
|
||||
insert into users (id, email, display_name, password_hash, instance_role)
|
||||
values (${userId}, ${`${userId}@example.invalid`}, 'Artifact Integration', 'not-a-login-credential', 'user')
|
||||
`
|
||||
await transaction`
|
||||
insert into workspaces (id, name)
|
||||
values (${workspaceId}, 'Artifact Workspace'), (${otherWorkspaceId}, 'Other Workspace')
|
||||
`
|
||||
await transaction`
|
||||
insert into workspace_memberships (user_id, workspace_id, role)
|
||||
values (${userId}, ${workspaceId}, 'editor')
|
||||
`
|
||||
await transaction`
|
||||
insert into playbooks (id, workspace_id, logical_id, slug, namespace, source_type)
|
||||
values (${playbookId}, ${workspaceId}, 'artifact-integration', 'artifact-integration', ${namespace}, 'private')
|
||||
`
|
||||
await transaction`
|
||||
insert into playbook_versions (
|
||||
id, playbook_id, semantic_version, lifecycle, package_api_version,
|
||||
title, summary, category, risk_tier, package_json, template_text,
|
||||
content_digest, created_by
|
||||
) values (
|
||||
${versionId}, ${playbookId}, '1.0.0', 'validated',
|
||||
'devrunbook.playbook/v1.2', 'Artifact integration',
|
||||
'Integration fixture', 'test', 'low', '{}'::jsonb, '# Test',
|
||||
${'a'.repeat(64)}, ${userId}
|
||||
)
|
||||
`
|
||||
await transaction`
|
||||
insert into generated_runs (
|
||||
id, workspace_id, playbook_version_id, playbook_snapshot_json,
|
||||
repository_profile_snapshot_json, normalized_input_json,
|
||||
policy_snapshot_json, provenance_json, lint_result_json,
|
||||
rendered_prompt, render_digest, idempotency_key, generated_by
|
||||
) values (
|
||||
${runId}, ${workspaceId}, ${versionId}, '{}'::jsonb, null,
|
||||
'{}'::jsonb, '{}'::jsonb, '[]'::jsonb,
|
||||
'{"exportReadiness":"ready","findings":[]}'::jsonb,
|
||||
'# Test', ${'b'.repeat(64)}, ${`artifact-${artifactId}`}, ${userId}
|
||||
)
|
||||
`
|
||||
})
|
||||
|
||||
const metadata = new DrizzleGeneratedArtifactStore()
|
||||
const authorization = new DrizzleWorkspaceAuthorizationLookup()
|
||||
const content = new TextEncoder().encode('# Test\n')
|
||||
const created = await createGeneratedArtifact(
|
||||
{
|
||||
authorization,
|
||||
metadata,
|
||||
storage: new LocalArtifactStorage(artifactRoot),
|
||||
now: () => new Date('2026-07-27T12:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
actor: { userId },
|
||||
workspaceId,
|
||||
artifactId,
|
||||
runId,
|
||||
artifactType: 'markdown',
|
||||
filename: 'test.md',
|
||||
mediaType: 'text/markdown; charset=utf-8',
|
||||
content,
|
||||
},
|
||||
)
|
||||
expect(created.created).toBe(true)
|
||||
|
||||
const [persisted] = await sql<
|
||||
{ id: string; runId: string; sha256: string; sizeBytes: string }[]
|
||||
>`
|
||||
select id, run_id as "runId", sha256, size_bytes as "sizeBytes"
|
||||
from generated_artifacts
|
||||
where id = ${artifactId}
|
||||
`
|
||||
expect(persisted).toMatchObject({
|
||||
id: artifactId,
|
||||
runId,
|
||||
sha256: created.artifact.sha256,
|
||||
sizeBytes: String(content.byteLength),
|
||||
})
|
||||
|
||||
const downloaded = await downloadGeneratedArtifact(
|
||||
{
|
||||
authorization,
|
||||
metadata: new DrizzleGeneratedArtifactStore(),
|
||||
storage: new LocalArtifactStorage(artifactRoot),
|
||||
},
|
||||
{ actor: { userId }, workspaceId, artifactId },
|
||||
)
|
||||
expect(downloaded.content).toEqual(content)
|
||||
expect(downloaded.artifact.sha256).toHaveLength(64)
|
||||
await expect(
|
||||
metadata.listByRunInWorkspace(runId, workspaceId),
|
||||
).resolves.toMatchObject([
|
||||
{ id: artifactId, runId, workspaceId, sha256: created.artifact.sha256 },
|
||||
])
|
||||
await expect(
|
||||
metadata.listByRunInWorkspace(runId, otherWorkspaceId),
|
||||
).resolves.toEqual([])
|
||||
|
||||
await expect(
|
||||
downloadGeneratedArtifact(
|
||||
{
|
||||
authorization,
|
||||
metadata,
|
||||
storage: new LocalArtifactStorage(artifactRoot),
|
||||
},
|
||||
{ actor: { userId }, workspaceId: otherWorkspaceId, artifactId },
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'workspace_access_denied' })
|
||||
|
||||
await sql`delete from workspaces where id in (${workspaceId}, ${otherWorkspaceId})`
|
||||
await sql`delete from users where id = ${userId}`
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,574 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
authorizeWorkspaceAction,
|
||||
completeFirstRun,
|
||||
composeAndCreateGeneratedRun,
|
||||
createGeneratedArtifact,
|
||||
downloadGeneratedArtifact,
|
||||
getGeneratedRun,
|
||||
type ImmutableJsonObject,
|
||||
} from '../../packages/application/src/index'
|
||||
import { LocalArtifactStorage } from '../../packages/artifacts/src/index'
|
||||
import type {
|
||||
CanonicalPromptRequest,
|
||||
PlaybookMetadata,
|
||||
PlaybookSpecification,
|
||||
RepositoryProfile,
|
||||
} from '../../packages/composer/src/index'
|
||||
import {
|
||||
canonicalJson,
|
||||
loadBuiltInPlaybookRecords,
|
||||
sha256,
|
||||
} from '../../packages/content/src/index'
|
||||
import {
|
||||
closeDatabase,
|
||||
DrizzleFirstRunStore,
|
||||
DrizzleFirstRunTransactionRunner,
|
||||
DrizzleGeneratedArtifactStore,
|
||||
DrizzleGeneratedRunStore,
|
||||
DrizzlePlaybookCatalog,
|
||||
DrizzleWorkspaceAuthorizationLookup,
|
||||
getPersistedInstanceStatus,
|
||||
getSqlClient,
|
||||
} from '../../packages/db/src/index'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const exampleProfile: RepositoryProfile = {
|
||||
metadata: { name: 'Example TypeScript Service', revision: 1 },
|
||||
spec: {
|
||||
repositoryType: 'single-app',
|
||||
stack: {
|
||||
languages: ['TypeScript'],
|
||||
frameworks: ['Next.js'],
|
||||
packageManagers: ['pnpm'],
|
||||
databases: ['PostgreSQL'],
|
||||
deploymentTypes: ['Docker Compose'],
|
||||
},
|
||||
commands: [
|
||||
['lint', 'pnpm lint'],
|
||||
['typecheck', 'pnpm typecheck'],
|
||||
['unit-test', 'pnpm test'],
|
||||
['build', 'pnpm build'],
|
||||
].map(([role, command]) => ({
|
||||
role: role!,
|
||||
command: command!,
|
||||
workingDirectory: '.',
|
||||
})),
|
||||
paths: {
|
||||
applicationRoots: ['apps/web', 'packages'],
|
||||
testRoots: ['tests', 'apps/web/tests'],
|
||||
documentationRoots: ['docs'],
|
||||
protected: ['data', 'backups', '.env'],
|
||||
excluded: ['node_modules', '.git'],
|
||||
},
|
||||
policies: {
|
||||
preserveBackwardCompatibility: true,
|
||||
newDependencies: 'justify',
|
||||
gitWrite: 'none',
|
||||
migrations: 'reversible-only',
|
||||
productionDataAccess: 'forbidden',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function loadGovernedExampleProfile(): Promise<ImmutableJsonObject> {
|
||||
return parse(
|
||||
await readFile(
|
||||
path.resolve('examples/repository-profiles/example-profile.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
) as ImmutableJsonObject
|
||||
}
|
||||
|
||||
let ownerId = ''
|
||||
let primaryWorkspaceId = ''
|
||||
let generatedRunId = ''
|
||||
let generatedArtifactId = ''
|
||||
|
||||
afterAll(async () => closeDatabase())
|
||||
|
||||
const databaseIntegration = process.env.DATABASE_URL ? describe : describe.skip
|
||||
|
||||
databaseIntegration('Milestone 0 service-backed vertical slice', () => {
|
||||
it('atomically initializes, imports, composes, persists and protects the golden task', async () => {
|
||||
await expect(getPersistedInstanceStatus()).resolves.toMatchObject({
|
||||
state: 'uninitialized',
|
||||
setupRequired: true,
|
||||
})
|
||||
|
||||
const records = await loadBuiltInPlaybookRecords()
|
||||
const setup = await completeFirstRun(new DrizzleFirstRunStore(records), {
|
||||
instanceName: 'Integration instance',
|
||||
publicBaseUrl: 'http://127.0.0.1:3000',
|
||||
owner: {
|
||||
email: 'owner@integration.test',
|
||||
displayName: 'Integration owner',
|
||||
passwordHash: 'better-auth-prehashed-integration-fixture',
|
||||
},
|
||||
configuration: { instanceName: 'Integration instance' },
|
||||
configurationDigest: sha256(
|
||||
canonicalJson({ instanceName: 'Integration instance' }),
|
||||
),
|
||||
})
|
||||
ownerId = setup.ownerId
|
||||
primaryWorkspaceId = setup.workspaceId
|
||||
|
||||
const sql = getSqlClient()
|
||||
const [catalogCount] = await sql<{ count: number }[]>`
|
||||
select count(*)::int as count from playbook_versions
|
||||
`
|
||||
expect(catalogCount?.count).toBe(28)
|
||||
await expect(getPersistedInstanceStatus()).resolves.toMatchObject({
|
||||
state: 'ready',
|
||||
setupRequired: false,
|
||||
})
|
||||
|
||||
const rootCause = records.find(
|
||||
(record) => record.slug === 'root-cause-bugfix',
|
||||
)
|
||||
expect(rootCause).toBeDefined()
|
||||
const [version] = await sql<{ id: string }[]>`
|
||||
select pv.id
|
||||
from playbook_versions pv
|
||||
join playbooks p on p.id = pv.playbook_id
|
||||
where p.slug = 'root-cause-bugfix' and pv.semantic_version = '1.0.0'
|
||||
`
|
||||
expect(version?.id).toBeTruthy()
|
||||
|
||||
const manifest = rootCause!.packageJson as unknown as {
|
||||
metadata: PlaybookMetadata
|
||||
spec: PlaybookSpecification
|
||||
}
|
||||
const inputs = {
|
||||
problemStatement: 'Example value for Problem statement',
|
||||
reproductionClues: '',
|
||||
preserveCompatibility: true,
|
||||
affectedScope: [],
|
||||
}
|
||||
const prompt: CanonicalPromptRequest = {
|
||||
metadata: manifest.metadata,
|
||||
specification: manifest.spec,
|
||||
template: rootCause!.templateText,
|
||||
inputs,
|
||||
workMode: 'guided',
|
||||
autonomyLevel: 'verify',
|
||||
repositoryProfile: exampleProfile,
|
||||
}
|
||||
const request = {
|
||||
prompt,
|
||||
snapshots: {
|
||||
playbook: rootCause!.packageJson as unknown as ImmutableJsonObject,
|
||||
repositoryProfile: await loadGovernedExampleProfile(),
|
||||
normalizedInput: inputs,
|
||||
policy: { autonomyLevel: 'verify', conditionsResolved: true },
|
||||
provenance: [],
|
||||
},
|
||||
lint: { exportReadiness: 'ready' as const, findings: [] },
|
||||
generatedBy: setup.ownerId,
|
||||
workspaceId: setup.workspaceId,
|
||||
playbookVersionId: version!.id,
|
||||
idempotencyKey: 'milestone-zero-root-cause-golden',
|
||||
}
|
||||
const dependencies = {
|
||||
store: new DrizzleGeneratedRunStore(),
|
||||
nextId: randomUUID,
|
||||
now: () => new Date(),
|
||||
workspaceAuthorization: new DrizzleWorkspaceAuthorizationLookup(),
|
||||
}
|
||||
const created = await composeAndCreateGeneratedRun(dependencies, request)
|
||||
const golden = await readFile(
|
||||
path.resolve('examples/rendered-prompts/root-cause-bugfix.md'),
|
||||
'utf8',
|
||||
)
|
||||
expect(created.created).toBe(true)
|
||||
expect(created.run.renderedPrompt).toBe(golden)
|
||||
expect(created.run.renderDigest).toBe(sha256(golden))
|
||||
generatedRunId = created.run.id
|
||||
generatedArtifactId = randomUUID()
|
||||
const artifactRoot = process.env.ARTIFACT_ROOT
|
||||
if (!artifactRoot)
|
||||
throw new Error('ARTIFACT_ROOT is required for integration tests')
|
||||
const artifactBytes = new TextEncoder().encode(golden)
|
||||
const artifact = await createGeneratedArtifact(
|
||||
{
|
||||
authorization: new DrizzleWorkspaceAuthorizationLookup(),
|
||||
metadata: new DrizzleGeneratedArtifactStore(),
|
||||
storage: new LocalArtifactStorage(artifactRoot),
|
||||
now: () => new Date(),
|
||||
},
|
||||
{
|
||||
actor: { userId: ownerId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
artifactId: generatedArtifactId,
|
||||
runId: created.run.id,
|
||||
artifactType: 'prompt_text',
|
||||
filename: 'root-cause-bugfix.md',
|
||||
mediaType: 'text/markdown; charset=utf-8',
|
||||
content: artifactBytes,
|
||||
},
|
||||
)
|
||||
expect(artifact.created).toBe(true)
|
||||
expect(artifact.artifact.sha256).toBe(sha256(golden))
|
||||
|
||||
const retried = await composeAndCreateGeneratedRun(dependencies, request)
|
||||
expect(retried.created).toBe(false)
|
||||
expect(retried.run.id).toBe(created.run.id)
|
||||
|
||||
await expect(
|
||||
sql`update generated_runs set rendered_prompt = 'mutated' where id = ${created.run.id}`,
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
sql`update playbook_versions set title = 'mutated' where id = ${version!.id}`,
|
||||
).rejects.toThrow(/playbook_versions is immutable/u)
|
||||
|
||||
const duplicateRunner = new DrizzleFirstRunTransactionRunner()
|
||||
await expect(
|
||||
duplicateRunner.run(records, (transaction) =>
|
||||
transaction.importBuiltInPlaybooks(),
|
||||
),
|
||||
).resolves.toEqual({ imported: 28 })
|
||||
const catalogBeforeReconnect =
|
||||
await new DrizzlePlaybookCatalog().listBuiltIns()
|
||||
expect(catalogBeforeReconnect).toHaveLength(28)
|
||||
expect(
|
||||
catalogBeforeReconnect.map(({ slug, version, digest }) => ({
|
||||
slug,
|
||||
version,
|
||||
digest,
|
||||
})),
|
||||
).toEqual(
|
||||
records.map((record) => ({
|
||||
slug: record.slug,
|
||||
version: record.semanticVersion,
|
||||
digest: record.contentDigest,
|
||||
})),
|
||||
)
|
||||
|
||||
const searchableCatalog = new DrizzlePlaybookCatalog()
|
||||
const searchMatches = await searchableCatalog.list({
|
||||
q: 'root cause defect',
|
||||
category: ['bugfixing'],
|
||||
riskTier: ['moderate'],
|
||||
lifecycle: ['reviewed'],
|
||||
source: ['built_in'],
|
||||
})
|
||||
expect(searchMatches.map(({ slug }) => slug)).toEqual(['root-cause-bugfix'])
|
||||
const rootCauseDetail = await searchableCatalog.findBySlug(
|
||||
'root-cause-bugfix',
|
||||
'built_in',
|
||||
)
|
||||
expect(rootCauseDetail?.current).toMatchObject({
|
||||
version: '1.0.0',
|
||||
digest: rootCause!.contentDigest,
|
||||
manifest: { metadata: { slug: 'root-cause-bugfix' } },
|
||||
})
|
||||
expect(rootCauseDetail?.versions).toHaveLength(1)
|
||||
await expect(
|
||||
searchableCatalog.findVersionBySlug(
|
||||
'root-cause-bugfix',
|
||||
'1.0.0',
|
||||
'built_in',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
template: rootCause!.templateText,
|
||||
quality: rootCause!.packageJson.quality,
|
||||
})
|
||||
|
||||
const conflictingRecords = records.map((record) =>
|
||||
record.slug === 'root-cause-bugfix'
|
||||
? { ...record, contentDigest: '0'.repeat(64) }
|
||||
: record,
|
||||
)
|
||||
await expect(
|
||||
duplicateRunner.run(conflictingRecords, (transaction) =>
|
||||
transaction.importBuiltInPlaybooks(),
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'playbook_version_conflict' })
|
||||
|
||||
await closeDatabase()
|
||||
await expect(new DrizzlePlaybookCatalog().listBuiltIns()).resolves.toEqual(
|
||||
catalogBeforeReconnect,
|
||||
)
|
||||
const restoredArtifact = await downloadGeneratedArtifact(
|
||||
{
|
||||
authorization: new DrizzleWorkspaceAuthorizationLookup(),
|
||||
metadata: new DrizzleGeneratedArtifactStore(),
|
||||
storage: new LocalArtifactStorage(artifactRoot),
|
||||
},
|
||||
{
|
||||
actor: { userId: ownerId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
artifactId: generatedArtifactId,
|
||||
},
|
||||
)
|
||||
expect(Buffer.from(restoredArtifact.content).toString('utf8')).toBe(golden)
|
||||
expect(restoredArtifact.artifact.sha256).toBe(sha256(golden))
|
||||
}, 30_000)
|
||||
|
||||
it('persists role boundaries without cross-workspace or instance-admin bypass', async () => {
|
||||
expect(ownerId).toBeTruthy()
|
||||
expect(primaryWorkspaceId).toBeTruthy()
|
||||
const sql = getSqlClient()
|
||||
const viewerId = randomUUID()
|
||||
const editorId = randomUUID()
|
||||
const administratorId = randomUUID()
|
||||
const isolatedWorkspaceId = randomUUID()
|
||||
|
||||
await sql`
|
||||
insert into users (
|
||||
id, email, display_name, password_hash, email_verified,
|
||||
instance_role, status
|
||||
) values
|
||||
(${viewerId}, 'viewer@integration.test', 'Integration viewer', 'not-a-login-credential', true, 'user', 'active'),
|
||||
(${editorId}, 'editor@integration.test', 'Integration editor', 'not-a-login-credential', true, 'user', 'active'),
|
||||
(${administratorId}, 'admin@integration.test', 'Integration administrator', 'not-a-login-credential', true, 'instance_admin', 'active')
|
||||
`
|
||||
await sql`
|
||||
insert into workspaces (id, name, type)
|
||||
values (${isolatedWorkspaceId}, 'Isolated integration workspace', 'team')
|
||||
`
|
||||
await sql`
|
||||
insert into workspace_memberships (workspace_id, user_id, role)
|
||||
values
|
||||
(${primaryWorkspaceId}, ${viewerId}, 'viewer'),
|
||||
(${primaryWorkspaceId}, ${editorId}, 'editor')
|
||||
`
|
||||
|
||||
const lookup = new DrizzleWorkspaceAuthorizationLookup()
|
||||
await expect(
|
||||
authorizeWorkspaceAction(lookup, {
|
||||
actor: { userId: viewerId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
action: 'read',
|
||||
}),
|
||||
).resolves.toMatchObject({ workspaceRole: 'viewer' })
|
||||
await expect(
|
||||
authorizeWorkspaceAction(lookup, {
|
||||
actor: { userId: viewerId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
action: 'write',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'workspace_access_denied' })
|
||||
const reader = new DrizzleGeneratedRunStore()
|
||||
await expect(
|
||||
getGeneratedRun(
|
||||
{ reader, workspaceAuthorization: lookup },
|
||||
{
|
||||
actor: { userId: viewerId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
runId: generatedRunId,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ id: generatedRunId })
|
||||
await expect(
|
||||
getGeneratedRun(
|
||||
{ reader, workspaceAuthorization: lookup },
|
||||
{
|
||||
actor: { userId: viewerId },
|
||||
workspaceId: isolatedWorkspaceId,
|
||||
runId: generatedRunId,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'workspace_access_denied' })
|
||||
const artifactRoot = process.env.ARTIFACT_ROOT
|
||||
if (!artifactRoot)
|
||||
throw new Error('ARTIFACT_ROOT is required for integration tests')
|
||||
await expect(
|
||||
downloadGeneratedArtifact(
|
||||
{
|
||||
authorization: lookup,
|
||||
metadata: new DrizzleGeneratedArtifactStore(),
|
||||
storage: new LocalArtifactStorage(artifactRoot),
|
||||
},
|
||||
{
|
||||
actor: { userId: viewerId },
|
||||
workspaceId: isolatedWorkspaceId,
|
||||
artifactId: generatedArtifactId,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'workspace_access_denied' })
|
||||
await expect(
|
||||
authorizeWorkspaceAction(lookup, {
|
||||
actor: { userId: editorId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
action: 'write',
|
||||
}),
|
||||
).resolves.toMatchObject({ workspaceRole: 'editor' })
|
||||
await expect(
|
||||
authorizeWorkspaceAction(lookup, {
|
||||
actor: { userId: editorId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
action: 'destructive',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'workspace_access_denied' })
|
||||
await expect(
|
||||
authorizeWorkspaceAction(lookup, {
|
||||
actor: { userId: ownerId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
action: 'destructive',
|
||||
}),
|
||||
).resolves.toMatchObject({ workspaceRole: 'owner' })
|
||||
await expect(
|
||||
authorizeWorkspaceAction(lookup, {
|
||||
actor: { userId: viewerId },
|
||||
workspaceId: isolatedWorkspaceId,
|
||||
action: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'workspace_access_denied' })
|
||||
await expect(
|
||||
authorizeWorkspaceAction(lookup, {
|
||||
actor: { userId: administratorId },
|
||||
workspaceId: primaryWorkspaceId,
|
||||
action: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'workspace_access_denied' })
|
||||
})
|
||||
|
||||
it('persists the four representative golden compositions and their digests', async () => {
|
||||
const slugs = [
|
||||
'repository-health-audit',
|
||||
'root-cause-bugfix',
|
||||
'feature-from-spec',
|
||||
'production-readiness-audit',
|
||||
] as const
|
||||
const records = await loadBuiltInPlaybookRecords()
|
||||
const sql = getSqlClient()
|
||||
const store = new DrizzleGeneratedRunStore()
|
||||
const governedExampleProfile = await loadGovernedExampleProfile()
|
||||
|
||||
for (const slug of slugs) {
|
||||
const record = records.find((candidate) => candidate.slug === slug)
|
||||
expect(record, slug).toBeDefined()
|
||||
const manifest = record!.packageJson as unknown as {
|
||||
metadata: PlaybookMetadata
|
||||
spec: PlaybookSpecification & {
|
||||
compatibility?: { repositoryRequired?: boolean }
|
||||
}
|
||||
}
|
||||
const example = parse(
|
||||
await readFile(
|
||||
path.resolve('content/playbooks', slug, 'examples/minimal.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
) as {
|
||||
workMode: string
|
||||
autonomyLevel: CanonicalPromptRequest['autonomyLevel']
|
||||
inputs?: CanonicalPromptRequest['inputs'] & ImmutableJsonObject
|
||||
repositoryProfile?: string
|
||||
}
|
||||
const [version] = await sql<{ id: string }[]>`
|
||||
select pv.id
|
||||
from playbook_versions pv
|
||||
join playbooks p on p.id = pv.playbook_id
|
||||
where p.slug = ${slug} and pv.semantic_version = ${record!.semanticVersion}
|
||||
`
|
||||
expect(version?.id, slug).toBeTruthy()
|
||||
const inputs = example.inputs ?? {}
|
||||
const selectedProfile =
|
||||
example.repositoryProfile ||
|
||||
manifest.spec.compatibility?.repositoryRequired
|
||||
? exampleProfile
|
||||
: null
|
||||
const requiredInput = manifest.spec.inputs?.find(
|
||||
(definition) => definition.required,
|
||||
)
|
||||
expect(requiredInput, `${slug} required input`).toBeDefined()
|
||||
const invalidInputs = { ...inputs }
|
||||
delete invalidInputs[requiredInput!.key]
|
||||
await expect(
|
||||
composeAndCreateGeneratedRun(
|
||||
{
|
||||
store,
|
||||
nextId: randomUUID,
|
||||
now: () => new Date(),
|
||||
workspaceAuthorization: new DrizzleWorkspaceAuthorizationLookup(),
|
||||
},
|
||||
{
|
||||
prompt: {
|
||||
metadata: manifest.metadata,
|
||||
specification: manifest.spec,
|
||||
template: record!.templateText,
|
||||
inputs: invalidInputs,
|
||||
workMode: example.workMode,
|
||||
autonomyLevel: example.autonomyLevel,
|
||||
repositoryProfile: selectedProfile,
|
||||
},
|
||||
snapshots: {
|
||||
playbook: record!.packageJson as unknown as ImmutableJsonObject,
|
||||
repositoryProfile: selectedProfile
|
||||
? governedExampleProfile
|
||||
: null,
|
||||
normalizedInput: invalidInputs,
|
||||
policy: {
|
||||
autonomyLevel: example.autonomyLevel,
|
||||
conditionsResolved: true,
|
||||
},
|
||||
provenance: [],
|
||||
},
|
||||
lint: { exportReadiness: 'ready', findings: [] },
|
||||
generatedBy: ownerId,
|
||||
workspaceId: primaryWorkspaceId,
|
||||
playbookVersionId: version!.id,
|
||||
idempotencyKey: `milestone-zero-invalid-${slug}`,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'composition_input_invalid' })
|
||||
const golden = await readFile(
|
||||
path.resolve('examples/rendered-prompts', `${slug}.md`),
|
||||
'utf8',
|
||||
)
|
||||
const result = await composeAndCreateGeneratedRun(
|
||||
{
|
||||
store,
|
||||
nextId: randomUUID,
|
||||
now: () => new Date(),
|
||||
workspaceAuthorization: new DrizzleWorkspaceAuthorizationLookup(),
|
||||
},
|
||||
{
|
||||
prompt: {
|
||||
metadata: manifest.metadata,
|
||||
specification: manifest.spec,
|
||||
template: record!.templateText,
|
||||
inputs,
|
||||
workMode: example.workMode,
|
||||
autonomyLevel: example.autonomyLevel,
|
||||
repositoryProfile: selectedProfile,
|
||||
},
|
||||
snapshots: {
|
||||
playbook: record!.packageJson as unknown as ImmutableJsonObject,
|
||||
repositoryProfile: selectedProfile ? governedExampleProfile : null,
|
||||
normalizedInput: inputs,
|
||||
policy: {
|
||||
autonomyLevel: example.autonomyLevel,
|
||||
conditionsResolved: true,
|
||||
},
|
||||
provenance: [],
|
||||
},
|
||||
lint: { exportReadiness: 'ready', findings: [] },
|
||||
generatedBy: ownerId,
|
||||
workspaceId: primaryWorkspaceId,
|
||||
playbookVersionId: version!.id,
|
||||
idempotencyKey: `milestone-zero-representative-${slug}`,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.run.renderedPrompt, slug).toBe(golden)
|
||||
expect(result.run.renderDigest, slug).toBe(sha256(golden))
|
||||
const [persisted] = await sql<
|
||||
{ renderedPrompt: string; renderDigest: string }[]
|
||||
>`
|
||||
select rendered_prompt as "renderedPrompt", render_digest as "renderDigest"
|
||||
from generated_runs
|
||||
where id = ${result.run.id} and workspace_id = ${primaryWorkspaceId}
|
||||
`
|
||||
expect(persisted, slug).toEqual({
|
||||
renderedPrompt: golden,
|
||||
renderDigest: sha256(golden),
|
||||
})
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repositoryRoot = process.cwd()
|
||||
|
||||
function sourceFiles(relativeRoot: string): string[] {
|
||||
const absoluteRoot = path.join(repositoryRoot, relativeRoot)
|
||||
return readdirSync(absoluteRoot, { recursive: true, withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/\.(?:ts|tsx)$/u.test(entry.name) &&
|
||||
!entry.name.endsWith('.test.ts'),
|
||||
)
|
||||
.map((entry) => path.join(entry.parentPath, entry.name))
|
||||
}
|
||||
|
||||
function violations(files: readonly string[], forbidden: RegExp) {
|
||||
return files
|
||||
.filter((file) => forbidden.test(readFileSync(file, 'utf8')))
|
||||
.map((file) => path.relative(repositoryRoot, file).replaceAll('\\', '/'))
|
||||
}
|
||||
|
||||
describe('architectural dependency boundaries', () => {
|
||||
it('keeps persistence adapters out of Next.js route handlers and UI', () => {
|
||||
const routes = sourceFiles('apps/web/src/app').filter((file) =>
|
||||
file.endsWith(`${path.sep}route.ts`),
|
||||
)
|
||||
const ui = sourceFiles('packages/ui/src')
|
||||
const forbidden = /(?:@devrunbook\/db|drizzle-orm)/u
|
||||
|
||||
expect(violations(routes, forbidden)).toEqual([])
|
||||
expect(violations(ui, forbidden)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps domain and application packages independent of framework and persistence code', () => {
|
||||
expect(
|
||||
violations(
|
||||
sourceFiles('packages/domain/src'),
|
||||
/(?:@devrunbook\/(?:application|db)|drizzle-orm|from ['"]next(?:\/|['"]))/u,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(
|
||||
violations(
|
||||
sourceFiles('packages/application/src'),
|
||||
/(?:@devrunbook\/db|drizzle-orm|from ['"]next(?:\/|['"]))/u,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('prevents content and composer code from executing imported instructions', () => {
|
||||
const files = [
|
||||
...sourceFiles('packages/content/src'),
|
||||
...sourceFiles('packages/composer/src'),
|
||||
]
|
||||
expect(
|
||||
violations(
|
||||
files,
|
||||
/(?:node:child_process|child_process|Bun\.spawn|Deno\.Command)/u,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps operator commands attached to application use cases', () => {
|
||||
const commands = sourceFiles('apps/worker/src/operator')
|
||||
for (const command of commands) {
|
||||
const source = readFileSync(command, 'utf8')
|
||||
expect(source, path.relative(repositoryRoot, command)).toContain(
|
||||
'@devrunbook/application',
|
||||
)
|
||||
expect(source, path.relative(repositoryRoot, command)).not.toMatch(
|
||||
/(?:getSqlClient|\.execute\(|sql`)/u,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps worker orchestration downstream and job handlers adapter-free', () => {
|
||||
const upstream = [
|
||||
...sourceFiles('packages/domain/src'),
|
||||
...sourceFiles('packages/application/src'),
|
||||
...sourceFiles('packages/db/src'),
|
||||
...sourceFiles('apps/web/src'),
|
||||
]
|
||||
expect(
|
||||
violations(upstream, /(?:@devrunbook\/worker|apps\/worker)/u),
|
||||
).toEqual([])
|
||||
|
||||
const handlers = sourceFiles('apps/worker/src/jobs').filter((file) =>
|
||||
file.endsWith(`${path.sep}handlers.ts`),
|
||||
)
|
||||
expect(
|
||||
violations(
|
||||
handlers,
|
||||
/(?:@devrunbook\/db|drizzle-orm|getSqlClient|node:child_process|sql`)/u,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type {
|
||||
AuthPersistence,
|
||||
AuthSessionRecord,
|
||||
AuthUserRecord,
|
||||
CreateAuthSessionRecord,
|
||||
} from '../../packages/application/src/auth/auth-service'
|
||||
import { AuthService } from '../../packages/application/src/auth/auth-service'
|
||||
import { TokenDigester } from '../../packages/application/src/auth/token-digest'
|
||||
import { loadPlaybookPackage } from '../../packages/content/src/loader'
|
||||
import { createLogger } from '../../packages/observability/src/index'
|
||||
import { handleAuthRequest } from '../../apps/web/src/auth/csrf'
|
||||
import {
|
||||
assertNonSecretConfiguration,
|
||||
authorizeBootstrap,
|
||||
} from '../../apps/web/src/setup/setup-policy'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function maliciousPackage(): Promise<string> {
|
||||
const directory = await mkdtemp(
|
||||
path.join(tmpdir(), 'devrunbook-security-content-'),
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
await cp(path.resolve('content/playbooks/root-cause-bugfix'), directory, {
|
||||
recursive: true,
|
||||
})
|
||||
const manifestPath = path.join(directory, 'playbook.yaml')
|
||||
const manifest = (await readFile(manifestPath, 'utf8')).replace(
|
||||
' sensitive: false\n includeInOutput: true',
|
||||
' sensitive: true\n includeInOutput: true',
|
||||
)
|
||||
await writeFile(manifestPath, manifest)
|
||||
return directory
|
||||
}
|
||||
|
||||
class CapturingAuthPersistence implements AuthPersistence {
|
||||
readonly user: AuthUserRecord = {
|
||||
id: 'user-1',
|
||||
email: 'owner@example.test',
|
||||
displayName: 'Owner',
|
||||
passwordHash: '[password-hash]',
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
status: 'active',
|
||||
createdAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
sessionInput: CreateAuthSessionRecord | undefined
|
||||
|
||||
async findUserById(id: string) {
|
||||
return id === this.user.id ? this.user : null
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return email === this.user.email ? this.user : null
|
||||
}
|
||||
|
||||
async updateUser() {
|
||||
return this.user
|
||||
}
|
||||
|
||||
async createSession(input: CreateAuthSessionRecord) {
|
||||
this.sessionInput = input
|
||||
return {
|
||||
id: 'session-1',
|
||||
revokedAt: null,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
async findSessionByTokenHash(): Promise<AuthSessionRecord | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async touchSession(): Promise<AuthSessionRecord | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async revokeSessionByTokenHash() {
|
||||
return false
|
||||
}
|
||||
|
||||
async revokeSessionsForUser() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
describe('production security boundaries', () => {
|
||||
it('uses the database workspace migration runner copied into the image', async () => {
|
||||
const dockerfile = await readFile(path.resolve('Dockerfile'), 'utf8')
|
||||
|
||||
expect(dockerfile).toContain(
|
||||
'CMD ["./packages/db/node_modules/.bin/tsx", "packages/db/src/migrate.ts"]',
|
||||
)
|
||||
expect(dockerfile).not.toContain(
|
||||
'CMD ["./node_modules/.bin/tsx", "packages/db/src/migrate.ts"]',
|
||||
)
|
||||
})
|
||||
|
||||
it('ships the worker as a standalone bundle without production node_modules', async () => {
|
||||
const dockerfile = await readFile(path.resolve('Dockerfile'), 'utf8')
|
||||
const workerPackage = JSON.parse(
|
||||
await readFile(path.resolve('apps/worker/package.json'), 'utf8'),
|
||||
) as { scripts: { build: string } }
|
||||
|
||||
expect(workerPackage.scripts.build).toContain(
|
||||
'esbuild src/index.ts src/operator/password-reset.ts',
|
||||
)
|
||||
expect(workerPackage.scripts.build).toContain('--bundle --platform=node')
|
||||
expect(workerPackage.scripts.build).toContain(
|
||||
'const require = createRequire(import.meta.url)',
|
||||
)
|
||||
expect(dockerfile).toContain(
|
||||
'cp -a /app/apps/worker/dist/. /out/worker/dist/',
|
||||
)
|
||||
expect(dockerfile).not.toContain(
|
||||
'pnpm --filter @devrunbook/worker --prod deploy',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects secret-bearing package output and nested setup configuration', async () => {
|
||||
await expect(loadPlaybookPackage(await maliciousPackage())).rejects.toThrow(
|
||||
'sensitive input problemStatement cannot be included in output',
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
assertNonSecretConfiguration({
|
||||
integration: { apiToken: 'must-never-be-persisted' },
|
||||
}),
|
||||
).toThrow(
|
||||
'configuration.integration.apiToken must not contain secret material',
|
||||
)
|
||||
expect(() =>
|
||||
assertNonSecretConfiguration({
|
||||
integrationEncryptionKeyVersion: 'v2',
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('persists only a peppered HMAC session digest', async () => {
|
||||
const rawToken = 'raw-session-token-with-high-entropy-0123456789'
|
||||
const persistence = new CapturingAuthPersistence()
|
||||
const digester = new TokenDigester(Buffer.alloc(32, 7))
|
||||
const service = new AuthService(
|
||||
persistence,
|
||||
digester,
|
||||
() => new Date('2026-07-27T12:00:00.000Z'),
|
||||
)
|
||||
|
||||
const active = await service.createSession({
|
||||
userId: persistence.user.id,
|
||||
rawToken,
|
||||
})
|
||||
|
||||
expect(active?.rawToken).toBe(rawToken)
|
||||
expect(JSON.stringify(persistence.sessionInput)).not.toContain(rawToken)
|
||||
expect(persistence.sessionInput?.tokenHash).toMatch(
|
||||
/^hmac-sha256:v1:[a-f0-9]{64}$/u,
|
||||
)
|
||||
expect(
|
||||
digester.matches(rawToken, persistence.sessionInput!.tokenHash),
|
||||
).toBe(true)
|
||||
expect(new TokenDigester(Buffer.alloc(32, 8)).digest(rawToken)).not.toBe(
|
||||
persistence.sessionInput?.tokenHash,
|
||||
)
|
||||
})
|
||||
|
||||
it('denies proxy bootstrap bypass and cross-origin credential requests without reflection', async () => {
|
||||
const proxied = new Request('http://127.0.0.1/api/v1/instance/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'x-forwarded-for': '127.0.0.1' },
|
||||
})
|
||||
expect(authorizeBootstrap(proxied, '', undefined)).toBe(false)
|
||||
expect(
|
||||
authorizeBootstrap(proxied, 'attacker-token', 'operator-token'),
|
||||
).toBe(false)
|
||||
|
||||
const handler = vi.fn(async () => new Response('should not run'))
|
||||
const rawCookie = 'devrunbook.session=raw-session-secret'
|
||||
const request = new Request(
|
||||
'https://runbook.example.test/api/auth/session',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://attacker.example.test',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
cookie: rawCookie,
|
||||
},
|
||||
},
|
||||
)
|
||||
const response = await handleAuthRequest(
|
||||
request,
|
||||
handler,
|
||||
'https://runbook.example.test',
|
||||
)
|
||||
const body = await response.text()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
expect(body).not.toContain('attacker.example.test')
|
||||
expect(body).not.toContain(rawCookie)
|
||||
expect(body).toContain('INVALID_ORIGIN')
|
||||
})
|
||||
|
||||
it('redacts root and nested secret fields from structured logs', async () => {
|
||||
let output = ''
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
output += String(chunk)
|
||||
return true
|
||||
})
|
||||
const logger = createLogger('info')
|
||||
|
||||
logger.info(
|
||||
{
|
||||
password: 'root-password-secret',
|
||||
token: 'root-token-secret',
|
||||
req: {
|
||||
headers: {
|
||||
authorization: 'Bearer raw-authorization-secret',
|
||||
cookie: 'session=raw-cookie-secret',
|
||||
},
|
||||
},
|
||||
account: { secret: 'nested-account-secret' },
|
||||
},
|
||||
'security redaction probe',
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
expect(output).not.toContain('root-password-secret')
|
||||
expect(output).not.toContain('root-token-secret')
|
||||
expect(output).not.toContain('raw-authorization-secret')
|
||||
expect(output).not.toContain('raw-cookie-secret')
|
||||
expect(output).not.toContain('nested-account-secret')
|
||||
expect(output).toContain('[REDACTED]')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user