fix(deploy): resolve Docker startup and CSRF issues
- Rename TASKPLANER_DATA_DIR to DATA_DIR (avoid adapter-node envPrefix conflict) - Add TASKPLANER_ORIGIN for CSRF protection in docker-compose.yml - Add automatic database schema initialization on startup - Add Playwright E2E tests for Docker deployment verification - Update .env.example with correct variable names Fixes container restart loop and 403 errors on form submission. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ PORT=3000
|
||||
# Directory for database and uploads
|
||||
# Docker: /app/data (must match volume mount)
|
||||
# Local development: ./data
|
||||
TASKPLANER_DATA_DIR=/app/data
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# ============================================
|
||||
# Production URL (REQUIRED for production)
|
||||
@@ -24,8 +24,9 @@ TASKPLANER_DATA_DIR=/app/data
|
||||
|
||||
# The full URL where users access the app
|
||||
# Used for CSRF validation and generating absolute URLs
|
||||
# Must use TASKPLANER_ prefix due to adapter-node envPrefix config
|
||||
# Example: https://tasks.example.com
|
||||
ORIGIN=http://localhost:3000
|
||||
TASKPLANER_ORIGIN=http://localhost:3000
|
||||
|
||||
# ============================================
|
||||
# Request Limits
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -29,3 +29,10 @@ data/*.db-shm
|
||||
data/attachments/*
|
||||
!data/.gitkeep
|
||||
!data/attachments/.gitkeep
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
49
.planning/phases/06-deployment/06-UAT.md
Normal file
49
.planning/phases/06-deployment/06-UAT.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 06-deployment
|
||||
source: 06-01-SUMMARY.md, 06-02-SUMMARY.md
|
||||
started: 2026-02-01T12:30:00Z
|
||||
updated: 2026-02-01T12:30:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Docker Build Succeeds
|
||||
expected: Run `docker compose build` — build completes without errors
|
||||
result: pass
|
||||
|
||||
### 2. Container Starts and Runs
|
||||
expected: Run `docker compose up -d` — container starts, `docker compose ps` shows healthy status
|
||||
result: pass
|
||||
|
||||
### 3. Application Accessible
|
||||
expected: Open http://localhost:3000 in browser — TaskPlanner UI loads, shows entry list
|
||||
result: pass
|
||||
|
||||
### 4. Health Endpoint Returns 200
|
||||
expected: Run `curl http://localhost:3000/health` — returns 200 OK with JSON status
|
||||
result: pass
|
||||
|
||||
### 5. Data Persists Across Restart
|
||||
expected: Create a test entry, run `docker compose restart`, refresh browser — entry still exists
|
||||
result: pass
|
||||
|
||||
### 6. Backup Script Creates Archive
|
||||
expected: Run `./backup.sh` — creates timestamped .tar.gz file in backups/ directory
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 6
|
||||
passed: 6
|
||||
issues: 0
|
||||
pending: 0
|
||||
skipped: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
[none yet]
|
||||
@@ -40,7 +40,7 @@ USER nodejs
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV TASKPLANER_DATA_DIR=/app/data
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV PORT=3000
|
||||
|
||||
# Expose port
|
||||
|
||||
@@ -8,7 +8,8 @@ services:
|
||||
- taskplaner_data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TASKPLANER_DATA_DIR=/app/data
|
||||
- DATA_DIR=/app/data
|
||||
- TASKPLANER_ORIGIN=http://localhost:3000
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
@@ -1625,6 +1626,22 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
|
||||
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -4789,6 +4806,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
|
||||
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
|
||||
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:docker": "BASE_URL=http://localhost:3000 playwright test tests/docker-deployment.spec.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
|
||||
20
playwright.config.ts
Normal file
20
playwright.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' }
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import * as schema from './schema';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const DATA_DIR = process.env.TASKPLANER_DATA_DIR || './data';
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const DB_PATH = join(DATA_DIR, 'taskplaner.db');
|
||||
|
||||
// Ensure data directory exists
|
||||
@@ -18,6 +18,49 @@ const sqlite = new Database(DB_PATH);
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
|
||||
// Initialize schema if tables don't exist
|
||||
const tableExists = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries'")
|
||||
.get();
|
||||
|
||||
if (!tableExists) {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'thought' CHECK(type IN ('task', 'thought')),
|
||||
status TEXT DEFAULT 'open' CHECK(status IN ('open', 'done', 'archived')),
|
||||
pinned INTEGER DEFAULT 0,
|
||||
due_date TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE images (
|
||||
id TEXT PRIMARY KEY,
|
||||
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
ext TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX tagNameUniqueIndex ON tags(lower(name));
|
||||
|
||||
CREATE TABLE entry_tags (
|
||||
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (entry_id, tag_id)
|
||||
);
|
||||
`);
|
||||
console.log('Database schema initialized');
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
export { schema };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, writeFile, unlink } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DATA_DIR = process.env.TASKPLANER_DATA_DIR || './data';
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
export const UPLOAD_DIR = join(DATA_DIR, 'uploads');
|
||||
export const ORIGINALS_DIR = join(DATA_DIR, 'uploads/originals');
|
||||
export const THUMBNAILS_DIR = join(DATA_DIR, 'uploads/thumbnails');
|
||||
|
||||
109
tests/docker-deployment.spec.ts
Normal file
109
tests/docker-deployment.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Docker deployment tests
|
||||
*
|
||||
* These tests verify the application works correctly when deployed in Docker.
|
||||
* They specifically test CSRF protection which requires TASKPLANER_ORIGIN to be set.
|
||||
*
|
||||
* Run against Docker: BASE_URL=http://localhost:3000 npx playwright test
|
||||
*/
|
||||
|
||||
test.describe('Docker Deployment', () => {
|
||||
test('application loads', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/TaskPlaner/i);
|
||||
});
|
||||
|
||||
test('health endpoint returns ok', async ({ request }) => {
|
||||
const response = await request.get('/health');
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.text()).toBe('ok');
|
||||
});
|
||||
|
||||
test('can create entry via form submission', async ({ page }) => {
|
||||
// This test verifies CSRF protection is properly configured
|
||||
// If TASKPLANER_ORIGIN is not set, form submissions return 403
|
||||
await page.goto('/');
|
||||
|
||||
// Fill in quick capture form (textarea for content)
|
||||
const contentInput = page.locator('textarea[name="content"]');
|
||||
await contentInput.fill('Test entry from Playwright');
|
||||
|
||||
// Submit the form - button text is "Add"
|
||||
const addButton = page.locator('button[type="submit"]:has-text("Add")');
|
||||
await addButton.click();
|
||||
|
||||
// Wait for the entry to appear in the list
|
||||
await expect(page.locator('text=Test entry from Playwright')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can toggle entry completion', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Create a task entry first
|
||||
const contentInput = page.locator('textarea[name="content"]');
|
||||
await contentInput.fill('Test task for completion');
|
||||
|
||||
// Select task type
|
||||
const typeSelect = page.locator('select[name="type"]');
|
||||
await typeSelect.selectOption('task');
|
||||
|
||||
const addButton = page.locator('button[type="submit"]:has-text("Add")');
|
||||
await addButton.click();
|
||||
|
||||
// Wait for entry to appear
|
||||
await expect(page.locator('text=Test task for completion')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Find and click the checkbox to mark complete
|
||||
const checkbox = page.locator('input[type="checkbox"]').first();
|
||||
await checkbox.click();
|
||||
|
||||
// Verify the action completed (no 403 error)
|
||||
// The checkbox should now be checked
|
||||
await expect(checkbox).toBeChecked({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('data persists across container restart', async ({ page }) => {
|
||||
// This test verifies Docker volume persistence
|
||||
const uniqueContent = `Persistence test ${Date.now()}`;
|
||||
|
||||
// Create an entry
|
||||
await page.goto('/');
|
||||
const contentInput = page.locator('textarea[name="content"]');
|
||||
await contentInput.fill(uniqueContent);
|
||||
|
||||
const addButton = page.locator('button[type="submit"]:has-text("Add")');
|
||||
await addButton.click();
|
||||
|
||||
// Wait for entry to appear
|
||||
await expect(page.locator(`text=${uniqueContent}`)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Restart the container
|
||||
await execAsync('docker compose restart');
|
||||
|
||||
// Wait for container to be healthy again
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/health');
|
||||
if (response.ok) {
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Container not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
expect(healthy).toBe(true);
|
||||
|
||||
// Verify entry still exists after restart
|
||||
await page.goto('/');
|
||||
await expect(page.locator(`text=${uniqueContent}`)).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user