diff --git a/.env.example b/.env.example index 7781be6..3ac1b2e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 7a12b10..be63dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,10 @@ data/*.db-shm data/attachments/* !data/.gitkeep !data/attachments/.gitkeep + +# Backups +backups/ + +# Playwright +playwright-report/ +test-results/ diff --git a/.planning/phases/06-deployment/06-UAT.md b/.planning/phases/06-deployment/06-UAT.md new file mode 100644 index 0000000..8be11a2 --- /dev/null +++ b/.planning/phases/06-deployment/06-UAT.md @@ -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] diff --git a/Dockerfile b/Dockerfile index 4ad0d87..51a61ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index f11f91b..be945a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package-lock.json b/package-lock.json index 4ef1fb4..01cb826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7aa0a65..6d6f663 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..561e7fd --- /dev/null +++ b/playwright.config.ts @@ -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' } + } + ] +}); diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index d30a721..60b6c16 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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 }; diff --git a/src/lib/server/images/storage.ts b/src/lib/server/images/storage.ts index 2c8ac81..c4b453e 100644 --- a/src/lib/server/images/storage.ts +++ b/src/lib/server/images/storage.ts @@ -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'); diff --git a/tests/docker-deployment.spec.ts b/tests/docker-deployment.spec.ts new file mode 100644 index 0000000..cb25c9b --- /dev/null +++ b/tests/docker-deployment.spec.ts @@ -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 }); + }); +});