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
|
# Directory for database and uploads
|
||||||
# Docker: /app/data (must match volume mount)
|
# Docker: /app/data (must match volume mount)
|
||||||
# Local development: ./data
|
# Local development: ./data
|
||||||
TASKPLANER_DATA_DIR=/app/data
|
DATA_DIR=/app/data
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Production URL (REQUIRED for production)
|
# Production URL (REQUIRED for production)
|
||||||
@@ -24,8 +24,9 @@ TASKPLANER_DATA_DIR=/app/data
|
|||||||
|
|
||||||
# The full URL where users access the app
|
# The full URL where users access the app
|
||||||
# Used for CSRF validation and generating absolute URLs
|
# Used for CSRF validation and generating absolute URLs
|
||||||
|
# Must use TASKPLANER_ prefix due to adapter-node envPrefix config
|
||||||
# Example: https://tasks.example.com
|
# Example: https://tasks.example.com
|
||||||
ORIGIN=http://localhost:3000
|
TASKPLANER_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Request Limits
|
# Request Limits
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -29,3 +29,10 @@ data/*.db-shm
|
|||||||
data/attachments/*
|
data/attachments/*
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
!data/attachments/.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
|
# Set environment variables
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV TASKPLANER_DATA_DIR=/app/data
|
ENV DATA_DIR=/app/data
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ services:
|
|||||||
- taskplaner_data:/app/data
|
- taskplaner_data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- TASKPLANER_DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
|
- TASKPLANER_ORIGIN=http://localhost:3000
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
@@ -1625,6 +1626,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -4789,6 +4806,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@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 { existsSync, mkdirSync } from 'fs';
|
||||||
import { dirname, join } from 'path';
|
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');
|
const DB_PATH = join(DATA_DIR, 'taskplaner.db');
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
@@ -18,6 +18,49 @@ const sqlite = new Database(DB_PATH);
|
|||||||
// Enable WAL mode for better concurrent read performance
|
// Enable WAL mode for better concurrent read performance
|
||||||
sqlite.pragma('journal_mode = WAL');
|
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 const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
export { schema };
|
export { schema };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mkdir, writeFile, unlink } from 'node:fs/promises';
|
import { mkdir, writeFile, unlink } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
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 UPLOAD_DIR = join(DATA_DIR, 'uploads');
|
||||||
export const ORIGINALS_DIR = join(DATA_DIR, 'uploads/originals');
|
export const ORIGINALS_DIR = join(DATA_DIR, 'uploads/originals');
|
||||||
export const THUMBNAILS_DIR = join(DATA_DIR, 'uploads/thumbnails');
|
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