8 Commits

Author SHA1 Message Date
4ce7df4649 Merge pull request 'fix: skip db boot init in test env to prevent parallel worker lock' (#4) from fix/db-boot-test-isolation into dev
Some checks failed
CI / test (push) Successful in 9m29s
CI / build-dev (push) Has been cancelled
Reviewed-on: #4
2026-03-28 11:41:55 -04:00
6c04a30c3a fix: skip db boot init in test env to prevent parallel worker lock
All checks were successful
CI / test (pull_request) Successful in 9m29s
CI / build-dev (pull_request) Has been skipped
Vitest runs test files in parallel workers. Each worker imports server/db.js,
which triggered module-level init(DEFAULT_PATH) unconditionally. Two workers
racing to open the same SQLite file caused "database is locked", followed
by process.exit(1) killing the worker — surfacing as:

  Error: process.exit unexpectedly called with "1"

Fix: guard the boot init block behind NODE_ENV !== 'test'. Vitest sets
NODE_ENV=test automatically. Each worker's beforeEach(() => _resetForTest())
initialises its own :memory: database, so no file coordination is needed.

process.exit(1) is also guarded by the same condition — it must never
fire inside a test runner process.

TDD: two regression tests added to tests/db.test.js documenting the
expected boot behaviour and proving the module loads cleanly in parallel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:31:55 -04:00
1412b2e0b7 Merge pull request 'feat: build :dev Docker image on push to dev' (#1) from chore/dev-staging-build into dev
All checks were successful
CI / test (push) Successful in 9m29s
CI / build-dev (push) Successful in 13s
Reviewed-on: #1
2026-03-28 10:38:48 -04:00
30b037ff9c feat: build :dev Docker image on push to dev
All checks were successful
CI / test (pull_request) Successful in 9m30s
CI / build-dev (pull_request) Has been skipped
Adds a build-dev job to ci.yml that fires after tests pass on direct
pushes to dev (not PRs). Pushes two tags to the registry:

  :dev          — mutable, always the latest integrated dev state
  :dev-<sha>    — immutable, for tracing exactly which commit is running

Staging servers can pull :dev to test before a release PR is opened.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:27:23 -04:00
7a5b5d7afc chore: establish dev branch and branching workflow
All checks were successful
CI / test (push) Successful in 9m26s
Merges the initial ci.yml + release.yml workflow changes onto dev.
This is the first merge under the new feature-branch → dev → main model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:16:00 -04:00
3383bee968 chore: replace build.yml with ci.yml + release.yml
Splits the single workflow into two with distinct responsibilities:

ci.yml    — runs tests on push/PR to dev and main. Powers the required
            status check for branch protection on both branches.

release.yml — triggers on push to main (merged PR). Reads version from
              package.json, asserts the tag doesn't already exist, creates
              the git tag, generates patch notes from commits since the
              previous tag, builds and pushes the Docker image, and creates
              the Gitea release. No more manual git tag or git push --tags.

build.yml deleted — all three of its jobs are covered by the new files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:15:52 -04:00
0c30e4bd29 chore: release v1.1.2
All checks were successful
Build / test (push) Successful in 9m27s
Build / build (push) Successful in 27s
Build / release (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:57 -04:00
01f83d25f6 fix: SPA deep-link assets and broken home screen CSS
Three root causes addressed:

1. Added <base href="/"> to index.html so all relative asset paths
   (css/app.css, js/*.js) resolve from the root regardless of the
   current SPA route. Without this, /instance/117 requested
   /instance/css/app.css, which hit the SPA fallback and returned
   HTML; helmet's nosniff then refused it as a stylesheet.

2. Removed upgrade-insecure-requests from the CSP (useDefaults: false).
   This directive told browsers to upgrade HTTP→HTTPS for every asset
   request, breaking all resource loading on HTTP-only deployments.

3. Changed script-src-attr from 'none' to 'unsafe-inline' to allow
   the inline onclick handlers used throughout the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:48 -04:00
9 changed files with 232 additions and 89 deletions

View File

@@ -1,84 +0,0 @@
name: Build
on:
push:
branches: [main]
tags:
- 'v*'
env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
build:
runs-on: ubuntu-latest
needs: test
if: startsWith(gitea.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
release:
runs-on: ubuntu-latest
needs: build
if: startsWith(gitea.ref, 'refs/tags/v')
steps:
- name: Create release
run: |
curl -sf -X POST \
-H "Authorization: token ${{ secrets.TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{
\"tag_name\": \"${{ gitea.ref_name }}\",
\"name\": \"Catalyst ${{ gitea.ref_name }}\",
\"body\": \"### Image\n\n\`${{ env.IMAGE }}:${{ gitea.ref_name }}\`\",
\"draft\": false,
\"prerelease\": false
}"

50
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: npm
- run: npm ci
- run: npm test
build-dev:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:dev
${{ env.IMAGE }}:dev-${{ gitea.sha }}

View File

@@ -0,0 +1,95 @@
name: Release
on:
push:
branches: [main]
env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: npm
- run: npm ci
- run: npm test
- name: Read version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=${VERSION}" >> $GITEA_ENV
- name: Assert tag does not exist
run: |
if git ls-remote --tags origin "refs/tags/v${{ env.VERSION }}" | grep -q .; then
echo "ERROR: tag v${{ env.VERSION }} already exists — bump version in package.json before merging to main."
exit 1
fi
- name: Create and push tag
run: |
git config user.name "gitea-actions"
git config user.email "actions@gitea"
git tag "v${{ env.VERSION }}"
git push origin "v${{ env.VERSION }}"
- name: Generate release notes
run: |
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
NOTES=$(git log "${LAST_TAG}..HEAD" --pretty=format:"- %s" --no-merges)
else
NOTES=$(git log --pretty=format:"- %s" --no-merges)
fi
NOTES_JSON=$(printf '%s' "$NOTES" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
echo "NOTES=${NOTES_JSON}" >> $GITEA_ENV
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}},value=v${{ env.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=v${{ env.VERSION }}
type=sha,prefix=,format=short
type=raw,value=latest
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
- name: Create Gitea release
run: |
curl -sf -X POST \
-H "Authorization: token ${{ secrets.TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{
\"tag_name\": \"v${{ env.VERSION }}\",
\"name\": \"Catalyst v${{ env.VERSION }}\",
\"body\": \"### Changes\n\n${{ env.NOTES }}\n\n### Image\n\n\`${{ env.IMAGE }}:${{ env.VERSION }}\`\",
\"draft\": false,
\"prerelease\": false
}"

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Catalyst</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -1,6 +1,6 @@
{
"name": "catalyst",
"version": "1.1.1",
"version": "1.1.2",
"type": "module",
"scripts": {
"start": "node server/server.js",

View File

@@ -133,5 +133,18 @@ export function _resetForTest() {
}
// ── Boot ──────────────────────────────────────────────────────────────────────
// Skipped in test environment — parallel Vitest workers would race to open
// the same file, causing "database is locked". _resetForTest() in beforeEach
// handles initialisation for every test worker using :memory: instead.
init(process.env.DB_PATH ?? DEFAULT_PATH);
if (process.env.NODE_ENV !== 'test') {
const DB_PATH = process.env.DB_PATH ?? DEFAULT_PATH;
try {
init(DB_PATH);
} catch (e) {
console.error('[catalyst] fatal: could not open database at', DB_PATH);
console.error('[catalyst] ensure the data directory exists and is writable by the server process.');
console.error(e);
process.exit(1);
}
}

View File

@@ -11,10 +11,18 @@ export const app = express();
app.use(helmet({
contentSecurityPolicy: {
useDefaults: false, // explicit — upgrade-insecure-requests breaks HTTP deployments
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
'style-src': ["'self'", 'https://fonts.googleapis.com'],
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https://fonts.gstatic.com'],
'form-action': ["'self'"],
'frame-ancestors': ["'self'"],
'img-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'script-src': ["'self'"],
'script-src-attr': ["'unsafe-inline'"], // allow onclick handlers
'style-src': ["'self'", 'https://fonts.googleapis.com'],
},
},
}));

View File

@@ -237,3 +237,43 @@ describe('DELETE /api/instances/:vmid', () => {
expect(res.status).toBe(400)
})
})
// ── Static assets & SPA routing ───────────────────────────────────────────────
describe('static assets and SPA routing', () => {
it('serves index.html at root', async () => {
const res = await request(app).get('/')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/html/)
})
it('serves index.html for deep SPA routes (e.g. /instance/117)', async () => {
const res = await request(app).get('/instance/117')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/html/)
})
it('serves CSS with correct content-type (not sniffed as HTML)', async () => {
const res = await request(app).get('/css/app.css')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/css/)
})
it('does not set upgrade-insecure-requests in CSP (HTTP deployments must work)', async () => {
const res = await request(app).get('/')
const csp = res.headers['content-security-policy'] ?? ''
expect(csp).not.toContain('upgrade-insecure-requests')
})
it('allows inline event handlers in CSP (onclick attributes)', async () => {
const res = await request(app).get('/')
const csp = res.headers['content-security-policy'] ?? ''
// script-src-attr must not be 'none' — that blocks onclick handlers
expect(csp).not.toContain("script-src-attr 'none'")
})
it('index.html contains base href / for correct asset resolution on deep routes', async () => {
const res = await request(app).get('/')
expect(res.text).toContain('<base href="/">')
})
})

View File

@@ -165,3 +165,23 @@ describe('deleteInstance', () => {
expect(getInstance(2)).not.toBeNull();
});
});
// ── Test environment boot isolation ───────────────────────────────────────────
describe('test environment boot isolation', () => {
it('vitest runs with NODE_ENV=test', () => {
// Vitest sets NODE_ENV=test automatically. This is the guard condition
// that prevents the boot init() from opening the real database file.
expect(process.env.NODE_ENV).toBe('test');
});
it('db module loads cleanly in parallel workers without locking the real db file', () => {
// Regression: the module-level init(DEFAULT_PATH) used to run unconditionally,
// causing "database is locked" when multiple test workers imported db.js at
// the same time. process.exit(1) then killed the worker mid-suite.
// Fix: boot init is skipped when NODE_ENV=test. _resetForTest() handles setup.
// Reaching this line proves the module loaded without calling process.exit.
expect(() => _resetForTest()).not.toThrow();
expect(getInstances()).toEqual([]);
});
});