67
.github/workflows/close-untitled-issues.yml
vendored
Normal file
67
.github/workflows/close-untitled-issues.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Close untitled issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close if title is empty or generic
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
const badTitles = [
|
||||
"[BUG]",
|
||||
"bug report",
|
||||
"bug",
|
||||
"issue",
|
||||
];
|
||||
|
||||
const featureRequestTitles = [
|
||||
"feature request",
|
||||
"[feature]",
|
||||
"[feature request]",
|
||||
"[enhancement]"
|
||||
]
|
||||
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
if (badTitles.includes(titleLower)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
}
|
||||
44
.github/workflows/test.yml
vendored
Normal file
44
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd server && npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,3 +56,5 @@ coverage
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
@@ -160,6 +160,7 @@ services:
|
||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -301,6 +302,7 @@ trek.yourdomain.com {
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
||||
|
||||
## Optional API Keys
|
||||
|
||||
|
||||
@@ -7,18 +7,57 @@ metadata:
|
||||
data:
|
||||
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||
PORT: {{ .Values.env.PORT | quote }}
|
||||
{{- if .Values.env.TZ }}
|
||||
TZ: {{ .Values.env.TZ | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.LOG_LEVEL }}
|
||||
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.APP_URL }}
|
||||
APP_URL: {{ .Values.env.APP_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||
{{- if .Values.env.FORCE_HTTPS }}
|
||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TRUST_PROXY }}
|
||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ISSUER }}
|
||||
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_CLIENT_ID }}
|
||||
OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_DISPLAY_NAME }}
|
||||
OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ONLY }}
|
||||
OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ADMIN_CLAIM }}
|
||||
OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ADMIN_VALUE }}
|
||||
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_SCOPE }}
|
||||
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_DISCOVERY_URL }}
|
||||
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.DEMO_MODE }}
|
||||
DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -54,6 +54,12 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: ADMIN_PASSWORD
|
||||
optional: true
|
||||
- name: OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -14,6 +14,9 @@ data:
|
||||
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -38,4 +41,7 @@ stringData:
|
||||
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -15,20 +15,44 @@ service:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# TZ: "UTC"
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
# FORCE_HTTPS: "false"
|
||||
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
||||
# TRUST_PROXY: "1"
|
||||
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
||||
# OIDC_DISCOVERY_URL: ""
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# OIDC_ISSUER: ""
|
||||
# OpenID Connect provider URL.
|
||||
# OIDC_CLIENT_ID: ""
|
||||
# OIDC client ID.
|
||||
# OIDC_DISPLAY_NAME: "SSO"
|
||||
# Label shown on the SSO login button.
|
||||
# OIDC_ONLY: "false"
|
||||
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
|
||||
# OIDC_ADMIN_CLAIM: ""
|
||||
# OIDC claim used to identify admin users.
|
||||
# OIDC_ADMIN_VALUE: ""
|
||||
# Value of the OIDC claim that grants admin role.
|
||||
# OIDC_SCOPE: "openid email profile groups"
|
||||
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
|
||||
# OIDC_DISCOVERY_URL: ""
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# DEMO_MODE: "false"
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "60"
|
||||
# Max MCP API requests per user per minute. Defaults to 60.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -46,6 +70,8 @@ secretEnv:
|
||||
# If either is empty a random password is generated and printed to the server log.
|
||||
ADMIN_EMAIL: ""
|
||||
ADMIN_PASSWORD: ""
|
||||
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
@@ -83,6 +83,7 @@ export const tripsApi = {
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function GitHubPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
@@ -156,6 +156,24 @@ export default function GitHubPanel() {
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
@@ -96,22 +97,33 @@ interface FilePreviewPortalProps {
|
||||
}
|
||||
|
||||
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const [authUrl, setAuthUrl] = useState('')
|
||||
const rawUrl = file?.url || ''
|
||||
useEffect(() => {
|
||||
if (!rawUrl) return
|
||||
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||
}, [rawUrl])
|
||||
|
||||
if (!file) return null
|
||||
const url = file.url || `/uploads/${file.filename}`
|
||||
const isImage = file.mime_type?.startsWith('image/')
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
const isTxt = file.mime_type?.startsWith('text/')
|
||||
|
||||
const openInNewTab = async () => {
|
||||
const u = await getAuthUrl(rawUrl, 'download')
|
||||
window.open(u, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
<img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,19 +134,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -144,6 +156,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
}
|
||||
|
||||
const NOTE_COLORS = [
|
||||
{ value: '#6366f1', label: 'Indigo' },
|
||||
{ value: '#ef4444', label: 'Red' },
|
||||
@@ -460,7 +480,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
@@ -484,10 +504,10 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label htmlFor="note-file-input"
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -845,7 +865,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<img key={a.id} src={a.url} alt={a.original_name}
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => onPreviewFile?.(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
@@ -974,7 +994,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
|
||||
}
|
||||
// Reload note with attachments
|
||||
const fresh = await collabApi.getNotes(tripId)
|
||||
|
||||
@@ -232,10 +232,19 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
{appVersion && (
|
||||
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -879,7 +879,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
return (
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)', contentVisibility: 'auto', containIntrinsicSize: '0 64px' }}>
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||
@@ -896,6 +896,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
outlineOffset: -2,
|
||||
borderRadius: isDragTarget ? 8 : 0,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||
@@ -1553,8 +1554,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
value={ui.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('dayplan.noteTitle')}
|
||||
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
placeholder={t('dayplan.noteTitle') + ' *'}
|
||||
required
|
||||
style={{ fontSize: 13, fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<textarea
|
||||
value={ui.time}
|
||||
@@ -1568,7 +1570,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div style={{ textAlign: 'right', fontSize: 11, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} style={{ fontSize: 12, background: !ui.text?.trim() ? 'var(--border-primary)' : 'var(--accent)', color: !ui.text?.trim() ? 'var(--text-faint)' : 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||
{ui.mode === 'add' ? t('common.add') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import nl from './translations/nl'
|
||||
import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
import pl from './translations/pl'
|
||||
|
||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||
|
||||
@@ -24,14 +25,15 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'br', label: 'Português (Brasil)' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
] as const
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs, pl }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' }
|
||||
const RTL_LANGUAGES = new Set(['ar'])
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
@@ -40,7 +42,7 @@ export function getLocaleForLanguage(language: string): string {
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
export function isRtlLanguage(language: string): boolean {
|
||||
|
||||
@@ -87,6 +87,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.places': 'الأماكن',
|
||||
'dashboard.members': 'ال חברים',
|
||||
'dashboard.archive': 'أرشفة',
|
||||
'dashboard.copyTrip': 'نسخ',
|
||||
'dashboard.copySuffix': 'نسخة',
|
||||
'dashboard.restore': 'استعادة',
|
||||
'dashboard.archived': 'مؤرشفة',
|
||||
'dashboard.status.ongoing': 'جارية',
|
||||
@@ -105,6 +107,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'فشل الأرشفة',
|
||||
'dashboard.toast.restored': 'تمت استعادة الرحلة',
|
||||
'dashboard.toast.restoreError': 'فشل الاستعادة',
|
||||
'dashboard.toast.copied': 'تم نسخ الرحلة!',
|
||||
'dashboard.toast.copyError': 'فشل نسخ الرحلة',
|
||||
'dashboard.confirm.delete': 'حذف الرحلة "{title}"؟ سيتم حذف جميع الأماكن والخطط نهائيًا.',
|
||||
'dashboard.editTrip': 'تعديل الرحلة',
|
||||
'dashboard.createTrip': 'إنشاء رحلة جديدة',
|
||||
@@ -243,6 +247,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mcp.toast.deleted': 'تم حذف الرمز',
|
||||
'settings.mcp.toast.deleteError': 'فشل حذف الرمز',
|
||||
'settings.account': 'الحساب',
|
||||
'settings.about': 'حول',
|
||||
'settings.username': 'اسم المستخدم',
|
||||
'settings.email': 'البريد الإلكتروني',
|
||||
'settings.role': 'الدور',
|
||||
@@ -1490,17 +1495,17 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل',
|
||||
'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'تراجع',
|
||||
'undo.tooltip': 'تراجع: {action}',
|
||||
'undo.assignPlace': 'تم تعيين المكان لليوم',
|
||||
'undo.removeAssignment': 'تم إزالة المكان من اليوم',
|
||||
'undo.reorder': 'تمت إعادة ترتيب الأماكن',
|
||||
'undo.optimize': 'تم تحسين المسار',
|
||||
'undo.deletePlace': 'تم حذف المكان',
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
'undo.importGoogleList': 'استيراد خرائط Google',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'الإشعارات',
|
||||
@@ -1515,6 +1520,29 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.markUnread': 'تحديد كغير مقروء',
|
||||
'notifications.delete': 'حذف',
|
||||
'notifications.system': 'النظام',
|
||||
'memories.error.loadAlbums': 'فشل تحميل الألبومات',
|
||||
'memories.error.linkAlbum': 'فشل ربط الألبوم',
|
||||
'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم',
|
||||
'memories.error.syncAlbum': 'فشل مزامنة الألبوم',
|
||||
'memories.error.loadPhotos': 'فشل تحميل الصور',
|
||||
'memories.error.addPhotos': 'فشل إضافة الصور',
|
||||
'memories.error.removePhoto': 'فشل حذف الصورة',
|
||||
'memories.error.toggleSharing': 'فشل تحديث إعدادات المشاركة',
|
||||
'undo.addPlace': 'تمت إضافة المكان',
|
||||
'undo.done': 'تم التراجع: {action}',
|
||||
'notifications.test.title': 'إشعار تجريبي من {actor}',
|
||||
'notifications.test.text': 'هذا إشعار تجريبي بسيط.',
|
||||
'notifications.test.booleanTitle': 'يطلب منك {actor} الموافقة',
|
||||
'notifications.test.booleanText': 'إشعار تجريبي يتطلب إجابة.',
|
||||
'notifications.test.accept': 'موافقة',
|
||||
'notifications.test.decline': 'رفض',
|
||||
'notifications.test.navigateTitle': 'تحقق من شيء ما',
|
||||
'notifications.test.navigateText': 'إشعار تجريبي للتنقل.',
|
||||
'notifications.test.goThere': 'اذهب إلى هناك',
|
||||
'notifications.test.adminTitle': 'إذاعة المسؤول',
|
||||
'notifications.test.adminText': 'أرسل {actor} إشعاراً تجريبياً لجميع المسؤولين.',
|
||||
'notifications.test.tripTitle': 'نشر {actor} في رحلتك',
|
||||
'notifications.test.tripText': 'إشعار تجريبي للرحلة "{trip}".',
|
||||
}
|
||||
|
||||
export default ar
|
||||
@@ -82,6 +82,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.places': 'Lugares',
|
||||
'dashboard.members': 'Parceiros de viagem',
|
||||
'dashboard.archive': 'Arquivar',
|
||||
'dashboard.copyTrip': 'Copiar',
|
||||
'dashboard.copySuffix': 'cópia',
|
||||
'dashboard.restore': 'Restaurar',
|
||||
'dashboard.archived': 'Arquivada',
|
||||
'dashboard.status.ongoing': 'Em andamento',
|
||||
@@ -100,6 +102,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Não foi possível arquivar',
|
||||
'dashboard.toast.restored': 'Viagem restaurada',
|
||||
'dashboard.toast.restoreError': 'Não foi possível restaurar',
|
||||
'dashboard.toast.copied': 'Viagem copiada!',
|
||||
'dashboard.toast.copyError': 'Não foi possível copiar a viagem',
|
||||
'dashboard.confirm.delete': 'Excluir a viagem "{title}"? Todos os lugares e planos serão excluídos permanentemente.',
|
||||
'dashboard.editTrip': 'Editar viagem',
|
||||
'dashboard.createTrip': 'Criar nova viagem',
|
||||
@@ -213,6 +217,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.on': 'Ligado',
|
||||
'settings.off': 'Desligado',
|
||||
'settings.account': 'Conta',
|
||||
'settings.about': 'Sobre',
|
||||
'settings.username': 'Nome de usuário',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Função',
|
||||
@@ -1485,17 +1490,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens',
|
||||
'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Desfazer',
|
||||
'undo.tooltip': 'Desfazer: {action}',
|
||||
'undo.assignPlace': 'Local atribuído ao dia',
|
||||
'undo.removeAssignment': 'Local removido do dia',
|
||||
'undo.reorder': 'Locais reordenados',
|
||||
'undo.optimize': 'Rota otimizada',
|
||||
'undo.deletePlace': 'Local excluído',
|
||||
'undo.moveDay': 'Local movido para outro dia',
|
||||
'undo.lock': 'Bloqueio do local alternado',
|
||||
'undo.importGpx': 'Importação de GPX',
|
||||
'undo.importGoogleList': 'Importação do Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Notificações',
|
||||
@@ -1510,6 +1515,29 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.markUnread': 'Marcar como não lido',
|
||||
'notifications.delete': 'Excluir',
|
||||
'notifications.system': 'Sistema',
|
||||
'memories.error.loadAlbums': 'Falha ao carregar álbuns',
|
||||
'memories.error.linkAlbum': 'Falha ao vincular álbum',
|
||||
'memories.error.unlinkAlbum': 'Falha ao desvincular álbum',
|
||||
'memories.error.syncAlbum': 'Falha ao sincronizar álbum',
|
||||
'memories.error.loadPhotos': 'Falha ao carregar fotos',
|
||||
'memories.error.addPhotos': 'Falha ao adicionar fotos',
|
||||
'memories.error.removePhoto': 'Falha ao remover foto',
|
||||
'memories.error.toggleSharing': 'Falha ao atualizar compartilhamento',
|
||||
'undo.addPlace': 'Local adicionado',
|
||||
'undo.done': 'Desfeito: {action}',
|
||||
'notifications.test.title': 'Notificação de teste de {actor}',
|
||||
'notifications.test.text': 'Esta é uma notificação de teste simples.',
|
||||
'notifications.test.booleanTitle': '{actor} solicita sua aprovação',
|
||||
'notifications.test.booleanText': 'Notificação de teste booleana.',
|
||||
'notifications.test.accept': 'Aprovar',
|
||||
'notifications.test.decline': 'Recusar',
|
||||
'notifications.test.navigateTitle': 'Confira algo',
|
||||
'notifications.test.navigateText': 'Notificação de teste de navegação.',
|
||||
'notifications.test.goThere': 'Ir lá',
|
||||
'notifications.test.adminTitle': 'Transmissão do admin',
|
||||
'notifications.test.adminText': '{actor} enviou uma notificação de teste para todos os admins.',
|
||||
'notifications.test.tripTitle': '{actor} postou na sua viagem',
|
||||
'notifications.test.tripText': 'Notificação de teste para a viagem "{trip}".',
|
||||
}
|
||||
|
||||
export default br
|
||||
@@ -83,6 +83,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.places': 'Míst',
|
||||
'dashboard.members': 'Cestovní parťáci',
|
||||
'dashboard.archive': 'Archivovat',
|
||||
'dashboard.copyTrip': 'Kopírovat',
|
||||
'dashboard.copySuffix': 'kopie',
|
||||
'dashboard.restore': 'Obnovit',
|
||||
'dashboard.archived': 'Archivováno',
|
||||
'dashboard.status.ongoing': 'Probíhající',
|
||||
@@ -101,7 +103,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Nepodařilo se archivovat cestu',
|
||||
'dashboard.toast.restored': 'Cesta byla obnovena',
|
||||
'dashboard.toast.restoreError': 'Nepodařilo se obnovit cestu',
|
||||
'dashboard.confirm.delete': 'Smazat cestu „{title}“? Všechna místa a plány budou trvale smazány.',
|
||||
'dashboard.toast.copied': 'Cesta byla zkopírována!',
|
||||
'dashboard.toast.copyError': 'Nepodařilo se zkopírovat cestu',
|
||||
'dashboard.confirm.delete': 'Smazat cestu „{title}”? Všechna místa a plány budou trvale smazány.',
|
||||
'dashboard.editTrip': 'Upravit cestu',
|
||||
'dashboard.createTrip': 'Vytvořit novou cestu',
|
||||
'dashboard.tripTitle': 'Název',
|
||||
@@ -191,6 +195,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mcp.toast.deleted': 'Token smazán',
|
||||
'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
|
||||
'settings.account': 'Účet',
|
||||
'settings.about': 'O aplikaci',
|
||||
'settings.username': 'Uživatelské jméno',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Role',
|
||||
@@ -1488,17 +1493,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy',
|
||||
'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Zpět',
|
||||
'undo.tooltip': 'Zpět: {action}',
|
||||
'undo.assignPlace': 'Místo přiřazeno ke dni',
|
||||
'undo.removeAssignment': 'Místo odebráno ze dne',
|
||||
'undo.reorder': 'Místa přeseřazena',
|
||||
'undo.optimize': 'Trasa optimalizována',
|
||||
'undo.deletePlace': 'Místo smazáno',
|
||||
'undo.moveDay': 'Místo přesunuto na jiný den',
|
||||
'undo.lock': 'Zámek místa přepnut',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
'undo.importGoogleList': 'Import z Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Oznámení',
|
||||
@@ -1513,6 +1518,31 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.markUnread': 'Označit jako nepřečtené',
|
||||
'notifications.delete': 'Smazat',
|
||||
'notifications.system': 'Systém',
|
||||
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
|
||||
'atlas.searchCountry': 'Hledat zemi...',
|
||||
'memories.error.loadAlbums': 'Načtení alb se nezdařilo',
|
||||
'memories.error.linkAlbum': 'Propojení alba se nezdařilo',
|
||||
'memories.error.unlinkAlbum': 'Odpojení alba se nezdařilo',
|
||||
'memories.error.syncAlbum': 'Synchronizace alba se nezdařila',
|
||||
'memories.error.loadPhotos': 'Načtení fotek se nezdařilo',
|
||||
'memories.error.addPhotos': 'Přidání fotek se nezdařilo',
|
||||
'memories.error.removePhoto': 'Odebrání fotky se nezdařilo',
|
||||
'memories.error.toggleSharing': 'Aktualizace sdílení se nezdařila',
|
||||
'undo.addPlace': 'Místo přidáno',
|
||||
'undo.done': 'Vráceno zpět: {action}',
|
||||
'notifications.test.title': 'Testovací oznámení od {actor}',
|
||||
'notifications.test.text': 'Toto je jednoduché testovací oznámení.',
|
||||
'notifications.test.booleanTitle': '{actor} žádá o vaše schválení',
|
||||
'notifications.test.booleanText': 'Testovací oznámení s volbou.',
|
||||
'notifications.test.accept': 'Schválit',
|
||||
'notifications.test.decline': 'Odmítnout',
|
||||
'notifications.test.navigateTitle': 'Podívejte se na toto',
|
||||
'notifications.test.navigateText': 'Testovací navigační oznámení.',
|
||||
'notifications.test.goThere': 'Přejít tam',
|
||||
'notifications.test.adminTitle': 'Hromadná zpráva pro správce',
|
||||
'notifications.test.adminText': '{actor} odeslal testovací oznámení všem správcům.',
|
||||
'notifications.test.tripTitle': '{actor} přispěl do vašeho výletu',
|
||||
'notifications.test.tripText': 'Testovací oznámení pro výlet "{trip}".',
|
||||
}
|
||||
|
||||
export default cs
|
||||
@@ -82,6 +82,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.places': 'Orte',
|
||||
'dashboard.members': 'Reise-Buddies',
|
||||
'dashboard.archive': 'Archivieren',
|
||||
'dashboard.copyTrip': 'Kopieren',
|
||||
'dashboard.copySuffix': 'Kopie',
|
||||
'dashboard.restore': 'Wiederherstellen',
|
||||
'dashboard.archived': 'Archiviert',
|
||||
'dashboard.status.ongoing': 'Laufend',
|
||||
@@ -100,6 +102,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Fehler beim Archivieren',
|
||||
'dashboard.toast.restored': 'Reise wiederhergestellt',
|
||||
'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen',
|
||||
'dashboard.toast.copied': 'Reise kopiert!',
|
||||
'dashboard.toast.copyError': 'Fehler beim Kopieren der Reise',
|
||||
'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.',
|
||||
'dashboard.editTrip': 'Reise bearbeiten',
|
||||
'dashboard.createTrip': 'Neue Reise erstellen',
|
||||
@@ -238,6 +242,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mcp.toast.deleted': 'Token gelöscht',
|
||||
'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
|
||||
'settings.account': 'Konto',
|
||||
'settings.about': 'Über',
|
||||
'settings.username': 'Benutzername',
|
||||
'settings.email': 'E-Mail',
|
||||
'settings.role': 'Rolle',
|
||||
@@ -1487,17 +1492,17 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden',
|
||||
'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Rückgängig',
|
||||
'undo.tooltip': 'Rückgängig: {action}',
|
||||
'undo.assignPlace': 'Ort einem Tag zugewiesen',
|
||||
'undo.removeAssignment': 'Ort von Tag entfernt',
|
||||
'undo.reorder': 'Orte neu sortiert',
|
||||
'undo.optimize': 'Route optimiert',
|
||||
'undo.deletePlace': 'Ort gelöscht',
|
||||
'undo.moveDay': 'Ort zu anderem Tag verschoben',
|
||||
'undo.lock': 'Ortssperre umgeschaltet',
|
||||
'undo.importGpx': 'GPX-Import',
|
||||
'undo.importGoogleList': 'Google Maps-Import',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Benachrichtigungen',
|
||||
@@ -1512,6 +1517,29 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.markUnread': 'Als ungelesen markieren',
|
||||
'notifications.delete': 'Löschen',
|
||||
'notifications.system': 'System',
|
||||
'memories.error.loadAlbums': 'Alben konnten nicht geladen werden',
|
||||
'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden',
|
||||
'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden',
|
||||
'memories.error.syncAlbum': 'Album konnte nicht synchronisiert werden',
|
||||
'memories.error.loadPhotos': 'Fotos konnten nicht geladen werden',
|
||||
'memories.error.addPhotos': 'Fotos konnten nicht hinzugefügt werden',
|
||||
'memories.error.removePhoto': 'Foto konnte nicht entfernt werden',
|
||||
'memories.error.toggleSharing': 'Freigabe konnte nicht aktualisiert werden',
|
||||
'undo.addPlace': 'Ort hinzugefügt',
|
||||
'undo.done': 'Rückgängig gemacht: {action}',
|
||||
'notifications.test.title': 'Testbenachrichtigung von {actor}',
|
||||
'notifications.test.text': 'Dies ist eine einfache Testbenachrichtigung.',
|
||||
'notifications.test.booleanTitle': '{actor} bittet um Ihre Zustimmung',
|
||||
'notifications.test.booleanText': 'Dies ist eine boolesche Testbenachrichtigung.',
|
||||
'notifications.test.accept': 'Genehmigen',
|
||||
'notifications.test.decline': 'Ablehnen',
|
||||
'notifications.test.navigateTitle': 'Etwas ansehen',
|
||||
'notifications.test.navigateText': 'Dies ist eine Navigations-Testbenachrichtigung.',
|
||||
'notifications.test.goThere': 'Dorthin',
|
||||
'notifications.test.adminTitle': 'Admin-Broadcast',
|
||||
'notifications.test.adminText': '{actor} hat eine Testbenachrichtigung an alle Admins gesendet.',
|
||||
'notifications.test.tripTitle': '{actor} hat in Ihrer Reise gepostet',
|
||||
'notifications.test.tripText': 'Testbenachrichtigung für Reise "{trip}".',
|
||||
}
|
||||
|
||||
export default de
|
||||
@@ -82,6 +82,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.places': 'Places',
|
||||
'dashboard.members': 'Buddies',
|
||||
'dashboard.archive': 'Archive',
|
||||
'dashboard.copyTrip': 'Copy',
|
||||
'dashboard.copySuffix': 'copy',
|
||||
'dashboard.restore': 'Restore',
|
||||
'dashboard.archived': 'Archived',
|
||||
'dashboard.status.ongoing': 'Ongoing',
|
||||
@@ -100,6 +102,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Failed to archive trip',
|
||||
'dashboard.toast.restored': 'Trip restored',
|
||||
'dashboard.toast.restoreError': 'Failed to restore trip',
|
||||
'dashboard.toast.copied': 'Trip copied!',
|
||||
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
||||
'dashboard.editTrip': 'Edit Trip',
|
||||
'dashboard.createTrip': 'Create New Trip',
|
||||
@@ -238,6 +242,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mcp.toast.deleted': 'Token deleted',
|
||||
'settings.mcp.toast.deleteError': 'Failed to delete token',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'About',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
'settings.role': 'Role',
|
||||
|
||||
@@ -83,6 +83,8 @@ const es: Record<string, string> = {
|
||||
'dashboard.places': 'Lugares',
|
||||
'dashboard.members': 'Compañeros de viaje',
|
||||
'dashboard.archive': 'Archivar',
|
||||
'dashboard.copyTrip': 'Copiar',
|
||||
'dashboard.copySuffix': 'copia',
|
||||
'dashboard.restore': 'Restaurar',
|
||||
'dashboard.archived': 'Archivado',
|
||||
'dashboard.status.ongoing': 'En curso',
|
||||
@@ -101,6 +103,8 @@ const es: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': 'No se pudo archivar el viaje',
|
||||
'dashboard.toast.restored': 'Viaje restaurado',
|
||||
'dashboard.toast.restoreError': 'No se pudo restaurar el viaje',
|
||||
'dashboard.toast.copied': '¡Viaje copiado!',
|
||||
'dashboard.toast.copyError': 'No se pudo copiar el viaje',
|
||||
'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.',
|
||||
'dashboard.editTrip': 'Editar viaje',
|
||||
'dashboard.createTrip': 'Crear nuevo viaje',
|
||||
@@ -239,6 +243,7 @@ const es: Record<string, string> = {
|
||||
'settings.mcp.toast.deleted': 'Token eliminado',
|
||||
'settings.mcp.toast.deleteError': 'Error al eliminar el token',
|
||||
'settings.account': 'Cuenta',
|
||||
'settings.about': 'Acerca de',
|
||||
'settings.username': 'Usuario',
|
||||
'settings.email': 'Correo',
|
||||
'settings.role': 'Rol',
|
||||
@@ -1492,17 +1497,17 @@ const es: Record<string, string> = {
|
||||
'perm.actionHint.collab_edit': 'Quién puede crear notas, encuestas y enviar mensajes',
|
||||
'perm.actionHint.share_manage': 'Quién puede crear o eliminar enlaces compartidos públicos',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Deshacer',
|
||||
'undo.tooltip': 'Deshacer: {action}',
|
||||
'undo.assignPlace': 'Lugar asignado al día',
|
||||
'undo.removeAssignment': 'Lugar eliminado del día',
|
||||
'undo.reorder': 'Lugares reordenados',
|
||||
'undo.optimize': 'Ruta optimizada',
|
||||
'undo.deletePlace': 'Lugar eliminado',
|
||||
'undo.moveDay': 'Lugar movido a otro día',
|
||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||
'undo.importGpx': 'Importación GPX',
|
||||
'undo.importGoogleList': 'Importación de Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Notificaciones',
|
||||
@@ -1517,6 +1522,29 @@ const es: Record<string, string> = {
|
||||
'notifications.markUnread': 'Marcar como no leída',
|
||||
'notifications.delete': 'Eliminar',
|
||||
'notifications.system': 'Sistema',
|
||||
'memories.error.loadAlbums': 'Error al cargar los álbumes',
|
||||
'memories.error.linkAlbum': 'Error al vincular el álbum',
|
||||
'memories.error.unlinkAlbum': 'Error al desvincular el álbum',
|
||||
'memories.error.syncAlbum': 'Error al sincronizar el álbum',
|
||||
'memories.error.loadPhotos': 'Error al cargar las fotos',
|
||||
'memories.error.addPhotos': 'Error al agregar las fotos',
|
||||
'memories.error.removePhoto': 'Error al eliminar la foto',
|
||||
'memories.error.toggleSharing': 'Error al actualizar el uso compartido',
|
||||
'undo.addPlace': 'Lugar agregado',
|
||||
'undo.done': 'Deshecho: {action}',
|
||||
'notifications.test.title': 'Notificación de prueba de {actor}',
|
||||
'notifications.test.text': 'Esta es una notificación de prueba simple.',
|
||||
'notifications.test.booleanTitle': '{actor} solicita tu aprobación',
|
||||
'notifications.test.booleanText': 'Notificación de prueba booleana.',
|
||||
'notifications.test.accept': 'Aprobar',
|
||||
'notifications.test.decline': 'Rechazar',
|
||||
'notifications.test.navigateTitle': 'Mira esto',
|
||||
'notifications.test.navigateText': 'Notificación de prueba de navegación.',
|
||||
'notifications.test.goThere': 'Ir allí',
|
||||
'notifications.test.adminTitle': 'Difusión de administrador',
|
||||
'notifications.test.adminText': '{actor} envió una notificación de prueba a todos los administradores.',
|
||||
'notifications.test.tripTitle': '{actor} publicó en tu viaje',
|
||||
'notifications.test.tripText': 'Notificación de prueba para el viaje "{trip}".',
|
||||
}
|
||||
|
||||
export default es
|
||||
@@ -82,6 +82,8 @@ const fr: Record<string, string> = {
|
||||
'dashboard.places': 'Lieux',
|
||||
'dashboard.members': 'Compagnons de voyage',
|
||||
'dashboard.archive': 'Archiver',
|
||||
'dashboard.copyTrip': 'Copier',
|
||||
'dashboard.copySuffix': 'copie',
|
||||
'dashboard.restore': 'Restaurer',
|
||||
'dashboard.archived': 'Archivé',
|
||||
'dashboard.status.ongoing': 'En cours',
|
||||
@@ -100,6 +102,8 @@ const fr: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': "Impossible d'archiver le voyage",
|
||||
'dashboard.toast.restored': 'Voyage restauré',
|
||||
'dashboard.toast.restoreError': 'Impossible de restaurer le voyage',
|
||||
'dashboard.toast.copied': 'Voyage copié !',
|
||||
'dashboard.toast.copyError': 'Impossible de copier le voyage',
|
||||
'dashboard.confirm.delete': 'Supprimer le voyage « {title} » ? Tous les lieux et plans seront définitivement supprimés.',
|
||||
'dashboard.editTrip': 'Modifier le voyage',
|
||||
'dashboard.createTrip': 'Créer un nouveau voyage',
|
||||
@@ -238,6 +242,7 @@ const fr: Record<string, string> = {
|
||||
'settings.mcp.toast.deleted': 'Token supprimé',
|
||||
'settings.mcp.toast.deleteError': 'Impossible de supprimer le token',
|
||||
'settings.account': 'Compte',
|
||||
'settings.about': 'À propos',
|
||||
'settings.username': 'Nom d\'utilisateur',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Rôle',
|
||||
@@ -1486,17 +1491,17 @@ const fr: Record<string, string> = {
|
||||
'perm.actionHint.collab_edit': 'Qui peut créer des notes, des sondages et envoyer des messages',
|
||||
'perm.actionHint.share_manage': 'Qui peut créer ou supprimer des liens de partage publics',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Annuler',
|
||||
'undo.tooltip': 'Annuler : {action}',
|
||||
'undo.assignPlace': 'Lieu ajouté au jour',
|
||||
'undo.removeAssignment': 'Lieu retiré du jour',
|
||||
'undo.reorder': 'Lieux réorganisés',
|
||||
'undo.optimize': 'Itinéraire optimisé',
|
||||
'undo.deletePlace': 'Lieu supprimé',
|
||||
'undo.moveDay': 'Lieu déplacé vers un autre jour',
|
||||
'undo.lock': 'Verrouillage du lieu modifié',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
'undo.importGoogleList': 'Import Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Notifications',
|
||||
@@ -1511,6 +1516,29 @@ const fr: Record<string, string> = {
|
||||
'notifications.markUnread': 'Marquer comme non lu',
|
||||
'notifications.delete': 'Supprimer',
|
||||
'notifications.system': 'Système',
|
||||
'memories.error.loadAlbums': 'Impossible de charger les albums',
|
||||
'memories.error.linkAlbum': 'Impossible de lier l\'album',
|
||||
'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album',
|
||||
'memories.error.syncAlbum': 'Impossible de synchroniser l\'album',
|
||||
'memories.error.loadPhotos': 'Impossible de charger les photos',
|
||||
'memories.error.addPhotos': 'Impossible d\'ajouter les photos',
|
||||
'memories.error.removePhoto': 'Impossible de supprimer la photo',
|
||||
'memories.error.toggleSharing': 'Impossible de mettre à jour le partage',
|
||||
'undo.addPlace': 'Lieu ajouté',
|
||||
'undo.done': 'Annulé : {action}',
|
||||
'notifications.test.title': 'Notification test de {actor}',
|
||||
'notifications.test.text': 'Ceci est une simple notification de test.',
|
||||
'notifications.test.booleanTitle': '{actor} demande votre approbation',
|
||||
'notifications.test.booleanText': 'Notification de test booléenne.',
|
||||
'notifications.test.accept': 'Approuver',
|
||||
'notifications.test.decline': 'Refuser',
|
||||
'notifications.test.navigateTitle': 'Allez voir quelque chose',
|
||||
'notifications.test.navigateText': 'Notification de test de navigation.',
|
||||
'notifications.test.goThere': 'Y aller',
|
||||
'notifications.test.adminTitle': 'Diffusion admin',
|
||||
'notifications.test.adminText': '{actor} a envoyé une notification de test à tous les admins.',
|
||||
'notifications.test.tripTitle': '{actor} a publié dans votre voyage',
|
||||
'notifications.test.tripText': 'Notification de test pour le voyage "{trip}".',
|
||||
}
|
||||
|
||||
export default fr
|
||||
@@ -82,6 +82,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.places': 'hely',
|
||||
'dashboard.members': 'Útitársak',
|
||||
'dashboard.archive': 'Archiválás',
|
||||
'dashboard.copyTrip': 'Másolás',
|
||||
'dashboard.copySuffix': 'másolat',
|
||||
'dashboard.restore': 'Visszaállítás',
|
||||
'dashboard.archived': 'Archivált',
|
||||
'dashboard.status.ongoing': 'Folyamatban',
|
||||
@@ -100,6 +102,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Nem sikerült archiválni',
|
||||
'dashboard.toast.restored': 'Utazás visszaállítva',
|
||||
'dashboard.toast.restoreError': 'Nem sikerült visszaállítani',
|
||||
'dashboard.toast.copied': 'Utazás másolva!',
|
||||
'dashboard.toast.copyError': 'Nem sikerült másolni az utazást',
|
||||
'dashboard.confirm.delete': '"{title}" utazás törlése? Minden hely és terv véglegesen törlődik.',
|
||||
'dashboard.editTrip': 'Utazás szerkesztése',
|
||||
'dashboard.createTrip': 'Új utazás létrehozása',
|
||||
@@ -190,6 +194,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mcp.toast.deleted': 'Token törölve',
|
||||
'settings.mcp.toast.deleteError': 'Nem sikerült törölni a tokent',
|
||||
'settings.account': 'Fiók',
|
||||
'settings.about': 'Névjegy',
|
||||
'settings.username': 'Felhasználónév',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Szerepkör',
|
||||
@@ -1487,17 +1492,17 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'perm.actionHint.collab_edit': 'Ki hozhat létre jegyzeteket, szavazásokat és küldhet üzeneteket',
|
||||
'perm.actionHint.share_manage': 'Ki hozhat létre vagy törölhet nyilvános megosztási linkeket',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Visszavonás',
|
||||
'undo.tooltip': 'Visszavonás: {action}',
|
||||
'undo.assignPlace': 'Hely naphoz rendelve',
|
||||
'undo.removeAssignment': 'Hely eltávolítva a napról',
|
||||
'undo.reorder': 'Helyek átrendezve',
|
||||
'undo.optimize': 'Útvonal optimalizálva',
|
||||
'undo.deletePlace': 'Hely törölve',
|
||||
'undo.moveDay': 'Hely áthelyezve másik napra',
|
||||
'undo.lock': 'Hely zárolása váltva',
|
||||
'undo.importGpx': 'GPX importálás',
|
||||
'undo.importGoogleList': 'Google Maps importálás',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Értesítések',
|
||||
@@ -1512,6 +1517,29 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.markUnread': 'Olvasatlannak jelölés',
|
||||
'notifications.delete': 'Törlés',
|
||||
'notifications.system': 'Rendszer',
|
||||
'memories.error.loadAlbums': 'Az albumok betöltése sikertelen',
|
||||
'memories.error.linkAlbum': 'Az album csatolása sikertelen',
|
||||
'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen',
|
||||
'memories.error.syncAlbum': 'Az album szinkronizálása sikertelen',
|
||||
'memories.error.loadPhotos': 'A fotók betöltése sikertelen',
|
||||
'memories.error.addPhotos': 'A fotók hozzáadása sikertelen',
|
||||
'memories.error.removePhoto': 'A fotó eltávolítása sikertelen',
|
||||
'memories.error.toggleSharing': 'A megosztás frissítése sikertelen',
|
||||
'undo.addPlace': 'Hely hozzáadva',
|
||||
'undo.done': 'Visszavonva: {action}',
|
||||
'notifications.test.title': 'Teszt értesítés {actor} részéről',
|
||||
'notifications.test.text': 'Ez egy egyszerű teszt értesítés.',
|
||||
'notifications.test.booleanTitle': '{actor} jóváhagyásodat kéri',
|
||||
'notifications.test.booleanText': 'Teszt igen/nem értesítés.',
|
||||
'notifications.test.accept': 'Jóváhagyás',
|
||||
'notifications.test.decline': 'Elutasítás',
|
||||
'notifications.test.navigateTitle': 'Nézz meg valamit',
|
||||
'notifications.test.navigateText': 'Teszt navigációs értesítés.',
|
||||
'notifications.test.goThere': 'Odamegyek',
|
||||
'notifications.test.adminTitle': 'Adminisztrátor üzenet',
|
||||
'notifications.test.adminText': '{actor} teszt értesítést küldött az összes adminisztrátornak.',
|
||||
'notifications.test.tripTitle': '{actor} üzenetet küldött az utazásodba',
|
||||
'notifications.test.tripText': 'Teszt értesítés a(z) "{trip}" utazáshoz.',
|
||||
}
|
||||
|
||||
export default hu
|
||||
@@ -82,6 +82,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.places': 'Luoghi',
|
||||
'dashboard.members': 'Compagni di viaggio',
|
||||
'dashboard.archive': 'Archivia',
|
||||
'dashboard.copyTrip': 'Copia',
|
||||
'dashboard.copySuffix': 'copia',
|
||||
'dashboard.restore': 'Ripristina',
|
||||
'dashboard.archived': 'Archiviati',
|
||||
'dashboard.status.ongoing': 'In corso',
|
||||
@@ -100,6 +102,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Impossibile archiviare il viaggio',
|
||||
'dashboard.toast.restored': 'Viaggio ripristinato',
|
||||
'dashboard.toast.restoreError': 'Impossibile ripristinare il viaggio',
|
||||
'dashboard.toast.copied': 'Viaggio copiato!',
|
||||
'dashboard.toast.copyError': 'Impossibile copiare il viaggio',
|
||||
'dashboard.confirm.delete': 'Eliminare il viaggio "{title}"? Tutti i luoghi e i programmi verranno eliminati in modo permanente.',
|
||||
'dashboard.editTrip': 'Modifica Viaggio',
|
||||
'dashboard.createTrip': 'Crea Nuovo Viaggio',
|
||||
@@ -190,6 +194,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mcp.toast.deleted': 'Token eliminato',
|
||||
'settings.mcp.toast.deleteError': 'Impossibile eliminare il token',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Informazioni',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
'settings.role': 'Ruolo',
|
||||
@@ -1514,6 +1519,27 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.markUnread': 'Segna come non letto',
|
||||
'notifications.delete': 'Elimina',
|
||||
'notifications.system': 'Sistema',
|
||||
'memories.error.loadAlbums': 'Caricamento album non riuscito',
|
||||
'memories.error.linkAlbum': 'Collegamento album non riuscito',
|
||||
'memories.error.unlinkAlbum': 'Scollegamento album non riuscito',
|
||||
'memories.error.syncAlbum': 'Sincronizzazione album non riuscita',
|
||||
'memories.error.loadPhotos': 'Caricamento foto non riuscito',
|
||||
'memories.error.addPhotos': 'Aggiunta foto non riuscita',
|
||||
'memories.error.removePhoto': 'Rimozione foto non riuscita',
|
||||
'memories.error.toggleSharing': 'Aggiornamento condivisione non riuscito',
|
||||
'notifications.test.title': 'Notifica di test da {actor}',
|
||||
'notifications.test.text': 'Questa è una semplice notifica di test.',
|
||||
'notifications.test.booleanTitle': '{actor} richiede la tua approvazione',
|
||||
'notifications.test.booleanText': 'Notifica di test con risposta.',
|
||||
'notifications.test.accept': 'Approva',
|
||||
'notifications.test.decline': 'Rifiuta',
|
||||
'notifications.test.navigateTitle': 'Dai un\'occhiata',
|
||||
'notifications.test.navigateText': 'Notifica di test con navigazione.',
|
||||
'notifications.test.goThere': 'Vai',
|
||||
'notifications.test.adminTitle': 'Comunicazione admin',
|
||||
'notifications.test.adminText': '{actor} ha inviato una notifica di test a tutti gli amministratori.',
|
||||
'notifications.test.tripTitle': '{actor} ha pubblicato nel tuo viaggio',
|
||||
'notifications.test.tripText': 'Notifica di test per il viaggio "{trip}".',
|
||||
}
|
||||
|
||||
export default it
|
||||
@@ -82,6 +82,8 @@ const nl: Record<string, string> = {
|
||||
'dashboard.places': 'Plaatsen',
|
||||
'dashboard.members': 'Reisgenoten',
|
||||
'dashboard.archive': 'Archiveren',
|
||||
'dashboard.copyTrip': 'Kopiëren',
|
||||
'dashboard.copySuffix': 'kopie',
|
||||
'dashboard.restore': 'Herstellen',
|
||||
'dashboard.archived': 'Gearchiveerd',
|
||||
'dashboard.status.ongoing': 'Lopend',
|
||||
@@ -100,6 +102,8 @@ const nl: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': 'Reis archiveren mislukt',
|
||||
'dashboard.toast.restored': 'Reis hersteld',
|
||||
'dashboard.toast.restoreError': 'Reis herstellen mislukt',
|
||||
'dashboard.toast.copied': 'Reis gekopieerd!',
|
||||
'dashboard.toast.copyError': 'Reis kopiëren mislukt',
|
||||
'dashboard.confirm.delete': 'Reis "{title}" verwijderen? Alle plaatsen en plannen worden permanent verwijderd.',
|
||||
'dashboard.editTrip': 'Reis bewerken',
|
||||
'dashboard.createTrip': 'Nieuwe reis aanmaken',
|
||||
@@ -238,6 +242,7 @@ const nl: Record<string, string> = {
|
||||
'settings.mcp.toast.deleted': 'Token verwijderd',
|
||||
'settings.mcp.toast.deleteError': 'Token verwijderen mislukt',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Over',
|
||||
'settings.username': 'Gebruikersnaam',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Rol',
|
||||
@@ -1486,17 +1491,17 @@ const nl: Record<string, string> = {
|
||||
'perm.actionHint.collab_edit': 'Wie kan notities, polls aanmaken en berichten versturen',
|
||||
'perm.actionHint.share_manage': 'Wie kan openbare deellinks aanmaken of verwijderen',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Ongedaan maken',
|
||||
'undo.tooltip': 'Ongedaan maken: {action}',
|
||||
'undo.assignPlace': 'Locatie aan dag toegewezen',
|
||||
'undo.removeAssignment': 'Locatie uit dag verwijderd',
|
||||
'undo.reorder': 'Locaties hergeordend',
|
||||
'undo.optimize': 'Route geoptimaliseerd',
|
||||
'undo.deletePlace': 'Locatie verwijderd',
|
||||
'undo.moveDay': 'Locatie naar andere dag verplaatst',
|
||||
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
||||
'undo.importGpx': 'GPX-import',
|
||||
'undo.importGoogleList': 'Google Maps-import',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Meldingen',
|
||||
@@ -1511,6 +1516,29 @@ const nl: Record<string, string> = {
|
||||
'notifications.markUnread': 'Markeren als ongelezen',
|
||||
'notifications.delete': 'Verwijderen',
|
||||
'notifications.system': 'Systeem',
|
||||
'memories.error.loadAlbums': 'Albums laden mislukt',
|
||||
'memories.error.linkAlbum': 'Album koppelen mislukt',
|
||||
'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt',
|
||||
'memories.error.syncAlbum': 'Album synchroniseren mislukt',
|
||||
'memories.error.loadPhotos': 'Foto\'s laden mislukt',
|
||||
'memories.error.addPhotos': 'Foto\'s toevoegen mislukt',
|
||||
'memories.error.removePhoto': 'Foto verwijderen mislukt',
|
||||
'memories.error.toggleSharing': 'Delen bijwerken mislukt',
|
||||
'undo.addPlace': 'Locatie toegevoegd',
|
||||
'undo.done': 'Ongedaan gemaakt: {action}',
|
||||
'notifications.test.title': 'Testmelding van {actor}',
|
||||
'notifications.test.text': 'Dit is een eenvoudige testmelding.',
|
||||
'notifications.test.booleanTitle': '{actor} vraagt om uw goedkeuring',
|
||||
'notifications.test.booleanText': 'Booleaanse testmelding.',
|
||||
'notifications.test.accept': 'Goedkeuren',
|
||||
'notifications.test.decline': 'Afwijzen',
|
||||
'notifications.test.navigateTitle': 'Bekijk iets',
|
||||
'notifications.test.navigateText': 'Navigatie-testmelding.',
|
||||
'notifications.test.goThere': 'Ga erheen',
|
||||
'notifications.test.adminTitle': 'Admin-broadcast',
|
||||
'notifications.test.adminText': '{actor} heeft een testmelding naar alle admins gestuurd.',
|
||||
'notifications.test.tripTitle': '{actor} heeft gepost in uw reis',
|
||||
'notifications.test.tripText': 'Testmelding voor reis "{trip}".',
|
||||
}
|
||||
|
||||
export default nl
|
||||
1537
client/src/i18n/translations/pl.ts
Normal file
1537
client/src/i18n/translations/pl.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,8 @@ const ru: Record<string, string> = {
|
||||
'dashboard.places': 'Места',
|
||||
'dashboard.members': 'Попутчики',
|
||||
'dashboard.archive': 'Архивировать',
|
||||
'dashboard.copyTrip': 'Копировать',
|
||||
'dashboard.copySuffix': 'копия',
|
||||
'dashboard.restore': 'Восстановить',
|
||||
'dashboard.archived': 'В архиве',
|
||||
'dashboard.status.ongoing': 'В процессе',
|
||||
@@ -100,6 +102,8 @@ const ru: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': 'Не удалось архивировать поездку',
|
||||
'dashboard.toast.restored': 'Поездка восстановлена',
|
||||
'dashboard.toast.restoreError': 'Не удалось восстановить поездку',
|
||||
'dashboard.toast.copied': 'Поездка скопирована!',
|
||||
'dashboard.toast.copyError': 'Не удалось скопировать поездку',
|
||||
'dashboard.confirm.delete': 'Удалить поездку «{title}»? Все места и планы будут безвозвратно удалены.',
|
||||
'dashboard.editTrip': 'Редактировать поездку',
|
||||
'dashboard.createTrip': 'Создать новую поездку',
|
||||
@@ -238,6 +242,7 @@ const ru: Record<string, string> = {
|
||||
'settings.mcp.toast.deleted': 'Токен удалён',
|
||||
'settings.mcp.toast.deleteError': 'Не удалось удалить токен',
|
||||
'settings.account': 'Аккаунт',
|
||||
'settings.about': 'О приложении',
|
||||
'settings.username': 'Имя пользователя',
|
||||
'settings.email': 'Эл. почта',
|
||||
'settings.role': 'Роль',
|
||||
@@ -1486,17 +1491,17 @@ const ru: Record<string, string> = {
|
||||
'perm.actionHint.collab_edit': 'Кто может создавать заметки, опросы и отправлять сообщения',
|
||||
'perm.actionHint.share_manage': 'Кто может создавать или удалять публичные ссылки для обмена',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': 'Отменить',
|
||||
'undo.tooltip': 'Отменить: {action}',
|
||||
'undo.assignPlace': 'Место добавлено в день',
|
||||
'undo.removeAssignment': 'Место удалено из дня',
|
||||
'undo.reorder': 'Места переупорядочены',
|
||||
'undo.optimize': 'Маршрут оптимизирован',
|
||||
'undo.deletePlace': 'Место удалено',
|
||||
'undo.moveDay': 'Место перемещено в другой день',
|
||||
'undo.lock': 'Блокировка места изменена',
|
||||
'undo.importGpx': 'Импорт GPX',
|
||||
'undo.importGoogleList': 'Импорт из Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Уведомления',
|
||||
@@ -1511,6 +1516,29 @@ const ru: Record<string, string> = {
|
||||
'notifications.markUnread': 'Отметить как непрочитанное',
|
||||
'notifications.delete': 'Удалить',
|
||||
'notifications.system': 'Система',
|
||||
'memories.error.loadAlbums': 'Не удалось загрузить альбомы',
|
||||
'memories.error.linkAlbum': 'Не удалось привязать альбом',
|
||||
'memories.error.unlinkAlbum': 'Не удалось отвязать альбом',
|
||||
'memories.error.syncAlbum': 'Не удалось синхронизировать альбом',
|
||||
'memories.error.loadPhotos': 'Не удалось загрузить фотографии',
|
||||
'memories.error.addPhotos': 'Не удалось добавить фотографии',
|
||||
'memories.error.removePhoto': 'Не удалось удалить фотографию',
|
||||
'memories.error.toggleSharing': 'Не удалось обновить настройки доступа',
|
||||
'undo.addPlace': 'Место добавлено',
|
||||
'undo.done': 'Отменено: {action}',
|
||||
'notifications.test.title': 'Тестовое уведомление от {actor}',
|
||||
'notifications.test.text': 'Это простое тестовое уведомление.',
|
||||
'notifications.test.booleanTitle': '{actor} запрашивает подтверждение',
|
||||
'notifications.test.booleanText': 'Тестовое уведомление с выбором.',
|
||||
'notifications.test.accept': 'Подтвердить',
|
||||
'notifications.test.decline': 'Отклонить',
|
||||
'notifications.test.navigateTitle': 'Посмотрите на это',
|
||||
'notifications.test.navigateText': 'Тестовое уведомление с переходом.',
|
||||
'notifications.test.goThere': 'Перейти',
|
||||
'notifications.test.adminTitle': 'Рассылка администратора',
|
||||
'notifications.test.adminText': '{actor} отправил тестовое уведомление всем администраторам.',
|
||||
'notifications.test.tripTitle': '{actor} написал в вашей поездке',
|
||||
'notifications.test.tripText': 'Тестовое уведомление для поездки "{trip}".',
|
||||
}
|
||||
|
||||
export default ru
|
||||
@@ -82,6 +82,8 @@ const zh: Record<string, string> = {
|
||||
'dashboard.places': '地点',
|
||||
'dashboard.members': '旅伴',
|
||||
'dashboard.archive': '归档',
|
||||
'dashboard.copyTrip': '复制',
|
||||
'dashboard.copySuffix': '副本',
|
||||
'dashboard.restore': '恢复',
|
||||
'dashboard.archived': '已归档',
|
||||
'dashboard.status.ongoing': '进行中',
|
||||
@@ -100,6 +102,8 @@ const zh: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': '归档旅行失败',
|
||||
'dashboard.toast.restored': '旅行已恢复',
|
||||
'dashboard.toast.restoreError': '恢复旅行失败',
|
||||
'dashboard.toast.copied': '旅行已复制!',
|
||||
'dashboard.toast.copyError': '复制旅行失败',
|
||||
'dashboard.confirm.delete': '删除旅行「{title}」?所有地点和计划将被永久删除。',
|
||||
'dashboard.editTrip': '编辑旅行',
|
||||
'dashboard.createTrip': '创建新旅行',
|
||||
@@ -238,6 +242,7 @@ const zh: Record<string, string> = {
|
||||
'settings.mcp.toast.deleted': '令牌已删除',
|
||||
'settings.mcp.toast.deleteError': '删除令牌失败',
|
||||
'settings.account': '账户',
|
||||
'settings.about': '关于',
|
||||
'settings.username': '用户名',
|
||||
'settings.email': '邮箱',
|
||||
'settings.role': '角色',
|
||||
@@ -1486,17 +1491,17 @@ const zh: Record<string, string> = {
|
||||
'perm.actionHint.collab_edit': '谁可以创建笔记、投票和发送消息',
|
||||
'perm.actionHint.share_manage': '谁可以创建或删除公开分享链接',
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.button': '撤销',
|
||||
'undo.tooltip': '撤销:{action}',
|
||||
'undo.assignPlace': '地点已分配至某天',
|
||||
'undo.removeAssignment': '地点已从某天移除',
|
||||
'undo.reorder': '地点已重新排序',
|
||||
'undo.optimize': '路线已优化',
|
||||
'undo.deletePlace': '地点已删除',
|
||||
'undo.moveDay': '地点已移至另一天',
|
||||
'undo.lock': '地点锁定已切换',
|
||||
'undo.importGpx': 'GPX 导入',
|
||||
'undo.importGoogleList': 'Google 地图导入',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': '通知',
|
||||
@@ -1511,6 +1516,29 @@ const zh: Record<string, string> = {
|
||||
'notifications.markUnread': '标为未读',
|
||||
'notifications.delete': '删除',
|
||||
'notifications.system': '系统',
|
||||
'memories.error.loadAlbums': '加载相册失败',
|
||||
'memories.error.linkAlbum': '关联相册失败',
|
||||
'memories.error.unlinkAlbum': '取消关联相册失败',
|
||||
'memories.error.syncAlbum': '同步相册失败',
|
||||
'memories.error.loadPhotos': '加载照片失败',
|
||||
'memories.error.addPhotos': '添加照片失败',
|
||||
'memories.error.removePhoto': '删除照片失败',
|
||||
'memories.error.toggleSharing': '更新共享设置失败',
|
||||
'undo.addPlace': '地点已添加',
|
||||
'undo.done': '已撤销:{action}',
|
||||
'notifications.test.title': '来自 {actor} 的测试通知',
|
||||
'notifications.test.text': '这是一条简单的测试通知。',
|
||||
'notifications.test.booleanTitle': '{actor} 请求您的审批',
|
||||
'notifications.test.booleanText': '测试布尔通知。',
|
||||
'notifications.test.accept': '批准',
|
||||
'notifications.test.decline': '拒绝',
|
||||
'notifications.test.navigateTitle': '查看详情',
|
||||
'notifications.test.navigateText': '测试跳转通知。',
|
||||
'notifications.test.goThere': '前往',
|
||||
'notifications.test.adminTitle': '管理员广播',
|
||||
'notifications.test.adminText': '{actor} 向所有管理员发送了测试通知。',
|
||||
'notifications.test.tripTitle': '{actor} 在您的行程中发帖',
|
||||
'notifications.test.tripText': '行程"{trip}"的测试通知。',
|
||||
}
|
||||
|
||||
export default zh
|
||||
@@ -18,7 +18,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
interface AdminUser {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useToast } from '../components/shared/Toast'
|
||||
import {
|
||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
||||
LayoutGrid, List,
|
||||
LayoutGrid, List, Copy,
|
||||
} from 'lucide-react'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
|
||||
@@ -142,6 +142,7 @@ function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidG
|
||||
interface TripCardProps {
|
||||
trip: DashboardTrip
|
||||
onEdit?: (trip: DashboardTrip) => void
|
||||
onCopy?: (trip: DashboardTrip) => void
|
||||
onDelete?: (trip: DashboardTrip) => void
|
||||
onArchive?: (id: number) => void
|
||||
onClick: (trip: DashboardTrip) => void
|
||||
@@ -150,7 +151,7 @@ interface TripCardProps {
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
||||
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
@@ -189,10 +190,11 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
</div>
|
||||
|
||||
{/* Top-right actions */}
|
||||
{(onEdit || onArchive || onDelete) && (
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
|
||||
{onCopy && <IconBtn onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')}><Copy size={14} /></IconBtn>}
|
||||
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
|
||||
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
|
||||
</div>
|
||||
@@ -236,7 +238,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
}
|
||||
|
||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -314,10 +316,11 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
||||
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} />
|
||||
</div>
|
||||
|
||||
{(onEdit || onArchive || onDelete) && (
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
|
||||
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label={t('dashboard.copyTrip')} />}
|
||||
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
|
||||
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
|
||||
</div>
|
||||
@@ -328,7 +331,7 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
||||
}
|
||||
|
||||
// ── List View Item ──────────────────────────────────────────────────────────
|
||||
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -417,9 +420,10 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(onEdit || onArchive || onDelete) && (
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
|
||||
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label="" />}
|
||||
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
|
||||
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />}
|
||||
</div>
|
||||
@@ -432,6 +436,7 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
||||
interface ArchivedRowProps {
|
||||
trip: DashboardTrip
|
||||
onEdit?: (trip: DashboardTrip) => void
|
||||
onCopy?: (trip: DashboardTrip) => void
|
||||
onUnarchive?: (id: number) => void
|
||||
onDelete?: (trip: DashboardTrip) => void
|
||||
onClick: (trip: DashboardTrip) => void
|
||||
@@ -439,7 +444,7 @@ interface ArchivedRowProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
|
||||
function ArchivedRow({ trip, onEdit, onCopy, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
|
||||
return (
|
||||
<div onClick={() => onClick(trip)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||
@@ -465,8 +470,13 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(onEdit || onUnarchive || onDelete) && (
|
||||
{(onEdit || onCopy || onUnarchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{onCopy && <button onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<Copy size={12} />
|
||||
</button>}
|
||||
{onUnarchive && <button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
@@ -657,6 +667,16 @@ export default function DashboardPage(): React.ReactElement {
|
||||
setArchivedTrips(prev => prev.map(update))
|
||||
}
|
||||
|
||||
const handleCopy = async (trip: DashboardTrip) => {
|
||||
try {
|
||||
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.copied'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.copyError'))
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
||||
|| trips.find(t => t.start_date && t.start_date >= today)
|
||||
@@ -805,6 +825,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
trip={spotlight}
|
||||
t={t} locale={locale} dark={dark}
|
||||
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
@@ -821,6 +842,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
@@ -835,6 +857,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
@@ -865,6 +888,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
|
||||
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -6,13 +6,15 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check } from 'lucide-react'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Info } from 'lucide-react'
|
||||
import { authApi, adminApi } from '../api/client'
|
||||
import apiClient from '../api/client'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { UserWithOidc } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import type { Place } from '../types'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
@@ -153,11 +155,20 @@ export default function SettingsPage(): React.ReactElement {
|
||||
// Addon gating (derived from store)
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
}, [])
|
||||
const activePhotoProviders = addons.filter(a => a.type === 'photo_provider' && a.enabled)
|
||||
const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({})
|
||||
const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
}, [])
|
||||
@@ -404,6 +415,31 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
const mapPlaces = useMemo(() => {
|
||||
// Add center location to map places
|
||||
let places: Place[] = []
|
||||
places.push({
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: "Default map center",
|
||||
description: "",
|
||||
lat: defaultLat as number,
|
||||
lng: defaultLng as number,
|
||||
address: "",
|
||||
category_id: 0,
|
||||
icon: null,
|
||||
price: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: Date()
|
||||
});
|
||||
return places
|
||||
}, [defaultLat, defaultLng])
|
||||
|
||||
// Display
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
|
||||
@@ -630,6 +666,29 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height:"200px", width: "100%" }}>
|
||||
<MapView
|
||||
places={mapPlaces}
|
||||
dayPlaces={[]}
|
||||
route={null}
|
||||
routeSegments={null}
|
||||
selectedPlaceId={null}
|
||||
onMarkerClick={null}
|
||||
onMapClick={handleMapClick}
|
||||
onMapContextMenu={null}
|
||||
center = {[settings.default_lat, settings.default_lng]}
|
||||
zoom={defaultZoom}
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={null}
|
||||
dayOrderMap={[]}
|
||||
leftWidth={0}
|
||||
rightWidth={0}
|
||||
hasInspector={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving.map}
|
||||
@@ -1357,6 +1416,24 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{appVersion && (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '6px 14px' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)' }}>TREK</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<div style={{
|
||||
|
||||
@@ -48,6 +48,9 @@ interface AuthState {
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||
let authSequence = 0
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -61,6 +64,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
tripRemindersEnabled: false,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
@@ -84,6 +88,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
completeMfaLogin: async (mfaToken: string, code: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||
@@ -103,6 +108,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
register: async (username: string, email: string, password: string, invite_token?: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.register({ username, email, password, invite_token })
|
||||
@@ -138,10 +144,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadUser: async (opts?: { silent?: boolean }) => {
|
||||
const seq = authSequence
|
||||
const silent = !!opts?.silent
|
||||
if (!silent) set({ isLoading: true })
|
||||
try {
|
||||
const data = await authApi.me()
|
||||
if (seq !== authSequence) return // stale response — a login/register happened meanwhile
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
@@ -149,6 +157,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
})
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
// Only clear auth state on 401 (invalid/expired token), not on network errors
|
||||
const isAuthError = err && typeof err === 'object' && 'response' in err &&
|
||||
(err as { response?: { status?: number } }).response?.status === 401
|
||||
@@ -219,6 +228,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
||||
|
||||
demoLogin: async () => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.demoLogin()
|
||||
|
||||
@@ -38,6 +38,7 @@ services:
|
||||
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
@@ -28,6 +28,8 @@ OIDC_SCOPE=openid email profile groups # Space-separated OIDC scopes to request
|
||||
|
||||
DEMO_MODE=false # Demo mode - resets data hourly
|
||||
|
||||
# MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
|
||||
# Initial admin account — only used on first boot when no users exist yet.
|
||||
# If both are set the admin account is created with these credentials.
|
||||
# If either is omitted a random password is generated and printed to the server log.
|
||||
|
||||
2127
server/package-lock.json
generated
2127
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,13 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:ws": "vitest run tests/websocket",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
@@ -43,9 +49,13 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"nodemon": "^3.1.0"
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"nodemon": "^3.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
243
server/src/app.ts
Normal file
243
server/src/app.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from './config';
|
||||
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||
import { authenticate } from './middleware/auth';
|
||||
import { db } from './db/database';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
import tripsRoutes from './routes/trips';
|
||||
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
|
||||
import placesRoutes from './routes/places';
|
||||
import assignmentsRoutes from './routes/assignments';
|
||||
import packingRoutes from './routes/packing';
|
||||
import tagsRoutes from './routes/tags';
|
||||
import categoriesRoutes from './routes/categories';
|
||||
import adminRoutes from './routes/admin';
|
||||
import mapsRoutes from './routes/maps';
|
||||
import filesRoutes from './routes/files';
|
||||
import reservationsRoutes from './routes/reservations';
|
||||
import dayNotesRoutes from './routes/dayNotes';
|
||||
import weatherRoutes from './routes/weather';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import budgetRoutes from './routes/budget';
|
||||
import collabRoutes from './routes/collab';
|
||||
import backupRoutes from './routes/backup';
|
||||
import oidcRoutes from './routes/oidc';
|
||||
import vacayRoutes from './routes/vacay';
|
||||
import atlasRoutes from './routes/atlas';
|
||||
import immichRoutes from './routes/immich';
|
||||
import notificationRoutes from './routes/notifications';
|
||||
import shareRoutes from './routes/share';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { Addon } from './types';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||
}
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
let corsOrigin: cors.CorsOptions['origin'];
|
||||
if (allowedOrigins) {
|
||||
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||
|
||||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
"'self'", "ws:", "wss:",
|
||||
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
||||
"https://places.googleapis.com", "https://api.openweathermap.org",
|
||||
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson"
|
||||
],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
frameAncestors: ["'self'"],
|
||||
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
}));
|
||||
|
||||
if (shouldForceHttps) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
});
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
app.use(enforceGlobalMfaPolicy);
|
||||
|
||||
// Request logging with sensitive field redaction
|
||||
{
|
||||
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
|
||||
const redact = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
const startedAt = Date.now();
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (res.statusCode >= 500) {
|
||||
logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode >= 400) {
|
||||
logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
}
|
||||
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : '';
|
||||
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : '';
|
||||
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Static: avatars and covers are public
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
|
||||
// Photos require auth or valid share token
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
const safeName = path.basename(req.params.filename);
|
||||
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||
if (!token) return res.status(401).send('Authentication required');
|
||||
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} catch {
|
||||
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
|
||||
if (!shareRow) return res.status(401).send('Authentication required');
|
||||
}
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
// Block direct access to /uploads/files
|
||||
app.use('/uploads/files', (_req: Request, res: Response) => {
|
||||
res.status(401).send('Authentication required');
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/auth/oidc', oidcRoutes);
|
||||
app.use('/api/trips', tripsRoutes);
|
||||
app.use('/api/trips/:tripId/days', daysRoutes);
|
||||
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
|
||||
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Addons list endpoint
|
||||
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
|
||||
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
|
||||
});
|
||||
|
||||
// Addon routes
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/integrations/immich', immichRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
// MCP endpoint
|
||||
app.post('/mcp', mcpHandler);
|
||||
app.get('/mcp', mcpHandler);
|
||||
app.delete('/mcp', mcpHandler);
|
||||
|
||||
// Production static file serving
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const publicPath = path.join(__dirname, '../public');
|
||||
app.use(express.static(publicPath, {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
},
|
||||
}));
|
||||
app.get('*', (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(publicPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('Unhandled error:', err.message);
|
||||
} else {
|
||||
console.error('Unhandled error:', err);
|
||||
}
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const dataDir = path.resolve(__dirname, '../data');
|
||||
|
||||
|
||||
@@ -1,340 +1,29 @@
|
||||
import 'dotenv/config';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { createApp } from './app';
|
||||
|
||||
const app = express();
|
||||
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
|
||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
||||
app.set('trust proxy', parseInt(process.env.TRUST_PROXY as string) || 1);
|
||||
}
|
||||
|
||||
// Create upload directories on startup
|
||||
// Create upload and data directories on startup
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
const photosDir = path.join(uploadsDir, 'photos');
|
||||
const filesDir = path.join(uploadsDir, 'files');
|
||||
const coversDir = path.join(uploadsDir, 'covers');
|
||||
const avatarsDir = path.join(uploadsDir, 'avatars');
|
||||
const backupsDir = path.join(__dirname, '../data/backups');
|
||||
const tmpDir = path.join(__dirname, '../data/tmp');
|
||||
|
||||
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
|
||||
[uploadsDir, photosDir, filesDir, coversDir, avatarsDir, backupsDir, tmpDir].forEach(dir => {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
// Middleware
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
let corsOrigin: cors.CorsOptions['origin'];
|
||||
if (allowedOrigins) {
|
||||
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||
|
||||
app.use(cors({
|
||||
origin: corsOrigin,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
"'self'", "ws:", "wss:",
|
||||
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
||||
"https://places.googleapis.com", "https://api.openweathermap.org",
|
||||
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson"
|
||||
],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
frameAncestors: ["'self'"],
|
||||
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
}));
|
||||
|
||||
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
||||
if (shouldForceHttps) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
});
|
||||
}
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
app.use(enforceGlobalMfaPolicy);
|
||||
|
||||
{
|
||||
const { logInfo: _logInfo, logDebug: _logDebug, logWarn: _logWarn, logError: _logError } = require('./services/auditLog');
|
||||
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
|
||||
const _redact = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(_redact);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : _redact(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
|
||||
const startedAt = Date.now();
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - startedAt;
|
||||
|
||||
if (res.statusCode >= 500) {
|
||||
_logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
_logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode >= 400) {
|
||||
_logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
}
|
||||
|
||||
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(_redact(req.query))}` : '';
|
||||
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(_redact(req.body))}` : '';
|
||||
_logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Avatars are public (shown on login, sharing screens)
|
||||
import { authenticate } from './middleware/auth';
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
|
||||
// Serve uploaded photos — require auth token or valid share token
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
const safeName = path.basename(req.params.filename);
|
||||
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
||||
|
||||
// Allow if authenticated or if a valid share token is present
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = req.query.token as string || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||
if (!token) return res.status(401).send('Authentication required');
|
||||
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
jwt.verify(token, process.env.JWT_SECRET || require('./config').JWT_SECRET);
|
||||
} catch {
|
||||
// Check if it's a share token
|
||||
const shareRow = addonDb.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
|
||||
if (!shareRow) return res.status(401).send('Authentication required');
|
||||
}
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
// Block direct access to /uploads/files — served via authenticated /api/trips/:tripId/files/:id/download
|
||||
app.use('/uploads/files', (_req: Request, res: Response) => {
|
||||
res.status(401).send('Authentication required');
|
||||
});
|
||||
|
||||
// Routes
|
||||
import authRoutes from './routes/auth';
|
||||
import tripsRoutes from './routes/trips';
|
||||
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
|
||||
import placesRoutes from './routes/places';
|
||||
import assignmentsRoutes from './routes/assignments';
|
||||
import packingRoutes from './routes/packing';
|
||||
import tagsRoutes from './routes/tags';
|
||||
import categoriesRoutes from './routes/categories';
|
||||
import adminRoutes from './routes/admin';
|
||||
import mapsRoutes from './routes/maps';
|
||||
import filesRoutes from './routes/files';
|
||||
import reservationsRoutes from './routes/reservations';
|
||||
import dayNotesRoutes from './routes/dayNotes';
|
||||
import weatherRoutes from './routes/weather';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import budgetRoutes from './routes/budget';
|
||||
import collabRoutes from './routes/collab';
|
||||
import backupRoutes from './routes/backup';
|
||||
import oidcRoutes from './routes/oidc';
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/auth/oidc', oidcRoutes);
|
||||
app.use('/api/trips', tripsRoutes);
|
||||
app.use('/api/trips/:tripId/days', daysRoutes);
|
||||
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
|
||||
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
app.get('/api/health', (req: Request, res: Response) => res.json({ status: 'ok' }));
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Public addons endpoint (authenticated but not admin-only)
|
||||
import { authenticate as addonAuth } from './middleware/auth';
|
||||
import {db as addonDb} from './db/database';
|
||||
import { Addon } from './types';
|
||||
app.get('/api/addons', addonAuth, (req: Request, res: Response) => {
|
||||
const addons = addonDb.prepare('SELECT id, name, type, icon, enabled, config, sort_order FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Array<Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled' | 'config'> & { sort_order: number }>;
|
||||
const photoProviders = addonDb.prepare(`
|
||||
SELECT id, name, description, icon, enabled, config, sort_order
|
||||
FROM photo_providers
|
||||
WHERE enabled = 1
|
||||
ORDER BY sort_order
|
||||
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>;
|
||||
const providerIds = photoProviders.map(p => p.id);
|
||||
const providerFields = providerIds.length > 0
|
||||
? addonDb.prepare(`
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
WHERE provider_id IN (${providerIds.map(() => '?').join(',')})
|
||||
ORDER BY sort_order, id
|
||||
`).all(...providerIds) as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>
|
||||
: [];
|
||||
const fieldsByProvider = new Map<string, typeof providerFields>();
|
||||
for (const field of providerFields) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
arr.push(field);
|
||||
fieldsByProvider.set(field.provider_id, arr);
|
||||
}
|
||||
|
||||
const combined = [
|
||||
...addons,
|
||||
...photoProviders.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: p.enabled,
|
||||
config: p.config,
|
||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
placeholder: f.placeholder || '',
|
||||
required: !!f.required,
|
||||
secret: !!f.secret,
|
||||
settings_key: f.settings_key || null,
|
||||
payload_key: f.payload_key || null,
|
||||
sort_order: f.sort_order,
|
||||
})),
|
||||
sort_order: p.sort_order,
|
||||
})),
|
||||
].sort((a, b) => a.sort_order - b.sort_order || a.id.localeCompare(b.id));
|
||||
|
||||
res.json({
|
||||
addons: combined.map(a => ({
|
||||
...a,
|
||||
enabled: !!a.enabled,
|
||||
config: JSON.parse(a.config || '{}'),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Addon routes
|
||||
import vacayRoutes from './routes/vacay';
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
import atlasRoutes from './routes/atlas';
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
import immichRoutes from './routes/immich';
|
||||
app.use('/api/integrations/immich', immichRoutes);
|
||||
const synologyRoutes = require('./routes/synology').default;
|
||||
app.use('/api/integrations/synologyphotos', synologyRoutes);
|
||||
import memoriesRoutes from './routes/memories';
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
|
||||
import notificationRoutes from './routes/notifications';
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
|
||||
import shareRoutes from './routes/share';
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
// MCP endpoint (Streamable HTTP transport, per-user auth)
|
||||
import { mcpHandler, closeMcpSessions } from './mcp';
|
||||
app.post('/mcp', mcpHandler);
|
||||
app.get('/mcp', mcpHandler);
|
||||
app.delete('/mcp', mcpHandler);
|
||||
|
||||
// Serve static files in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const publicPath = path.join(__dirname, '../public');
|
||||
app.use(express.static(publicPath, {
|
||||
setHeaders: (res, filePath) => {
|
||||
// Never cache index.html so version updates are picked up immediately
|
||||
if (filePath.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
},
|
||||
}));
|
||||
app.get('*', (req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(publicPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Global error handler — do not leak stack traces in production
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Unhandled error:', err);
|
||||
} else {
|
||||
console.error('Unhandled error:', err.message);
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
const app = createApp();
|
||||
|
||||
import * as scheduler from './scheduler';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const server = app.listen(PORT, () => {
|
||||
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
|
||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
||||
const banner = [
|
||||
@@ -368,6 +57,7 @@ const server = app.listen(PORT, () => {
|
||||
// Graceful shutdown
|
||||
function shutdown(signal: string): void {
|
||||
const { logInfo: sLogInfo, logError: sLogError } = require('./services/auditLog');
|
||||
const { closeMcpSessions } = require('./mcp');
|
||||
sLogInfo(`${signal} received — shutting down gracefully...`);
|
||||
scheduler.stop();
|
||||
closeMcpSessions();
|
||||
|
||||
@@ -21,7 +21,8 @@ const sessions = new Map<string, McpSession>();
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const MAX_SESSIONS_PER_USER = 5;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const RATE_LIMIT_MAX = 60; // requests per minute per user
|
||||
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
|
||||
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
||||
|
||||
function extractToken(req: Request): string | null {
|
||||
export function extractToken(req: Request): string | null {
|
||||
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
|
||||
const cookieToken = (req as any).cookies?.trek_session;
|
||||
if (cookieToken) return cookieToken;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
|
||||
/** Paths that never require MFA (public or pre-auth). */
|
||||
function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||
if (method === 'GET' && pathNoQuery === '/api/health') return true;
|
||||
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
|
||||
@@ -17,7 +17,7 @@ function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||
}
|
||||
|
||||
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
|
||||
function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
||||
export function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
||||
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
||||
|
||||
@@ -59,7 +59,9 @@ const avatarUpload = multer({
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
||||
return cb(new Error('Only .jpg, .jpeg, .png, .gif, .webp images are allowed'));
|
||||
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
@@ -325,3 +327,6 @@ router.post('/resource-token', authenticate, (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
// Exported for test resets only — do not use in production code
|
||||
export { loginAttempts, mfaAttempts };
|
||||
|
||||
@@ -43,7 +43,9 @@ const noteUpload = multer({
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const BLOCKED = ['.svg', '.html', '.htm', '.xml', '.xhtml', '.js', '.jsx', '.ts', '.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.php'];
|
||||
if (BLOCKED.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
|
||||
return cb(new Error('File type not allowed'));
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
|
||||
@@ -57,14 +57,18 @@ const upload = multer({
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
|
||||
return cb(new Error('File type not allowed'));
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
||||
const fileExt = ext.replace('.', '');
|
||||
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('File type not allowed'));
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
cb(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,6 +74,21 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderItems(tripId, orderedIds);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
@@ -220,19 +235,4 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderItems(tripId, orderedIds);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,10 +3,10 @@ import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { canAccessTrip } from '../db/database';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { AuthRequest } from '../types';
|
||||
import { AuthRequest, Trip } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
verifyTripAccess,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
TRIP_SELECT,
|
||||
} from '../services/tripService';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -173,7 +174,175 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
|
||||
res.json({ cover_image: coverUrl });
|
||||
});
|
||||
|
||||
// ── Delete trip ───────────────────────────────────────────────────────────
|
||||
// ── Copy / duplicate a trip ──────────────────────────────────────────────────
|
||||
router.post('/:id/copy', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
|
||||
return res.status(403).json({ error: 'No permission to create trips' });
|
||||
|
||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
|
||||
if (!src) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const title = req.body.title || src.title;
|
||||
|
||||
const copyTrip = db.transaction(() => {
|
||||
// 1. Create new trip
|
||||
const tripResult = db.prepare(`
|
||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
|
||||
`).run(authReq.user.id, title, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3);
|
||||
const newTripId = tripResult.lastInsertRowid;
|
||||
|
||||
// 2. Copy days → build ID map
|
||||
const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(req.params.id) as any[];
|
||||
const dayMap = new Map<number, number | bigint>();
|
||||
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)');
|
||||
for (const d of oldDays) {
|
||||
const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title);
|
||||
dayMap.set(d.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
// 3. Copy places → build ID map
|
||||
const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const placeMap = new Map<number, number | bigint>();
|
||||
const insertPlace = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const p of oldPlaces) {
|
||||
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
|
||||
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
|
||||
p.website, p.phone, p.transport_mode, p.osm_id);
|
||||
placeMap.set(p.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
// 4. Copy place_tags
|
||||
const oldTags = db.prepare(`
|
||||
SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?
|
||||
`).all(req.params.id) as any[];
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
|
||||
for (const t of oldTags) {
|
||||
const newPlaceId = placeMap.get(t.place_id);
|
||||
if (newPlaceId) insertTag.run(newPlaceId, t.tag_id);
|
||||
}
|
||||
|
||||
// 5. Copy day_assignments → build ID map
|
||||
const oldAssignments = db.prepare(`
|
||||
SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?
|
||||
`).all(req.params.id) as any[];
|
||||
const assignmentMap = new Map<number, number | bigint>();
|
||||
const insertAssignment = db.prepare(`
|
||||
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const a of oldAssignments) {
|
||||
const newDayId = dayMap.get(a.day_id);
|
||||
const newPlaceId = placeMap.get(a.place_id);
|
||||
if (newDayId && newPlaceId) {
|
||||
const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes,
|
||||
a.reservation_status, a.reservation_notes, a.reservation_datetime,
|
||||
a.assignment_time, a.assignment_end_time);
|
||||
assignmentMap.set(a.id, r.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Copy day_accommodations → build ID map (before reservations, which reference them)
|
||||
const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const accomMap = new Map<number, number | bigint>();
|
||||
const insertAccom = db.prepare(`
|
||||
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const a of oldAccom) {
|
||||
const newPlaceId = placeMap.get(a.place_id);
|
||||
const newStartDay = dayMap.get(a.start_day_id);
|
||||
const newEndDay = dayMap.get(a.end_day_id);
|
||||
if (newPlaceId && newStartDay && newEndDay) {
|
||||
const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes);
|
||||
accomMap.set(a.id, r.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Copy reservations
|
||||
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertReservation = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
|
||||
location, confirmation_number, notes, status, type, metadata, day_plan_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const r of oldReservations) {
|
||||
insertReservation.run(newTripId,
|
||||
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
|
||||
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
|
||||
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
|
||||
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
|
||||
r.title, r.reservation_time, r.reservation_end_time,
|
||||
r.location, r.confirmation_number, r.notes, r.status, r.type,
|
||||
r.metadata, r.day_plan_position);
|
||||
}
|
||||
|
||||
// 8. Copy budget_items (paid_by_user_id reset to null)
|
||||
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertBudget = db.prepare(`
|
||||
INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const b of oldBudget) {
|
||||
insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order);
|
||||
}
|
||||
|
||||
// 9. Copy packing_bags → build ID map
|
||||
const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const bagMap = new Map<number, number | bigint>();
|
||||
const insertBag = db.prepare(`
|
||||
INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const bag of oldBags) {
|
||||
const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order);
|
||||
bagMap.set(bag.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
// 10. Copy packing_items (checked reset to 0)
|
||||
const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertPacking = db.prepare(`
|
||||
INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id)
|
||||
VALUES (?, ?, 0, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const p of oldPacking) {
|
||||
insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams,
|
||||
p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null);
|
||||
}
|
||||
|
||||
// 11. Copy day_notes
|
||||
const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertNote = db.prepare(`
|
||||
INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const n of oldNotes) {
|
||||
const newDayId = dayMap.get(n.day_id);
|
||||
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
|
||||
}
|
||||
|
||||
return newTripId;
|
||||
});
|
||||
|
||||
try {
|
||||
const newTripId = copyTrip();
|
||||
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId: Number(newTripId), title } });
|
||||
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId });
|
||||
res.status(201).json({ trip });
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'Failed to copy trip' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import cron, { type ScheduledTask } from 'node-cron';
|
||||
import archiver from 'archiver';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const dataDir = path.join(__dirname, '../data');
|
||||
const backupsDir = path.join(dataDir, 'backups');
|
||||
@@ -9,8 +9,8 @@ const uploadsDir = path.join(__dirname, '../uploads');
|
||||
const settingsFile = path.join(dataDir, 'backup-settings.json');
|
||||
|
||||
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
|
||||
const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
|
||||
const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const VALID_DAYS_OF_WEEK = new Set([0, 1, 2, 3, 4, 5, 6]); // 0=Sunday
|
||||
const VALID_HOURS = new Set(Array.from({length: 24}, (_, i) => i));
|
||||
|
||||
interface BackupSettings {
|
||||
enabled: boolean;
|
||||
@@ -21,9 +21,9 @@ interface BackupSettings {
|
||||
day_of_month: number;
|
||||
}
|
||||
|
||||
function buildCronExpression(settings: BackupSettings): string {
|
||||
const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
|
||||
const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
|
||||
export function buildCronExpression(settings: BackupSettings): string {
|
||||
const hour = VALID_HOURS.has(settings.hour) ? settings.hour : 2;
|
||||
const dow = VALID_DAYS_OF_WEEK.has(settings.day_of_week) ? settings.day_of_week : 0;
|
||||
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
|
||||
|
||||
switch (settings.interval) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { ENCRYPTION_KEY } from '../config';
|
||||
|
||||
const ENCRYPTED_PREFIX = 'enc:v1:';
|
||||
|
||||
@@ -101,7 +101,7 @@ export function formatNote(note: CollabNote) {
|
||||
return {
|
||||
...note,
|
||||
avatar_url: avatarUrl(note),
|
||||
attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })),
|
||||
attachments: attachments.map(a => ({ ...a, url: `/api/trips/${note.trip_id}/files/${a.id}/download` })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function addNoteFile(tripId: string | number, noteId: string | number, fi
|
||||
).run(tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype);
|
||||
|
||||
const saved = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid) as TripFile;
|
||||
return { file: { ...saved, url: `/uploads/${saved.filename}` } };
|
||||
return { file: { ...saved, url: `/api/trips/${tripId}/files/${saved.id}/download` } };
|
||||
}
|
||||
|
||||
export function getFormattedNoteById(noteId: string | number) {
|
||||
@@ -394,7 +394,7 @@ export async function fetchLinkPreview(url: string): Promise<LinkPreviewResult>
|
||||
const fallback: LinkPreviewResult = { title: null, description: null, image: null, url };
|
||||
|
||||
const parsed = new URL(url);
|
||||
const ssrf = await checkSsrf(url);
|
||||
const ssrf = await checkSsrf(url, true);
|
||||
if (!ssrf.allowed) {
|
||||
return { ...fallback, error: ssrf.error } as LinkPreviewResult & { error?: string };
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Response } from 'express';
|
||||
|
||||
const COOKIE_NAME = 'trek_session';
|
||||
|
||||
function cookieOptions(clear = false) {
|
||||
export function cookieOptions(clear = false) {
|
||||
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'strict' as const,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry
|
||||
};
|
||||
|
||||
@@ -175,14 +175,14 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
};
|
||||
|
||||
// Get localized event text
|
||||
function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
||||
export function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||
return texts[event](params);
|
||||
}
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
function buildEmailHtml(subject: string, body: string, lang: string): string {
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = appUrl || '#';
|
||||
@@ -256,7 +256,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
||||
}
|
||||
}
|
||||
|
||||
function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
||||
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Trip, User } from '../types';
|
||||
export const MS_PER_DAY = 86400000;
|
||||
export const MAX_TRIP_DAYS = 365;
|
||||
|
||||
const TRIP_SELECT = `
|
||||
export const TRIP_SELECT = `
|
||||
SELECT t.*,
|
||||
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
|
||||
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
|
||||
|
||||
@@ -116,7 +116,7 @@ const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
|
||||
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function cacheKey(lat: string, lng: string, date?: string): string {
|
||||
export function cacheKey(lat: string, lng: string, date?: string): string {
|
||||
const rlat = parseFloat(lat).toFixed(2);
|
||||
const rlng = parseFloat(lng).toFixed(2);
|
||||
return `${rlat}_${rlng}_${date || 'current'}`;
|
||||
@@ -138,7 +138,7 @@ function setCache(key: string, data: WeatherResult, ttlMs: number): void {
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function estimateCondition(tempAvg: number, precipMm: number): string {
|
||||
export function estimateCondition(tempAvg: number, precipMm: number): string {
|
||||
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
|
||||
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
|
||||
if (precipMm > 0.3) return 'Clouds';
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Trip {
|
||||
currency: string;
|
||||
cover_image?: string | null;
|
||||
is_archived: number;
|
||||
reminder_days: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dns from 'dns/promises';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import dns from 'node:dns/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true';
|
||||
|
||||
@@ -17,11 +17,11 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// Loopback
|
||||
if (/^127\./.test(addr) || addr === '::1') return true;
|
||||
if (addr.startsWith("127.") || addr === '::1') return true;
|
||||
// Unspecified
|
||||
if (/^0\./.test(addr)) return true;
|
||||
if (addr.startsWith("0.")) return true;
|
||||
// Link-local / cloud metadata
|
||||
if (/^169\.254\./.test(addr) || /^fe80:/i.test(addr)) return true;
|
||||
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
|
||||
@@ -33,9 +33,9 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// RFC-1918 private ranges
|
||||
if (/^10\./.test(addr)) return true;
|
||||
if (addr.startsWith("10.")) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
||||
if (/^192\.168\./.test(addr)) return true;
|
||||
if (addr.startsWith("192.168.")) return true;
|
||||
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
@@ -53,7 +53,7 @@ function isInternalHostname(hostname: string): boolean {
|
||||
return h.endsWith('.local') || h.endsWith('.internal') || h === 'localhost';
|
||||
}
|
||||
|
||||
export async function checkSsrf(rawUrl: string): Promise<SsrfResult> {
|
||||
export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean = false): Promise<SsrfResult> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
@@ -91,7 +91,7 @@ export async function checkSsrf(rawUrl: string): Promise<SsrfResult> {
|
||||
}
|
||||
|
||||
if (isPrivateNetwork(resolvedIp) || isInternalHostname(hostname)) {
|
||||
if (!ALLOW_INTERNAL_NETWORK) {
|
||||
if (!ALLOW_INTERNAL_NETWORK || bypassInternalIpAllowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { db, canAccessTrip } from './db/database';
|
||||
import { consumeEphemeralToken } from './services/ephemeralTokens';
|
||||
import { User } from './types';
|
||||
import http from 'http';
|
||||
import http from 'node:http';
|
||||
|
||||
interface NomadWebSocket extends WebSocket {
|
||||
isAlive: boolean;
|
||||
@@ -48,7 +48,7 @@ function setupWebSocket(server: http.Server): void {
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||
const heartbeat = setInterval(() => {
|
||||
wss!.clients.forEach((ws) => {
|
||||
wss.clients.forEach((ws) => {
|
||||
const nws = ws as NomadWebSocket;
|
||||
if (nws.isAlive === false) return nws.terminate();
|
||||
nws.isAlive = false;
|
||||
@@ -61,7 +61,7 @@ function setupWebSocket(server: http.Server): void {
|
||||
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
|
||||
const nws = ws as NomadWebSocket;
|
||||
// Extract token from query param
|
||||
const url = new URL(req.url!, 'http://localhost');
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
@@ -103,7 +103,7 @@ function setupWebSocket(server: http.Server): void {
|
||||
|
||||
nws.on('message', (data) => {
|
||||
// Rate limiting
|
||||
const rate = socketMsgCounts.get(nws)!;
|
||||
const rate = socketMsgCounts.get(nws);
|
||||
const now = Date.now();
|
||||
if (now - rate.windowStart > WS_MSG_WINDOW) {
|
||||
rate.count = 1;
|
||||
@@ -129,14 +129,14 @@ function setupWebSocket(server: http.Server): void {
|
||||
if (msg.type === 'join' && msg.tripId) {
|
||||
const tripId = Number(msg.tripId);
|
||||
// Verify the user has access to this trip
|
||||
if (!canAccessTrip(tripId, user!.id)) {
|
||||
if (!canAccessTrip(tripId, user.id)) {
|
||||
nws.send(JSON.stringify({ type: 'error', message: 'Access denied' }));
|
||||
return;
|
||||
}
|
||||
// Add to room
|
||||
if (!rooms.has(tripId)) rooms.set(tripId, new Set());
|
||||
rooms.get(tripId)!.add(nws);
|
||||
socketRooms.get(nws)!.add(tripId);
|
||||
rooms.get(tripId).add(nws);
|
||||
socketRooms.get(nws).add(tripId);
|
||||
nws.send(JSON.stringify({ type: 'joined', tripId }));
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ function broadcastToUser(userId: number, payload: Record<string, unknown>, exclu
|
||||
if (nws.readyState !== 1) continue;
|
||||
if (excludeNum && socketId.get(nws) === excludeNum) continue;
|
||||
const user = socketUser.get(nws);
|
||||
if (user && user.id === userId) {
|
||||
if (user?.id === userId) {
|
||||
nws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
BIN
server/tests/fixtures/small-image.jpg
vendored
Normal file
BIN
server/tests/fixtures/small-image.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 B |
11
server/tests/fixtures/test.gpx
vendored
Normal file
11
server/tests/fixtures/test.gpx
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="TREK Tests" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="48.8566" lon="2.3522">
|
||||
<name>Eiffel Tower</name>
|
||||
<desc>Paris landmark</desc>
|
||||
</wpt>
|
||||
<wpt lat="48.8606" lon="2.3376">
|
||||
<name>Louvre Museum</name>
|
||||
<desc>Art museum</desc>
|
||||
</wpt>
|
||||
</gpx>
|
||||
21
server/tests/fixtures/test.pdf
vendored
Normal file
21
server/tests/fixtures/test.pdf
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer
|
||||
<< /Size 4 /Root 1 0 R >>
|
||||
startxref
|
||||
190
|
||||
%%EOF
|
||||
34
server/tests/helpers/auth.ts
Normal file
34
server/tests/helpers/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Auth helpers for integration tests.
|
||||
*
|
||||
* Provides utilities to generate JWTs and authenticate supertest requests
|
||||
* using the fixed test JWT_SECRET from TEST_CONFIG.
|
||||
*/
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { TEST_CONFIG } from './test-db';
|
||||
|
||||
/** Signs a JWT for the given user ID using the test secret. */
|
||||
export function generateToken(userId: number, extraClaims: Record<string, unknown> = {}): string {
|
||||
return jwt.sign(
|
||||
{ id: userId, ...extraClaims },
|
||||
TEST_CONFIG.JWT_SECRET,
|
||||
{ algorithm: 'HS256', expiresIn: '1h' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cookie string suitable for supertest:
|
||||
* request(app).get('/api/...').set('Cookie', authCookie(userId))
|
||||
*/
|
||||
export function authCookie(userId: number): string {
|
||||
return `trek_session=${generateToken(userId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an Authorization header object suitable for supertest:
|
||||
* request(app).get('/api/...').set(authHeader(userId))
|
||||
*/
|
||||
export function authHeader(userId: number): Record<string, string> {
|
||||
return { Authorization: `Bearer ${generateToken(userId)}` };
|
||||
}
|
||||
287
server/tests/helpers/factories.ts
Normal file
287
server/tests/helpers/factories.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Test data factories.
|
||||
* Each factory inserts a row into the provided in-memory DB and returns the created object.
|
||||
* Passwords are stored as bcrypt hashes (cost factor 4 for speed in tests).
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { encryptMfaSecret } from '../../src/services/mfaCrypto';
|
||||
|
||||
let _userSeq = 0;
|
||||
let _tripSeq = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
password_hash: string;
|
||||
}
|
||||
|
||||
export function createUser(
|
||||
db: Database.Database,
|
||||
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {}
|
||||
): { user: TestUser; password: string } {
|
||||
_userSeq++;
|
||||
const password = overrides.password ?? `TestPass${_userSeq}!`;
|
||||
const email = overrides.email ?? `user${_userSeq}@test.example.com`;
|
||||
const username = overrides.username ?? `testuser${_userSeq}`;
|
||||
const role = overrides.role ?? 'user';
|
||||
const hash = bcrypt.hashSync(password, 4); // cost 4 for test speed
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username, email, hash, role);
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid) as TestUser;
|
||||
return { user, password };
|
||||
}
|
||||
|
||||
export function createAdmin(
|
||||
db: Database.Database,
|
||||
overrides: Partial<{ username: string; email: string; password: string }> = {}
|
||||
): { user: TestUser; password: string } {
|
||||
return createUser(db, { ...overrides, role: 'admin' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user with MFA already enabled (directly in DB, bypasses rate-limited HTTP endpoints).
|
||||
* Returns the user, password, and the TOTP secret so tests can generate valid codes.
|
||||
*/
|
||||
const KNOWN_MFA_SECRET = 'JBSWY3DPEHPK3PXP'; // fixed base32 secret for deterministic tests
|
||||
export function createUserWithMfa(
|
||||
db: Database.Database,
|
||||
overrides: Partial<{ username: string; email: string; password: string; role: 'admin' | 'user' }> = {}
|
||||
): { user: TestUser; password: string; totpSecret: string } {
|
||||
const { user, password } = createUser(db, overrides);
|
||||
const encryptedSecret = encryptMfaSecret(KNOWN_MFA_SECRET);
|
||||
db.prepare(
|
||||
'UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?'
|
||||
).run(encryptedSecret, user.id);
|
||||
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as TestUser;
|
||||
return { user: updated, password, totpSecret: KNOWN_MFA_SECRET };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestTrip {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
export function createTrip(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
overrides: Partial<{ title: string; start_date: string; end_date: string; description: string }> = {}
|
||||
): TestTrip {
|
||||
_tripSeq++;
|
||||
const title = overrides.title ?? `Test Trip ${_tripSeq}`;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO trips (user_id, title, description, start_date, end_date) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(userId, title, overrides.description ?? null, overrides.start_date ?? null, overrides.end_date ?? null);
|
||||
|
||||
// Auto-generate days if dates are provided
|
||||
if (overrides.start_date && overrides.end_date) {
|
||||
const start = new Date(overrides.start_date);
|
||||
const end = new Date(overrides.end_date);
|
||||
const tripId = result.lastInsertRowid as number;
|
||||
let dayNumber = 1;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, dayNumber++, dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare('SELECT * FROM trips WHERE id = ?').get(result.lastInsertRowid) as TestTrip;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Days
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestDay {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
day_number: number;
|
||||
date: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export function createDay(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
overrides: Partial<{ date: string; title: string; day_number: number }> = {}
|
||||
): TestDay {
|
||||
// Find the next day_number for this trip if not provided
|
||||
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const dayNumber = overrides.day_number ?? (maxDay.max ?? 0) + 1;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO days (trip_id, day_number, date, title) VALUES (?, ?, ?, ?)'
|
||||
).run(tripId, dayNumber, overrides.date ?? null, overrides.title ?? null);
|
||||
return db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as TestDay;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Places
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestPlace {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
category_id: number | null;
|
||||
}
|
||||
|
||||
export function createPlace(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
overrides: Partial<{ name: string; lat: number; lng: number; category_id: number; description: string }> = {}
|
||||
): TestPlace {
|
||||
// Get first available category if none provided
|
||||
const defaultCat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
||||
const categoryId = overrides.category_id ?? defaultCat?.id ?? null;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO places (trip_id, name, lat, lng, category_id, description) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
overrides.name ?? 'Test Place',
|
||||
overrides.lat ?? 48.8566,
|
||||
overrides.lng ?? 2.3522,
|
||||
categoryId,
|
||||
overrides.description ?? null
|
||||
);
|
||||
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid) as TestPlace;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trip Members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function addTripMember(db: Database.Database, tripId: number, userId: number): void {
|
||||
db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Budget Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestBudgetItem {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
total_price: number;
|
||||
}
|
||||
|
||||
export function createBudgetItem(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
overrides: Partial<{ name: string; category: string; total_price: number }> = {}
|
||||
): TestBudgetItem {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
overrides.name ?? 'Test Budget Item',
|
||||
overrides.category ?? 'Transport',
|
||||
overrides.total_price ?? 100
|
||||
);
|
||||
return db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as TestBudgetItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packing Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestPackingItem {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
checked: number;
|
||||
}
|
||||
|
||||
export function createPackingItem(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
overrides: Partial<{ name: string; category: string }> = {}
|
||||
): TestPackingItem {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO packing_items (trip_id, name, category, checked) VALUES (?, ?, ?, 0)'
|
||||
).run(tripId, overrides.name ?? 'Test Item', overrides.category ?? 'Clothing');
|
||||
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid) as TestPackingItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reservations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestReservation {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function createReservation(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
overrides: Partial<{ title: string; type: string; day_id: number }> = {}
|
||||
): TestReservation {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, ?, ?, ?)'
|
||||
).run(tripId, overrides.title ?? 'Test Reservation', overrides.type ?? 'flight', overrides.day_id ?? null);
|
||||
return db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid) as TestReservation;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestInviteToken {
|
||||
id: number;
|
||||
token: string;
|
||||
max_uses: number | null;
|
||||
used_count: number;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export function createInviteToken(
|
||||
db: Database.Database,
|
||||
overrides: Partial<{ token: string; max_uses: number; expires_at: string; created_by: number }> = {}
|
||||
): TestInviteToken {
|
||||
const token = overrides.token ?? `test-invite-${Date.now()}`;
|
||||
// created_by is required by the schema; use an existing admin or create one
|
||||
let createdBy = overrides.created_by;
|
||||
if (!createdBy) {
|
||||
const admin = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get() as { id: number } | undefined;
|
||||
if (admin) {
|
||||
createdBy = admin.id;
|
||||
} else {
|
||||
const any = db.prepare('SELECT id FROM users LIMIT 1').get() as { id: number } | undefined;
|
||||
if (any) {
|
||||
createdBy = any.id;
|
||||
} else {
|
||||
const r = db.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('invite_creator', 'invite_creator@test.example.com', 'x', 'admin')").run();
|
||||
createdBy = r.lastInsertRowid as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = db.prepare(
|
||||
'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES (?, ?, 0, ?, ?)'
|
||||
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
|
||||
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
|
||||
}
|
||||
193
server/tests/helpers/test-db.ts
Normal file
193
server/tests/helpers/test-db.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* In-memory SQLite test database helper.
|
||||
*
|
||||
* Usage in an integration test file:
|
||||
*
|
||||
* import { createTestDb, resetTestDb } from '../helpers/test-db';
|
||||
* import { buildDbMock } from '../helpers/test-db';
|
||||
*
|
||||
* // Declare at module scope (before vi.mock so it's available in factory)
|
||||
* const testDb = createTestDb();
|
||||
*
|
||||
* vi.mock('../../src/db/database', () => buildDbMock(testDb));
|
||||
* vi.mock('../../src/config', () => TEST_CONFIG);
|
||||
*
|
||||
* beforeEach(() => resetTestDb(testDb));
|
||||
* afterAll(() => testDb.close());
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
|
||||
// Tables to clear on reset, ordered to avoid FK violations
|
||||
const RESET_TABLES = [
|
||||
'file_links',
|
||||
'collab_poll_votes',
|
||||
'collab_messages',
|
||||
'collab_poll_options',
|
||||
'collab_polls',
|
||||
'collab_notes',
|
||||
'day_notes',
|
||||
'assignment_participants',
|
||||
'day_assignments',
|
||||
'packing_category_assignees',
|
||||
'packing_bags',
|
||||
'packing_items',
|
||||
'budget_item_members',
|
||||
'budget_items',
|
||||
'trip_files',
|
||||
'share_tokens',
|
||||
'photos',
|
||||
'reservations',
|
||||
'day_accommodations',
|
||||
'place_tags',
|
||||
'places',
|
||||
'days',
|
||||
'trip_members',
|
||||
'trips',
|
||||
'vacay_entries',
|
||||
'vacay_company_holidays',
|
||||
'vacay_holiday_calendars',
|
||||
'vacay_plan_members',
|
||||
'vacay_years',
|
||||
'vacay_plans',
|
||||
'atlas_visited_countries',
|
||||
'atlas_bucket_list',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
'user_settings',
|
||||
'mcp_tokens',
|
||||
'mcp_sessions',
|
||||
'invite_tokens',
|
||||
'tags',
|
||||
'app_settings',
|
||||
'users',
|
||||
];
|
||||
|
||||
const DEFAULT_CATEGORIES = [
|
||||
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
|
||||
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
|
||||
{ name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
|
||||
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
|
||||
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
|
||||
{ name: 'Activity', color: '#10b981', icon: '🎯' },
|
||||
{ name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
|
||||
{ name: 'Beach', color: '#06b6d4', icon: '🏖️' },
|
||||
{ name: 'Nature', color: '#84cc16', icon: '🌿' },
|
||||
{ name: 'Other', color: '#6366f1', icon: '📍' },
|
||||
];
|
||||
|
||||
const DEFAULT_ADDONS = [
|
||||
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||
{ id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
];
|
||||
|
||||
function seedDefaults(db: Database.Database): void {
|
||||
const insertCat = db.prepare('INSERT OR IGNORE INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
||||
for (const cat of DEFAULT_CATEGORIES) insertCat.run(cat.name, cat.color, cat.icon);
|
||||
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of DEFAULT_ADDONS) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh in-memory SQLite database with the full schema and migrations applied.
|
||||
* Default categories and addons are seeded. No users are created.
|
||||
*/
|
||||
export function createTestDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
seedDefaults(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all user-generated data from the test DB and re-seeds defaults.
|
||||
* Call in beforeEach() for test isolation within a file.
|
||||
*/
|
||||
export function resetTestDb(db: Database.Database): void {
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
for (const table of RESET_TABLES) {
|
||||
try { db.exec(`DELETE FROM "${table}"`); } catch { /* table may not exist in older schemas */ }
|
||||
}
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
seedDefaults(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the mock factory for vi.mock('../../src/db/database', ...).
|
||||
* The returned object mirrors the shape of database.ts exports.
|
||||
*
|
||||
* @example
|
||||
* const testDb = createTestDb();
|
||||
* vi.mock('../../src/db/database', () => buildDbMock(testDb));
|
||||
*/
|
||||
export function buildDbMock(testDb: Database.Database) {
|
||||
return {
|
||||
db: testDb,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number | string) => {
|
||||
interface PlaceRow {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const place = testDb.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = ?
|
||||
`).get(placeId) as PlaceRow | undefined;
|
||||
|
||||
if (!place) return null;
|
||||
|
||||
const tags = testDb.prepare(`
|
||||
SELECT t.* FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id = ?
|
||||
`).all(placeId);
|
||||
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id ? {
|
||||
id: place.category_id,
|
||||
name: place.category_name,
|
||||
color: place.category_color,
|
||||
icon: place.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: number | string, userId: number) => {
|
||||
return testDb.prepare(`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId);
|
||||
},
|
||||
isOwner: (tripId: number | string, userId: number) => {
|
||||
return !!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */
|
||||
export const TEST_CONFIG = {
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
};
|
||||
109
server/tests/helpers/ws-client.ts
Normal file
109
server/tests/helpers/ws-client.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* WebSocket test client helper.
|
||||
*
|
||||
* Usage:
|
||||
* import http from 'http';
|
||||
* import { setupWebSocket } from '../../src/websocket';
|
||||
* import { WsTestClient, getWsToken } from '../helpers/ws-client';
|
||||
*
|
||||
* let server: http.Server;
|
||||
* let client: WsTestClient;
|
||||
*
|
||||
* beforeAll(async () => {
|
||||
* const app = createApp();
|
||||
* server = http.createServer(app);
|
||||
* setupWebSocket(server);
|
||||
* await new Promise<void>(res => server.listen(0, res));
|
||||
* });
|
||||
*
|
||||
* afterAll(() => server.close());
|
||||
*
|
||||
* it('connects', async () => {
|
||||
* const addr = server.address() as AddressInfo;
|
||||
* const token = await getWsToken(addr.port, userId);
|
||||
* client = new WsTestClient(`ws://localhost:${addr.port}/ws?token=${token}`);
|
||||
* const msg = await client.waitForMessage('welcome');
|
||||
* expect(msg.type).toBe('welcome');
|
||||
* });
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface WsMessage {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class WsTestClient {
|
||||
private ws: WebSocket;
|
||||
private messageQueue: WsMessage[] = [];
|
||||
private waiters: Array<{ type: string; resolve: (msg: WsMessage) => void; reject: (err: Error) => void }> = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.on('message', (data: WebSocket.RawData) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString()) as WsMessage;
|
||||
const waiterIdx = this.waiters.findIndex(w => w.type === msg.type || w.type === '*');
|
||||
if (waiterIdx >= 0) {
|
||||
const waiter = this.waiters.splice(waiterIdx, 1)[0];
|
||||
waiter.resolve(msg);
|
||||
} else {
|
||||
this.messageQueue.push(msg);
|
||||
}
|
||||
} catch { /* ignore malformed messages */ }
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for a message of the given type (or '*' for any). */
|
||||
waitForMessage(type: string, timeoutMs = 5000): Promise<WsMessage> {
|
||||
// Check if already in queue
|
||||
const idx = this.messageQueue.findIndex(m => type === '*' || m.type === type);
|
||||
if (idx >= 0) {
|
||||
return Promise.resolve(this.messageQueue.splice(idx, 1)[0]);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const waiterIdx = this.waiters.findIndex(w => w.resolve === resolve);
|
||||
if (waiterIdx >= 0) this.waiters.splice(waiterIdx, 1);
|
||||
reject(new Error(`Timed out waiting for WS message type="${type}" after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.waiters.push({
|
||||
type,
|
||||
resolve: (msg) => { clearTimeout(timer); resolve(msg); },
|
||||
reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a JSON message. */
|
||||
send(msg: Record<string, unknown>): void {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
/** Close the connection. */
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
/** Wait for the connection to be open. */
|
||||
waitForOpen(timeoutMs = 3000): Promise<void> {
|
||||
if (this.ws.readyState === WebSocket.OPEN) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS open timed out')), timeoutMs);
|
||||
this.ws.once('open', () => { clearTimeout(timer); resolve(); });
|
||||
this.ws.once('error', (err) => { clearTimeout(timer); reject(err); });
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for the connection to close. */
|
||||
waitForClose(timeoutMs = 3000): Promise<number> {
|
||||
if (this.ws.readyState === WebSocket.CLOSED) return Promise.resolve(1000);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS close timed out')), timeoutMs);
|
||||
this.ws.once('close', (code) => { clearTimeout(timer); resolve(code); });
|
||||
});
|
||||
}
|
||||
}
|
||||
353
server/tests/integration/admin.test.ts
Normal file
353
server/tests/integration/admin.test.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Admin integration tests.
|
||||
* Covers ADMIN-001 to ADMIN-022.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createInviteToken } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Access control
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Admin access control', () => {
|
||||
it('ADMIN-022 — non-admin cannot access admin routes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/users')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('ADMIN-022 — unauthenticated request returns 401', async () => {
|
||||
const res = await request(app).get('/api/admin/users');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// User management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Admin user management', () => {
|
||||
it('ADMIN-001 — GET /admin/users lists all users', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
createUser(testDb);
|
||||
createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/users')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.users.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('ADMIN-002 — POST /admin/users creates a user', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/users')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ username: 'newuser', email: 'newuser@example.com', password: 'Secure1234!', role: 'user' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.user.email).toBe('newuser@example.com');
|
||||
});
|
||||
|
||||
it('ADMIN-003 — POST /admin/users with duplicate email returns 409', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: existing } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/users')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ username: 'duplicate', email: existing.email, password: 'Secure1234!' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('ADMIN-004 — PUT /admin/users/:id updates user', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/users/${user.id}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ username: 'updated_username' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.username).toBe('updated_username');
|
||||
});
|
||||
|
||||
it('ADMIN-005 — DELETE /admin/users/:id removes user', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/users/${user.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/users/${admin.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// System stats
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('System stats', () => {
|
||||
it('ADMIN-007 — GET /admin/stats returns system statistics', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/stats')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('totalUsers');
|
||||
expect(res.body).toHaveProperty('totalTrips');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Permissions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Permissions management', () => {
|
||||
it('ADMIN-008 — GET /admin/permissions returns permission config', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('permissions');
|
||||
expect(Array.isArray(res.body.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-008 — PUT /admin/permissions updates permissions', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const currentPerms = getRes.body;
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ permissions: currentPerms });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ADMIN-008 — PUT /admin/permissions without object returns 400', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ permissions: null });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Audit log
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Audit log', () => {
|
||||
it('ADMIN-009 — GET /admin/audit-log returns log entries', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/audit-log')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Addon management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Addon management', () => {
|
||||
it('ADMIN-011 — PUT /admin/addons/:id disables an addon', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/addons/atlas')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ADMIN-012 — PUT /admin/addons/:id re-enables an addon', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/addons/atlas')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ enabled: false });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/addons/atlas')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invite tokens
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Invite token management', () => {
|
||||
it('ADMIN-013 — POST /admin/invites creates an invite token', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/invites')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ max_uses: 5 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.invite.token).toBeDefined();
|
||||
});
|
||||
|
||||
it('ADMIN-014 — DELETE /admin/invites/:id removes invite', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const invite = createInviteToken(testDb, { created_by: admin.id });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/invites/${invite.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Packing templates
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Packing templates', () => {
|
||||
it('ADMIN-015 — POST /admin/packing-templates creates a template', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/packing-templates')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Beach Trip', description: 'Beach essentials' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.template.name).toBe('Beach Trip');
|
||||
});
|
||||
|
||||
it('ADMIN-016 — DELETE /admin/packing-templates/:id removes template', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const create = await request(app)
|
||||
.post('/api/admin/packing-templates')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Temp Template' });
|
||||
const templateId = create.body.template.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/packing-templates/${templateId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bag tracking
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Bag tracking', () => {
|
||||
it('ADMIN-017 — PUT /admin/bag-tracking toggles bag tracking', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/bag-tracking')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// JWT rotation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('JWT rotation', () => {
|
||||
it('ADMIN-018 — POST /admin/rotate-jwt-secret rotates the JWT secret', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/rotate-jwt-secret')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
343
server/tests/integration/assignments.test.ts
Normal file
343
server/tests/integration/assignments.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Day Assignments integration tests.
|
||||
* Covers ASSIGN-001 to ASSIGN-009.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// Helper: create a trip with a day and a place, return all three
|
||||
function setupAssignmentFixtures(userId: number) {
|
||||
const trip = createTrip(testDb, userId);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Test Place' });
|
||||
return { trip, day, place };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create assignment
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create assignment', () => {
|
||||
it('ASSIGN-001 — POST creates assignment linking place to day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
expect(res.status).toBe(201);
|
||||
// The assignment has an embedded place object, not a top-level place_id
|
||||
expect(res.body.assignment.place.id).toBe(place.id);
|
||||
expect(res.body.assignment.day_id).toBe(day.id);
|
||||
});
|
||||
|
||||
it('ASSIGN-001 — POST with notes stores notes on assignment', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, notes: 'Book table in advance' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.assignment.notes).toBe('Book table in advance');
|
||||
});
|
||||
|
||||
it('ASSIGN-001 — POST with non-existent place returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: 99999 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ASSIGN-001 — POST with non-existent day returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/99999/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ASSIGN-006 — non-member cannot create assignment', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({ place_id: place.id });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ASSIGN-006 — trip member can create assignment', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({ place_id: place.id });
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List assignments
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List assignments', () => {
|
||||
it('ASSIGN-002 — GET /api/trips/:tripId/days/:dayId/assignments returns assignments for the day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignments).toHaveLength(1);
|
||||
// Assignments have an embedded place object
|
||||
expect(res.body.assignments[0].place.id).toBe(place.id);
|
||||
});
|
||||
|
||||
it('ASSIGN-002 — returns empty array when no assignments exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ASSIGN-006 — non-member cannot list assignments', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const { trip, day } = setupAssignmentFixtures(owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete assignment
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete assignment', () => {
|
||||
it('ASSIGN-004 — DELETE removes assignment', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}/assignments/${assignmentId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.assignments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ASSIGN-004 — DELETE returns 404 for non-existent assignment', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}/assignments/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reorder assignments
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Reorder assignments', () => {
|
||||
it('ASSIGN-007 — PUT /reorder reorders assignments within a day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
const place1 = createPlace(testDb, trip.id, { name: 'Place A' });
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Place B' });
|
||||
|
||||
const a1 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place1.id });
|
||||
const a2 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place2.id });
|
||||
|
||||
const reorder = await request(app)
|
||||
.put(`/api/trips/${trip.id}/days/${day.id}/assignments/reorder`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] });
|
||||
expect(reorder.status).toBe(200);
|
||||
expect(reorder.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Move assignment
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Move assignment', () => {
|
||||
it('ASSIGN-008 — PUT /move transfers assignment to a different day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-06-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day1.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
const move = await request(app)
|
||||
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/move`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ new_day_id: day2.id, order_index: 0 });
|
||||
expect(move.status).toBe(200);
|
||||
expect(move.body.assignment.day_id).toBe(day2.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Participants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Assignment participants', () => {
|
||||
it('ASSIGN-005 — PUT /participants updates participant list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
const update = await request(app)
|
||||
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
expect(update.status).toBe(200);
|
||||
|
||||
const getParticipants = await request(app)
|
||||
.get(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getParticipants.status).toBe(200);
|
||||
expect(getParticipants.body.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ASSIGN-009 — PUT /time updates assignment time fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
const update = await request(app)
|
||||
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/time`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_time: '14:00', end_time: '16:00' });
|
||||
expect(update.status).toBe(200);
|
||||
// Time is embedded under assignment.place.place_time (COALESCEd from assignment_time)
|
||||
expect(update.body.assignment.place.place_time).toBe('14:00');
|
||||
expect(update.body.assignment.place.end_time).toBe('16:00');
|
||||
});
|
||||
});
|
||||
204
server/tests/integration/atlas.test.ts
Normal file
204
server/tests/integration/atlas.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Atlas integration tests.
|
||||
* Covers ATLAS-001 to ATLAS-008.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Atlas stats', () => {
|
||||
it('ATLAS-001 — GET /api/atlas/stats returns stats object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('countries');
|
||||
expect(res.body).toHaveProperty('stats');
|
||||
});
|
||||
|
||||
it('ATLAS-002 — GET /api/atlas/country/:code returns places in country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/country/FR')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.places)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mark/unmark country', () => {
|
||||
it('ATLAS-003 — POST /country/:code/mark marks country as visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/country/DE/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it appears in visited countries
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).toContain('DE');
|
||||
});
|
||||
|
||||
it('ATLAS-004 — DELETE /country/:code/mark unmarks country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/country/IT/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/atlas/country/IT/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bucket list', () => {
|
||||
it('ATLAS-005 — POST /bucket-list creates a bucket list item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Machu Picchu', country_code: 'PE', lat: -13.1631, lng: -72.5450 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item.name).toBe('Machu Picchu');
|
||||
});
|
||||
|
||||
it('ATLAS-005 — POST without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ country_code: 'JP' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ATLAS-006 — GET /bucket-list returns items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Santorini', country_code: 'GR' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ATLAS-007 — PUT /bucket-list/:id updates item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const create = await request(app)
|
||||
.post('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Old Name' });
|
||||
const id = create.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/addons/atlas/bucket-list/${id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'New Name', notes: 'Updated' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('ATLAS-008 — DELETE /bucket-list/:id removes item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const create = await request(app)
|
||||
.post('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Tokyo' });
|
||||
const id = create.body.item.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/addons/atlas/bucket-list/${id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ATLAS-008 — DELETE non-existent item returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/atlas/bucket-list/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
480
server/tests/integration/auth.test.ts
Normal file
480
server/tests/integration/auth.test.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Authentication integration tests.
|
||||
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-030.
|
||||
* OIDC scenarios (AUTH-023 to AUTH-027) require a real IdP and are excluded.
|
||||
* Rate limiting scenarios (AUTH-004, AUTH-018) are at the end of this file.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken } from '../helpers/factories';
|
||||
import { authCookie, authHeader } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// Reset rate limiter state between tests so they don't interfere
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Login
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Login', () => {
|
||||
it('AUTH-001 — successful login returns 200, user object, and trek_session cookie', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const res = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.email).toBe(user.email);
|
||||
expect(res.body.user.password_hash).toBeUndefined();
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: [res.headers['set-cookie']];
|
||||
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-002 — wrong password returns 401 with generic message', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app).post('/api/auth/login').send({ email: user.email, password: 'WrongPass1!' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toContain('Invalid email or password');
|
||||
});
|
||||
|
||||
it('AUTH-003 — non-existent email returns 401 with same generic message (no user enumeration)', async () => {
|
||||
const res = await request(app).post('/api/auth/login').send({ email: 'nobody@example.com', password: 'SomePass1!' });
|
||||
expect(res.status).toBe(401);
|
||||
// Must be same message as wrong-password to avoid email enumeration
|
||||
expect(res.body.error).toContain('Invalid email or password');
|
||||
});
|
||||
|
||||
it('AUTH-013 — POST /api/auth/logout clears session cookie', async () => {
|
||||
const res = await request(app).post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: (res.headers['set-cookie'] ? [res.headers['set-cookie']] : []);
|
||||
const sessionCookie = cookies.find((c: string) => c.includes('trek_session'));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).toMatch(/expires=Thu, 01 Jan 1970|Max-Age=0/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Registration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Registration', () => {
|
||||
it('AUTH-005 — first user registration creates admin role and returns 201 + cookie', async () => {
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
username: 'firstadmin',
|
||||
email: 'admin@example.com',
|
||||
password: 'Str0ng!Pass',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.user.role).toBe('admin');
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: [res.headers['set-cookie']];
|
||||
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-006 — registration with weak password is rejected', async () => {
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
username: 'weakpwduser',
|
||||
email: 'weak@example.com',
|
||||
password: 'short',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('AUTH-007 — registration with common password is rejected', async () => {
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
username: 'commonpwd',
|
||||
email: 'common@example.com',
|
||||
password: 'Password1', // 'password1' is in the COMMON_PASSWORDS set
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/common/i);
|
||||
});
|
||||
|
||||
it('AUTH-008 — registration with duplicate email returns 409', async () => {
|
||||
createUser(testDb, { email: 'taken@example.com' });
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
username: 'newuser',
|
||||
email: 'taken@example.com',
|
||||
password: 'Str0ng!Pass',
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('AUTH-009 — registration disabled by admin returns 403', async () => {
|
||||
createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
username: 'blocked',
|
||||
email: 'blocked@example.com',
|
||||
password: 'Str0ng!Pass',
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
it('AUTH-010 — registration with valid invite token succeeds even when registration disabled', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
||||
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
username: 'invited',
|
||||
email: 'invited@example.com',
|
||||
password: 'Str0ng!Pass',
|
||||
invite_token: invite.token,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number };
|
||||
expect(row.used_count).toBe(1);
|
||||
});
|
||||
|
||||
it('AUTH-011 — GET /api/auth/invite/:token with expired token returns 410', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
|
||||
const invite = createInviteToken(testDb, { expires_at: yesterday, created_by: admin.id });
|
||||
|
||||
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.error).toMatch(/expired/i);
|
||||
});
|
||||
|
||||
it('AUTH-012 — GET /api/auth/invite/:token with exhausted token returns 410', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
||||
// Mark as exhausted
|
||||
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
||||
|
||||
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.error).toMatch(/fully used/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Session / Me
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Session', () => {
|
||||
it('AUTH-014 — GET /api/auth/me without session returns 401 AUTH_REQUIRED', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('AUTH-014 — GET /api/auth/me with valid cookie returns safe user object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.id).toBe(user.id);
|
||||
expect(res.body.user.email).toBe(user.email);
|
||||
expect(res.body.user.password_hash).toBeUndefined();
|
||||
expect(res.body.user.mfa_secret).toBeUndefined();
|
||||
});
|
||||
|
||||
it('AUTH-021 — user with must_change_password=1 sees the flag in their profile', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.must_change_password).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// App Config (AUTH-028)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('App config', () => {
|
||||
it('AUTH-028 — GET /api/auth/app-config returns expected flags', async () => {
|
||||
const res = await request(app).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('allow_registration');
|
||||
expect(res.body).toHaveProperty('oidc_configured');
|
||||
expect(res.body).toHaveProperty('demo_mode');
|
||||
expect(res.body).toHaveProperty('has_users');
|
||||
expect(res.body).toHaveProperty('setup_complete');
|
||||
});
|
||||
|
||||
it('AUTH-028 — allow_registration is false after admin disables it', async () => {
|
||||
createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
const res = await request(app).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.allow_registration).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Demo Login (AUTH-022)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Demo login', () => {
|
||||
it('AUTH-022 — POST /api/auth/demo-login without DEMO_MODE returns 404', async () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
const res = await request(app).post('/api/auth/demo-login');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('AUTH-022 — POST /api/auth/demo-login with DEMO_MODE and demo user returns 200 + cookie', async () => {
|
||||
testDb.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@trek.app', 'x', 'user')"
|
||||
).run();
|
||||
process.env.DEMO_MODE = 'true';
|
||||
try {
|
||||
const res = await request(app).post('/api/auth/demo-login');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.email).toBe('demo@trek.app');
|
||||
} finally {
|
||||
delete process.env.DEMO_MODE;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MFA (AUTH-015 to AUTH-019)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MFA', () => {
|
||||
it('AUTH-015 — POST /api/auth/mfa/setup returns secret and QR data URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/setup')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toBeDefined();
|
||||
expect(res.body.otpauth_url).toContain('otpauth://');
|
||||
expect(res.body.qr_data_url).toMatch(/^data:image/);
|
||||
});
|
||||
|
||||
it('AUTH-015 — POST /api/auth/mfa/enable with valid TOTP code enables MFA', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const setupRes = await request(app)
|
||||
.post('/api/auth/mfa/setup')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(setupRes.status).toBe(200);
|
||||
|
||||
const enableRes = await request(app)
|
||||
.post('/api/auth/mfa/enable')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ code: authenticator.generate(setupRes.body.secret) });
|
||||
expect(enableRes.status).toBe(200);
|
||||
expect(enableRes.body.mfa_enabled).toBe(true);
|
||||
expect(Array.isArray(enableRes.body.backup_codes)).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-016 — login with MFA-enabled account returns mfa_required + mfa_token', async () => {
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(loginRes.status).toBe(200);
|
||||
expect(loginRes.body.mfa_required).toBe(true);
|
||||
expect(typeof loginRes.body.mfa_token).toBe('string');
|
||||
});
|
||||
|
||||
it('AUTH-016 — POST /api/auth/mfa/verify-login with valid code completes login', async () => {
|
||||
const { user, password, totpSecret } = createUserWithMfa(testDb);
|
||||
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const { mfa_token } = loginRes.body;
|
||||
|
||||
const verifyRes = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token, code: authenticator.generate(totpSecret) });
|
||||
expect(verifyRes.status).toBe(200);
|
||||
expect(verifyRes.body.user).toBeDefined();
|
||||
const cookies: string[] = Array.isArray(verifyRes.headers['set-cookie'])
|
||||
? verifyRes.headers['set-cookie']
|
||||
: [verifyRes.headers['set-cookie']];
|
||||
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-017 — verify-login with invalid TOTP code returns 401', async () => {
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
|
||||
const verifyRes = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token: loginRes.body.mfa_token, code: '000000' });
|
||||
expect(verifyRes.status).toBe(401);
|
||||
expect(verifyRes.body.error).toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
it('AUTH-019 — disable MFA with valid password and TOTP code', async () => {
|
||||
const { user, password, totpSecret } = createUserWithMfa(testDb);
|
||||
|
||||
const disableRes = await request(app)
|
||||
.post('/api/auth/mfa/disable')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ password, code: authenticator.generate(totpSecret) });
|
||||
expect(disableRes.status).toBe(200);
|
||||
expect(disableRes.body.mfa_enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Forced MFA Policy (AUTH-020)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Forced MFA policy', () => {
|
||||
it('AUTH-020 — non-MFA user is blocked (403 MFA_REQUIRED) when require_mfa is true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
|
||||
// mfaPolicy checks Authorization: Bearer header
|
||||
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('MFA_REQUIRED');
|
||||
});
|
||||
|
||||
it('AUTH-020 — /api/auth/me and MFA setup endpoints are exempt from require_mfa', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
|
||||
const meRes = await request(app).get('/api/auth/me').set(authHeader(user.id));
|
||||
expect(meRes.status).toBe(200);
|
||||
|
||||
const setupRes = await request(app).post('/api/auth/mfa/setup').set(authHeader(user.id));
|
||||
expect(setupRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('AUTH-020 — MFA-enabled user passes through require_mfa policy', async () => {
|
||||
const { user } = createUserWithMfa(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
|
||||
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Short-lived tokens (AUTH-029, AUTH-030)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Short-lived tokens', () => {
|
||||
it('AUTH-029 — POST /api/auth/ws-token returns a single-use token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/ws-token')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('AUTH-030 — POST /api/auth/resource-token returns a single-use token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/resource-token')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ purpose: 'download' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.token.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Rate limiting', () => {
|
||||
it('AUTH-004 — login endpoint rate-limits after 10 attempts from the same IP', async () => {
|
||||
// beforeEach has cleared loginAttempts; we fill up exactly to the limit
|
||||
let lastStatus = 0;
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'ratelimit@example.com', password: 'wrong' });
|
||||
lastStatus = res.status;
|
||||
if (lastStatus === 429) break;
|
||||
}
|
||||
expect(lastStatus).toBe(429);
|
||||
});
|
||||
|
||||
it('AUTH-018 — MFA verify-login endpoint rate-limits after 5 attempts', async () => {
|
||||
let lastStatus = 0;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token: 'badtoken', code: '000000' });
|
||||
lastStatus = res.status;
|
||||
if (lastStatus === 429) break;
|
||||
}
|
||||
expect(lastStatus).toBe(429);
|
||||
});
|
||||
});
|
||||
175
server/tests/integration/backup.test.ts
Normal file
175
server/tests/integration/backup.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Backup integration tests.
|
||||
* Covers BACKUP-001 to BACKUP-008.
|
||||
*
|
||||
* Note: createBackup() is async and creates real files.
|
||||
* These tests run in test env and may not have a full DB file to zip,
|
||||
* but the service should handle gracefully.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
|
||||
vi.mock('../../src/services/backupService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/services/backupService')>('../../src/services/backupService');
|
||||
return {
|
||||
...actual,
|
||||
createBackup: vi.fn().mockResolvedValue({
|
||||
filename: 'backup-2026-04-03T06-00-00.zip',
|
||||
size: 1024,
|
||||
sizeText: '1.0 KB',
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
updateAutoSettings: vi.fn().mockReturnValue({
|
||||
enabled: false,
|
||||
interval: 'daily',
|
||||
keep_days: 7,
|
||||
hour: 2,
|
||||
day_of_week: 0,
|
||||
day_of_month: 1,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createAdmin, createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Backup access control', () => {
|
||||
it('non-admin cannot access backup routes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/list')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup list', () => {
|
||||
it('BACKUP-001 — GET /backup/list returns backups array', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/list')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.backups)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup creation', () => {
|
||||
it('BACKUP-001 — POST /backup/create creates a backup', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.backup).toHaveProperty('filename');
|
||||
expect(res.body.backup).toHaveProperty('size');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-backup settings', () => {
|
||||
it('BACKUP-008 — GET /backup/auto-settings returns current config', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/auto-settings')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('settings');
|
||||
expect(res.body.settings).toHaveProperty('enabled');
|
||||
});
|
||||
|
||||
it('BACKUP-008 — PUT /backup/auto-settings updates settings', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/backup/auto-settings')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ enabled: false, interval: 'daily', keep_days: 7 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('settings');
|
||||
expect(res.body.settings).toHaveProperty('enabled');
|
||||
expect(res.body.settings).toHaveProperty('interval');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup security', () => {
|
||||
it('BACKUP-007 — Download with path traversal filename is rejected', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/download/../../etc/passwd')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
// Express normalises the URL before routing; path traversal gets resolved
|
||||
// to a path that matches no route → 404
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('BACKUP-007 — Delete with path traversal filename is rejected', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/backup/../../../etc/passwd')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
// Express normalises the URL, stripping traversal → no route match → 404
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
286
server/tests/integration/budget.test.ts
Normal file
286
server/tests/integration/budget.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Budget Planner integration tests.
|
||||
* Covers BUDGET-001 to BUDGET-010.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create budget item
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create budget item', () => {
|
||||
it('BUDGET-001 — POST creates budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Flights', category: 'Transport', total_price: 500, currency: 'EUR' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item.name).toBe('Flights');
|
||||
expect(res.body.item.total_price).toBe(500);
|
||||
});
|
||||
|
||||
it('BUDGET-001 — POST without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ category: 'Transport', total_price: 200 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('BUDGET-010 — non-member cannot create budget item', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({ name: 'Hotels', total_price: 300 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List budget items
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List budget items', () => {
|
||||
it('BUDGET-002 — GET /api/trips/:tripId/budget returns all items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 300 });
|
||||
createBudgetItem(testDb, trip.id, { name: 'Hotel', total_price: 500 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('BUDGET-002 — member can list budget items', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Rental', total_price: 200 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update budget item
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update budget item', () => {
|
||||
it('BUDGET-003 — PUT updates budget item fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Old Name', total_price: 100 });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'New Name', total_price: 250 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.name).toBe('New Name');
|
||||
expect(res.body.item.total_price).toBe(250);
|
||||
});
|
||||
|
||||
it('BUDGET-003 — PUT non-existent item returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Updated' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete budget item
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete budget item', () => {
|
||||
it('BUDGET-004 — DELETE removes item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/budget/${item.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Members
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Budget item members', () => {
|
||||
it('BUDGET-005 — PUT /members assigns members to budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.members).toBeDefined();
|
||||
});
|
||||
|
||||
it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: 'not-an-array' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('BUDGET-006 — PUT /members/:userId/paid toggles paid status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
// Assign user as member first
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.member).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Summary & Settlement
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Budget summary and settlement', () => {
|
||||
it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/summary/per-person`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('BUDGET-008 — GET /settlement returns settlement transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('balances');
|
||||
expect(res.body).toHaveProperty('flows');
|
||||
});
|
||||
|
||||
it('BUDGET-009 — settlement with no payers returns empty transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Item with no members/payers assigned
|
||||
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
640
server/tests/integration/collab.test.ts
Normal file
640
server/tests/integration/collab.test.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* Collab (notes, polls, messages, reactions) integration tests.
|
||||
* Covers COLLAB-001 to COLLAB-027.
|
||||
*
|
||||
* Note: File upload to collab notes (COLLAB-005/006/007) requires physical file I/O.
|
||||
* Link preview (COLLAB-025/026) would need fetch mocking — skipped here.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
// Ensure uploads/files dir exists for collab file uploads
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Collab Notes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Collab notes', () => {
|
||||
it('COLLAB-001 — POST /collab/notes creates a note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Packing Ideas', content: 'Bring sunscreen', category: 'Planning' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.note.title).toBe('Packing Ideas');
|
||||
expect(res.body.note.content).toBe('Bring sunscreen');
|
||||
});
|
||||
|
||||
it('COLLAB-001 — POST without title returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ content: 'No title' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('COLLAB-001 — non-member cannot create collab note', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({ title: 'Sneaky note' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('COLLAB-002 — GET /collab/notes returns all notes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Note A' });
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Note B' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('COLLAB-003 — PUT /collab/notes/:id updates a note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Old Title', content: 'Old content' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/collab/notes/${noteId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'New Title', content: 'New content', pinned: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.note.title).toBe('New Title');
|
||||
expect(res.body.note.pinned).toBe(1);
|
||||
});
|
||||
|
||||
it('COLLAB-003 — PUT non-existent note returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/collab/notes/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Updated' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('COLLAB-004 — DELETE /collab/notes/:id removes note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'To Delete' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('COLLAB-005 — POST /collab/notes/:id/files uploads a file to a note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Note with file' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
expect(upload.status).toBe(201);
|
||||
expect(upload.body.file).toBeDefined();
|
||||
});
|
||||
|
||||
it('COLLAB-006 — uploading blocked extension to note is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Note' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
// Create a temp .svg file
|
||||
const svgPath = path.join(uploadsDir, 'collab_blocked.svg');
|
||||
fs.writeFileSync(svgPath, '<svg></svg>');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', svgPath);
|
||||
expect(res.status).toBe(400);
|
||||
} finally {
|
||||
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('COLLAB-007 — DELETE /collab/notes/:noteId/files/:fileId removes file from note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Note with file' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('COLLAB-028 — uploaded note file URL uses authenticated download path, not /uploads/', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'URL check' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
expect(upload.status).toBe(201);
|
||||
|
||||
const fileUrl = upload.body.file.url;
|
||||
expect(fileUrl).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/);
|
||||
expect(fileUrl).not.toContain('/uploads/');
|
||||
});
|
||||
|
||||
it('COLLAB-029 — note attachments in listing use authenticated download URLs', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'List URL check' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.status).toBe(200);
|
||||
|
||||
const note = list.body.notes.find((n: any) => n.id === noteId);
|
||||
expect(note.attachments.length).toBe(1);
|
||||
expect(note.attachments[0].url).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/);
|
||||
expect(note.attachments[0].url).not.toContain('/uploads/');
|
||||
});
|
||||
|
||||
it('COLLAB-030 — note file is downloadable via files endpoint with ephemeral token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Downloadable note' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
const fileUrl = upload.body.file.url;
|
||||
|
||||
// Obtain an ephemeral resource token (same flow as getAuthUrl on the client)
|
||||
const tokenRes = await request(app)
|
||||
.post('/api/auth/resource-token')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ purpose: 'download' });
|
||||
expect(tokenRes.status).toBe(200);
|
||||
const { token } = tokenRes.body;
|
||||
|
||||
// Download with ?token= should succeed
|
||||
const dl = await request(app).get(`${fileUrl}?token=${token}`);
|
||||
expect(dl.status).toBe(200);
|
||||
});
|
||||
|
||||
it('COLLAB-031 — note file download without auth returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Auth required note' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
const fileUrl = upload.body.file.url;
|
||||
|
||||
// Download without any auth should fail
|
||||
const dl = await request(app).get(fileUrl);
|
||||
expect(dl.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Polls
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Polls', () => {
|
||||
it('COLLAB-008 — POST /collab/polls creates a poll', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Where to eat?', options: ['Pizza', 'Sushi', 'Tacos'] });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.poll.question).toBe('Where to eat?');
|
||||
expect(res.body.poll.options).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('COLLAB-008 — POST without question returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ options: ['A', 'B'] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('COLLAB-009 — GET /collab/polls returns polls', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Beach or mountains?', options: ['Beach', 'Mountains'] });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.polls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('COLLAB-010 — POST /collab/polls/:id/vote casts a vote', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Restaurant?', options: ['Italian', 'French'] });
|
||||
const pollId = create.body.poll.id;
|
||||
|
||||
const vote = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ option_index: 0 });
|
||||
expect(vote.status).toBe(200);
|
||||
expect(vote.body.poll).toBeDefined();
|
||||
});
|
||||
|
||||
it('COLLAB-011 — PUT /collab/polls/:id/close closes a poll', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Hotel?', options: ['Budget', 'Luxury'] });
|
||||
const pollId = create.body.poll.id;
|
||||
|
||||
const close = await request(app)
|
||||
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(close.status).toBe(200);
|
||||
expect(close.body.poll.is_closed).toBe(true);
|
||||
});
|
||||
|
||||
it('COLLAB-012 — cannot vote on closed poll', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Closed?', options: ['Yes', 'No'] });
|
||||
const pollId = create.body.poll.id;
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const vote = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ option_index: 0 });
|
||||
expect(vote.status).toBe(400);
|
||||
});
|
||||
|
||||
it('COLLAB-013 — DELETE /collab/polls/:id removes poll', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Delete me?', options: ['Yes', 'No'] });
|
||||
const pollId = create.body.poll.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/collab/polls/${pollId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Messages
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Messages', () => {
|
||||
it('COLLAB-014 — POST /collab/messages sends a message', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Hello, team!' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.message.text).toBe('Hello, team!');
|
||||
});
|
||||
|
||||
it('COLLAB-014 — POST without text returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: '' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('COLLAB-014 — non-member cannot send message', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({ text: 'Unauthorized' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('COLLAB-015 — GET /collab/messages returns messages in order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'First message' });
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Second message' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.messages.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('COLLAB-016 — POST /collab/messages with reply_to links reply', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const parent = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Original' });
|
||||
const parentId = parent.body.message.id;
|
||||
|
||||
const reply = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Reply here', reply_to: parentId });
|
||||
expect(reply.status).toBe(201);
|
||||
expect(reply.body.message.reply_to).toBe(parentId);
|
||||
});
|
||||
|
||||
it('COLLAB-017 — DELETE /collab/messages/:id removes own message', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const msg = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Delete me' });
|
||||
const msgId = msg.body.message.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('COLLAB-017 — cannot delete another user\'s message', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const msg = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ text: 'Owner message' });
|
||||
const msgId = msg.body.message.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(del.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reactions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Message reactions', () => {
|
||||
it('COLLAB-018 — POST /collab/messages/:id/react adds a reaction', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const msg = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'React to me' });
|
||||
const msgId = msg.body.message.id;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ emoji: '👍' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reactions).toBeDefined();
|
||||
});
|
||||
|
||||
it('COLLAB-018 — POST react without emoji returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const msg = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Test' });
|
||||
const msgId = msg.body.message.id;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Long text validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Collab validation', () => {
|
||||
it('COLLAB-018 — message text exceeding 5000 chars is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'A'.repeat(5001) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
235
server/tests/integration/dayNotes.test.ts
Normal file
235
server/tests/integration/dayNotes.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Day Notes integration tests.
|
||||
* Covers NOTE-001 to NOTE-006.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create day note
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create day note', () => {
|
||||
it('NOTE-001 — POST creates a day note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Remember to book tickets', time: '09:00' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.note.text).toBe('Remember to book tickets');
|
||||
expect(res.body.note.time).toBe('09:00');
|
||||
});
|
||||
|
||||
it('NOTE-001 — POST without text returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ time: '10:00' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NOTE-002 — text exceeding 500 characters is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'A'.repeat(501) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NOTE-001 — POST on non-existent day returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/99999/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'This should fail' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List day notes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List day notes', () => {
|
||||
it('NOTE-003 — GET returns notes for a day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Note A' });
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Note B' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('NOTE-006 — non-member cannot list notes', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update day note
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update day note', () => {
|
||||
it('NOTE-004 — PUT updates a note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Old text' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/days/${day.id}/notes/${noteId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'New text', icon: '🎯' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.note.text).toBe('New text');
|
||||
expect(res.body.note.icon).toBe('🎯');
|
||||
});
|
||||
|
||||
it('NOTE-004 — PUT on non-existent note returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/days/${day.id}/notes/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Updated' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete day note
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete day note', () => {
|
||||
it('NOTE-005 — DELETE removes note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'To delete' });
|
||||
const noteId = create.body.note.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}/notes/${noteId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('NOTE-005 — DELETE non-existent note returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}/notes/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
465
server/tests/integration/days.test.ts
Normal file
465
server/tests/integration/days.test.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Days & Accommodations API integration tests.
|
||||
* Covers DAY-001 through DAY-006 and ACCOM-001 through ACCOM-003.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-memory DB — schema applied in beforeAll after mocks register
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); });
|
||||
afterAll(() => { testDb.close(); });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List days (DAY-001, DAY-002)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List days', () => {
|
||||
it('DAY-001 — GET /api/trips/:tripId/days returns days for a trip the user can access', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toBeDefined();
|
||||
expect(Array.isArray(res.body.days)).toBe(true);
|
||||
expect(res.body.days).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('DAY-001 — Member can list days for a shared trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip', start_date: '2026-07-01', end_date: '2026-07-02' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('DAY-002 — Non-member cannot list days (404)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DAY-002 — Unauthenticated request returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/days`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create day (DAY-006)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create day', () => {
|
||||
it('DAY-006 — POST /api/trips/:tripId/days creates a standalone day with no date', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Open Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'A free day' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.day).toBeDefined();
|
||||
expect(res.body.day.trip_id).toBe(trip.id);
|
||||
expect(res.body.day.date).toBeNull();
|
||||
expect(res.body.day.notes).toBe('A free day');
|
||||
});
|
||||
|
||||
it('DAY-006 — POST /api/trips/:tripId/days creates a day with a date', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Dated Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ date: '2026-08-15' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.day.date).toBe('2026-08-15');
|
||||
});
|
||||
|
||||
it('DAY-006 — Non-member cannot create a day (404)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({ notes: 'Infiltration' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update day (DAY-003, DAY-004)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update day', () => {
|
||||
it('DAY-003 — PUT /api/trips/:tripId/days/:dayId updates the day title', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
|
||||
const day = createDay(testDb, trip.id, { title: 'Old Title' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'New Title' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.day).toBeDefined();
|
||||
expect(res.body.day.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('DAY-004 — PUT /api/trips/:tripId/days/:dayId updates the day notes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'Visit the Louvre' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.day.notes).toBe('Visit the Louvre');
|
||||
});
|
||||
|
||||
it('DAY-003 — PUT returns 404 for a day that does not belong to the trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
|
||||
createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/days/999999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Ghost' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('DAY-003 — Non-member cannot update a day (404)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private' });
|
||||
const day = createDay(testDb, trip.id, { title: 'Original' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({ title: 'Hacked' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reorder days (DAY-005)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Reorder days', () => {
|
||||
it('DAY-005 — Reorder: GET days returns them in day_number order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Create trip with 3 days auto-generated
|
||||
const trip = createTrip(testDb, user.id, {
|
||||
title: 'Trip',
|
||||
start_date: '2026-09-01',
|
||||
end_date: '2026-09-03',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(3);
|
||||
// Days should be ordered by day_number ascending (the service sorts by day_number ASC)
|
||||
expect(res.body.days[0].date).toBe('2026-09-01');
|
||||
expect(res.body.days[2].date).toBe('2026-09-03');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete day
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete day', () => {
|
||||
it('DELETE /api/trips/:tripId/days/:dayId removes the day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const deleted = testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id);
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('DELETE /api/trips/:tripId/days/:dayId returns 404 for unknown day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/999999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Accommodations (ACCOM-001, ACCOM-002, ACCOM-003)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Accommodations', () => {
|
||||
it('ACCOM-001 — POST /api/trips/:tripId/accommodations creates an accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-10-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-10-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
place_id: place.id,
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
check_in: '15:00',
|
||||
check_out: '11:00',
|
||||
confirmation: 'ABC123',
|
||||
notes: 'Breakfast included',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.accommodation).toBeDefined();
|
||||
expect(res.body.accommodation.place_id).toBe(place.id);
|
||||
expect(res.body.accommodation.start_day_id).toBe(day1.id);
|
||||
expect(res.body.accommodation.end_day_id).toBe(day2.id);
|
||||
expect(res.body.accommodation.confirmation).toBe('ABC123');
|
||||
});
|
||||
|
||||
it('ACCOM-001 — POST missing required fields returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'no ids' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('ACCOM-001 — POST with invalid place_id returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: 999999, start_day_id: day.id, end_day_id: day.id });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ACCOM-002 — GET /api/trips/:tripId/accommodations returns accommodations for the trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-11-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-11-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' });
|
||||
|
||||
// Seed accommodation directly
|
||||
testDb.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)'
|
||||
).run(trip.id, place.id, day1.id, day2.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.accommodations).toBeDefined();
|
||||
expect(Array.isArray(res.body.accommodations)).toBe(true);
|
||||
expect(res.body.accommodations).toHaveLength(1);
|
||||
expect(res.body.accommodations[0].place_name).toBe('Boutique Inn');
|
||||
});
|
||||
|
||||
it('ACCOM-002 — Non-member cannot get accommodations (404)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ACCOM-003 — DELETE /api/trips/:tripId/accommodations/:id removes accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-12-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-12-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Budget Hostel' });
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
const accommodationId = createRes.body.accommodation.id;
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteRes.body.success).toBe(true);
|
||||
|
||||
// Verify removed from DB
|
||||
const row = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodationId);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/accommodations/999999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('ACCOM-001 — Creating accommodation also creates a linked reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-10-10' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-10-12' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Luxury Resort' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id, confirmation: 'CONF-XYZ' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
// Linked reservation should exist
|
||||
const reservation = testDb.prepare(
|
||||
'SELECT * FROM reservations WHERE accommodation_id = ?'
|
||||
).get(res.body.accommodation.id) as any;
|
||||
expect(reservation).toBeDefined();
|
||||
expect(reservation.type).toBe('hotel');
|
||||
expect(reservation.confirmation_number).toBe('CONF-XYZ');
|
||||
});
|
||||
|
||||
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-10-15' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-10-17' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Mountain Lodge' });
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
|
||||
|
||||
const accommodationId = createRes.body.accommodation.id;
|
||||
const reservationBefore = testDb.prepare(
|
||||
'SELECT id FROM reservations WHERE accommodation_id = ?'
|
||||
).get(accommodationId) as any;
|
||||
expect(reservationBefore).toBeDefined();
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
const reservationAfter = testDb.prepare(
|
||||
'SELECT id FROM reservations WHERE id = ?'
|
||||
).get(reservationBefore.id);
|
||||
expect(reservationAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
382
server/tests/integration/files.test.ts
Normal file
382
server/tests/integration/files.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Trip Files integration tests.
|
||||
* Covers FILE-001 to FILE-021.
|
||||
*
|
||||
* Notes:
|
||||
* - Tests use fixture files from tests/fixtures/
|
||||
* - File uploads create real files in uploads/files/ — tests clean up after themselves where possible
|
||||
* - FILE-009 (ephemeral token download) is covered via the /api/auth/resource-token endpoint
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
|
||||
// Ensure uploads/files dir exists
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
// Seed allowed_file_types to include common types (wildcard)
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
// Re-seed allowed_file_types after reset
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// Helper to upload a file and return the file object
|
||||
async function uploadFile(tripId: number, userId: number, fixturePath = FIXTURE_PDF) {
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${tripId}/files`)
|
||||
.set('Cookie', authCookie(userId))
|
||||
.attach('file', fixturePath);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Upload file
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Upload file', () => {
|
||||
it('FILE-001 — POST uploads a file and returns file metadata', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.file).toBeDefined();
|
||||
expect(res.body.file.id).toBeDefined();
|
||||
expect(res.body.file.filename).toBeDefined();
|
||||
});
|
||||
|
||||
it('FILE-002 — uploading a blocked extension (.svg) is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Create a temp .svg file
|
||||
const svgPath = path.join(uploadsDir, 'test_blocked.svg');
|
||||
fs.writeFileSync(svgPath, '<svg></svg>');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', svgPath);
|
||||
expect(res.status).toBe(400);
|
||||
} finally {
|
||||
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('FILE-021 — non-member cannot upload file', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List files
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List files', () => {
|
||||
it('FILE-006 — GET returns all non-trashed files', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
await uploadFile(trip.id, user.id, FIXTURE_IMG);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.files.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('FILE-007 — GET ?trash=true returns only trashed files', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
// Soft-delete it
|
||||
await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const trash = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files?trash=true`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(trash.status).toBe(200);
|
||||
const trashIds = (trash.body.files as any[]).map((f: any) => f.id);
|
||||
expect(trashIds).toContain(fileId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Star / unstar
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Star/unstar file', () => {
|
||||
it('FILE-011 — PATCH /:id/star toggles starred status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.file.starred).toBe(1);
|
||||
|
||||
// Toggle back
|
||||
const res2 = await request(app)
|
||||
.patch(`/api/trips/${trip.id}/files/${fileId}/star`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res2.body.file.starred).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Soft delete / restore / permanent delete
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Soft delete, restore, permanent delete', () => {
|
||||
it('FILE-012 — DELETE moves file to trash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
// Should not appear in normal list
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const ids = (list.body.files as any[]).map((f: any) => f.id);
|
||||
expect(ids).not.toContain(fileId);
|
||||
});
|
||||
|
||||
it('FILE-013 — POST /:id/restore restores from trash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const restore = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files/${fileId}/restore`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(restore.status).toBe(200);
|
||||
expect(restore.body.file.id).toBe(fileId);
|
||||
});
|
||||
|
||||
it('FILE-014 — DELETE /:id/permanent permanently deletes from trash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const perm = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(perm.status).toBe(200);
|
||||
expect(perm.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('FILE-015 — DELETE /:id/permanent on non-trashed file returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
// Not trashed — should 404
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('FILE-016 — DELETE /trash/empty empties all trash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const f1 = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const f2 = await uploadFile(trip.id, user.id, FIXTURE_IMG);
|
||||
|
||||
await request(app).delete(`/api/trips/${trip.id}/files/${f1.body.file.id}`).set('Cookie', authCookie(user.id));
|
||||
await request(app).delete(`/api/trips/${trip.id}/files/${f2.body.file.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
const empty = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/trash/empty`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(empty.status).toBe(200);
|
||||
|
||||
const trash = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files?trash=true`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(trash.body.files).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update file metadata
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update file metadata', () => {
|
||||
it('FILE-017 — PUT updates description', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ description: 'My important document' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.file.description).toBe('My important document');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// File links
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('File links', () => {
|
||||
it('FILE-018/019/020 — link file to reservation, list links, unlink', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const resv = createReservation(testDb, trip.id, { title: 'My Flight', type: 'flight' });
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
// Link (POST /:id/link)
|
||||
const link = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files/${fileId}/link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ reservation_id: resv.id });
|
||||
expect(link.status).toBe(200);
|
||||
expect(link.body.success).toBe(true);
|
||||
|
||||
// List links (GET /:id/links)
|
||||
const links = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/links`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(links.status).toBe(200);
|
||||
expect(links.body.links.some((l: any) => l.reservation_id === resv.id)).toBe(true);
|
||||
|
||||
// Unlink (DELETE /:id/link/:linkId — use the link id from the list)
|
||||
const linkId = links.body.links.find((l: any) => l.reservation_id === resv.id)?.id;
|
||||
expect(linkId).toBeDefined();
|
||||
const unlink = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}/link/${linkId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(unlink.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Download
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('File download', () => {
|
||||
it('FILE-010 — GET /:id/download without auth returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('FILE-008 — GET /:id/download with Bearer JWT downloads or 404s (no physical file in tests)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
// authenticateDownload accepts a signed JWT as Bearer token
|
||||
const token = generateToken(user.id);
|
||||
|
||||
const dl = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
// multer stores the file to disk during uploadFile — physical file exists
|
||||
expect(dl.status).toBe(200);
|
||||
});
|
||||
});
|
||||
122
server/tests/integration/health.test.ts
Normal file
122
server/tests/integration/health.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Basic smoke test to validate the integration test DB mock pattern.
|
||||
* Tests MISC-001 — Health check endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists
|
||||
// before the mock factory below runs. Schema setup happens in beforeAll
|
||||
// (after mocks are registered, so config is mocked when migrations run).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
// Schema setup runs here — config is mocked so migrations work correctly
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Health check', () => {
|
||||
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic auth', () => {
|
||||
it('AUTH-014 — GET /api/auth/me without session returns 401', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toMatchObject({ id: user.id, email: user.email });
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: [res.headers['set-cookie']];
|
||||
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.id).toBe(user.id);
|
||||
expect(res.body.user.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
147
server/tests/integration/immich.test.ts
Normal file
147
server/tests/integration/immich.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Immich integration tests.
|
||||
* Covers IMMICH-001 to IMMICH-015 (settings, SSRF protection, connection test).
|
||||
*
|
||||
* External Immich API calls are not made — tests focus on settings persistence
|
||||
* and input validation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS.
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||||
return {
|
||||
...actual,
|
||||
checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const h = url.hostname;
|
||||
if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
|
||||
return { allowed: false, isPrivate: true, error: 'Requests to loopback addresses are not allowed' };
|
||||
}
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
|
||||
return { allowed: false, isPrivate: true, error: 'Requests to private network addresses are not allowed' };
|
||||
}
|
||||
return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Immich settings', () => {
|
||||
it('IMMICH-001 — GET /api/immich/settings returns current settings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/integrations/immich/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
// Settings may be empty initially
|
||||
expect(res.body).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-001 — PUT /api/immich/settings saves Immich URL and API key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'test-api-key' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('IMMICH-002 — PUT /api/immich/settings with private IP is blocked by SSRF guard', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'http://192.168.1.100', immich_api_key: 'test-key' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('IMMICH-002 — PUT /api/immich/settings with loopback is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'http://127.0.0.1:2283', immich_api_key: 'test-key' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Immich authentication', () => {
|
||||
it('GET /api/immich/settings without auth returns 401', async () => {
|
||||
const res = await request(app).get('/api/integrations/immich/settings');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('PUT /api/immich/settings without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.send({ url: 'https://example.com', api_key: 'key' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
135
server/tests/integration/maps.test.ts
Normal file
135
server/tests/integration/maps.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Maps integration tests.
|
||||
* Covers MAPS-001 to MAPS-008.
|
||||
*
|
||||
* External API calls (Nominatim, Google Places, Wikipedia) are tested at the
|
||||
* input validation level. Full integration tests would require live external APIs.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Maps authentication', () => {
|
||||
it('POST /maps/search without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.send({ query: 'Paris' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /maps/reverse without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8566&lng=2.3522');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maps validation', () => {
|
||||
it('MAPS-001 — POST /maps/search without query returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('MAPS-006 — GET /maps/reverse without lat/lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('MAPS-007 — POST /maps/resolve-url without url returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/resolve-url')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maps SSRF protection', () => {
|
||||
it('MAPS-007 — POST /maps/resolve-url with internal IP is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// SSRF: should be blocked by ssrfGuard
|
||||
const res = await request(app)
|
||||
.post('/api/maps/resolve-url')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'http://192.168.1.1/admin' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('MAPS-007 — POST /maps/resolve-url with loopback IP is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/resolve-url')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'http://127.0.0.1/secret' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
132
server/tests/integration/mcp.test.ts
Normal file
132
server/tests/integration/mcp.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* MCP integration tests.
|
||||
* Covers MCP-001 to MCP-013.
|
||||
*
|
||||
* The MCP endpoint uses JWT auth and server-sent events / streaming HTTP.
|
||||
* Tests focus on authentication and basic rejection behavior.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('MCP authentication', () => {
|
||||
// MCP handler checks if the 'mcp' addon is enabled first (403 if not),
|
||||
// then checks auth (401). In test DB the addon may be disabled.
|
||||
|
||||
it('MCP-001 — POST /mcp without auth returns 403 (addon disabled before auth check)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
// MCP handler checks addon enabled before verifying auth; addon is disabled in test DB
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('MCP-001 — GET /mcp without auth returns 403 (addon disabled)', async () => {
|
||||
const res = await request(app).get('/mcp');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('MCP-001 — DELETE /mcp without auth returns 403 (addon disabled)', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/mcp')
|
||||
.set('Mcp-Session-Id', 'fake-session-id');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP session init', () => {
|
||||
it('MCP-002 — POST /mcp with valid JWT passes auth check (may fail if addon disabled)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Enable MCP addon in test DB
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
// Valid JWT + enabled addon → auth passes; SDK returns 200 with session headers
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('MCP-003 — DELETE /mcp with unknown session returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Mcp-Session-Id', 'nonexistent-session-id');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('MCP-004 — POST /mcp with invalid JWT returns 401 (when addon enabled)', async () => {
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', 'Bearer invalid.jwt.token')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
142
server/tests/integration/misc.test.ts
Normal file
142
server/tests/integration/misc.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Miscellaneous integration tests.
|
||||
* Covers MISC-001, 002, 004, 007, 008, 013, 015.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Health check', () => {
|
||||
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Addons list', () => {
|
||||
it('MISC-002 — GET /api/addons returns enabled addons', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.addons)).toBe(true);
|
||||
// Should only return enabled addons
|
||||
const enabled = (res.body.addons as any[]).filter((a: any) => !a.enabled);
|
||||
expect(enabled.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Photo endpoint auth', () => {
|
||||
it('MISC-007 — GET /uploads/files without auth is blocked (401)', async () => {
|
||||
// /uploads/files is blocked without auth; /uploads/avatars and /uploads/covers are public static
|
||||
const res = await request(app).get('/uploads/files/nonexistent.txt');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Force HTTPS redirect', () => {
|
||||
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests', async () => {
|
||||
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
try {
|
||||
httpsApp = createApp();
|
||||
} finally {
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
});
|
||||
|
||||
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
|
||||
delete process.env.FORCE_HTTPS;
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Categories endpoint', () => {
|
||||
it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App config', () => {
|
||||
it('MISC-015 — GET /api/auth/app-config returns configuration', async () => {
|
||||
const res = await request(app).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('allow_registration');
|
||||
expect(res.body).toHaveProperty('oidc_configured');
|
||||
});
|
||||
});
|
||||
177
server/tests/integration/notifications.test.ts
Normal file
177
server/tests/integration/notifications.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Notifications integration tests.
|
||||
* Covers NOTIF-001 to NOTIF-014.
|
||||
*
|
||||
* External SMTP / webhook calls are not made — tests focus on preferences,
|
||||
* in-app notification CRUD, and authentication.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Notification preferences', () => {
|
||||
it('NOTIF-001 — GET /api/notifications/preferences returns defaults', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('preferences');
|
||||
});
|
||||
|
||||
it('NOTIF-001 — PUT /api/notifications/preferences updates settings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notify_trip_invite: true, notify_booking_change: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('preferences');
|
||||
});
|
||||
|
||||
it('NOTIF — GET preferences without auth returns 401', async () => {
|
||||
const res = await request(app).get('/api/notifications/preferences');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('In-app notifications', () => {
|
||||
it('NOTIF-008 — GET /api/notifications/in-app returns notifications array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.notifications)).toBe(true);
|
||||
});
|
||||
|
||||
it('NOTIF-008 — GET /api/notifications/in-app/unread-count returns count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('count');
|
||||
expect(typeof res.body.count).toBe('number');
|
||||
});
|
||||
|
||||
it('NOTIF-009 — PUT /api/notifications/in-app/read-all marks all read', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/notifications/in-app/read-all')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('NOTIF-010 — DELETE /api/notifications/in-app/all deletes all notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/notifications/in-app/all')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('NOTIF-011 — PUT /api/notifications/in-app/:id/read on non-existent returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/notifications/in-app/99999/read')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('NOTIF-012 — DELETE /api/notifications/in-app/:id on non-existent returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/notifications/in-app/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification test endpoints', () => {
|
||||
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-smtp')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
// Non-admin gets 403
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
362
server/tests/integration/packing.test.ts
Normal file
362
server/tests/integration/packing.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Packing List integration tests.
|
||||
* Covers PACK-001 to PACK-014.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create packing item
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create packing item', () => {
|
||||
it('PACK-001 — POST creates a packing item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Passport', category: 'Documents' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item.name).toBe('Passport');
|
||||
expect(res.body.item.category).toBe('Documents');
|
||||
expect(res.body.item.checked).toBe(0);
|
||||
});
|
||||
|
||||
it('PACK-001 — POST without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ category: 'Clothing' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PACK-014 — non-member cannot create packing item', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({ name: 'Sunscreen' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List packing items
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List packing items', () => {
|
||||
it('PACK-002 — GET /api/trips/:tripId/packing returns all items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
createPackingItem(testDb, trip.id, { name: 'Shirt', category: 'Clothing' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PACK-002 — member can list packing items', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Jacket' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update packing item
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update packing item', () => {
|
||||
it('PACK-003 — PUT updates packing item (toggle checked)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id, { name: 'Camera' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/${item.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ checked: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.checked).toBe(1);
|
||||
});
|
||||
|
||||
it('PACK-003 — PUT returns 404 for non-existent item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Updated' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete packing item
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete packing item', () => {
|
||||
it('PACK-004 — DELETE removes packing item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id, { name: 'Sunglasses' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/packing/${item.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bulk import
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Bulk import packing items', () => {
|
||||
it('PACK-005 — POST /import creates multiple items at once', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/import`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
items: [
|
||||
{ name: 'Toothbrush', category: 'Toiletries' },
|
||||
{ name: 'Shampoo', category: 'Toiletries' },
|
||||
{ name: 'Socks', category: 'Clothing' },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.items).toHaveLength(3);
|
||||
expect(res.body.count).toBe(3);
|
||||
});
|
||||
|
||||
it('PACK-005 — POST /import with empty array returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/import`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ items: [] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reorder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Reorder packing items', () => {
|
||||
it('PACK-006 — PUT /reorder reorders items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const i1 = createPackingItem(testDb, trip.id, { name: 'Item A' });
|
||||
const i2 = createPackingItem(testDb, trip.id, { name: 'Item B' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/reorder`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ orderedIds: [i2.id, i1.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bags
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Bags', () => {
|
||||
it('PACK-008 — POST /bags creates a bag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Carry-on', color: '#3b82f6' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.bag.name).toBe('Carry-on');
|
||||
});
|
||||
|
||||
it('PACK-008 — POST /bags without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ color: '#ff0000' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PACK-011 — GET /bags returns bags list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Create a bag
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Main Bag' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bags).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('PACK-009 — PUT /bags/:bagId updates bag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Old Name' });
|
||||
const bagId = createRes.body.bag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/bags/${bagId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'New Name' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bag.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('PACK-010 — DELETE /bags/:bagId removes bag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Temp Bag' });
|
||||
const bagId = createRes.body.bag.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/packing/bags/${bagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Category assignees
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Category assignees', () => {
|
||||
it('PACK-012 — PUT /category-assignees/:category sets assignees', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/category-assignees/Clothing`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignees).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-013 — GET /category-assignees returns all category assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Set an assignee first
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/category-assignees/Electronics`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing/category-assignees`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignees).toBeDefined();
|
||||
});
|
||||
});
|
||||
530
server/tests/integration/places.test.ts
Normal file
530
server/tests/integration/places.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Places API integration tests.
|
||||
* Covers PLACE-001 through PLACE-019.
|
||||
*
|
||||
* Notes:
|
||||
* - PLACE-008/009: place-to-day assignment is tested in assignments.test.ts
|
||||
* - PLACE-014: reordering within a day is tested in assignments.test.ts
|
||||
* - PLACE-019: GPX bulk import tested here using the test fixture
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create place
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create place', () => {
|
||||
it('PLACE-001 — POST /api/trips/:tripId/places creates place and returns 201', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.place.name).toBe('Eiffel Tower');
|
||||
expect(res.body.place.lat).toBe(48.8584);
|
||||
expect(res.body.place.trip_id).toBe(trip.id);
|
||||
});
|
||||
|
||||
it('PLACE-001 — POST without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ lat: 48.8584, lng: 2.2945 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PLACE-002 — name exceeding 200 characters is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'A'.repeat(201) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PLACE-007 — non-member cannot create a place', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({ name: 'Test Place' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PLACE-016 — create place with category assigns it correctly', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Louvre', category_id: cat.id });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.place.category).toBeDefined();
|
||||
expect(res.body.place.category.id).toBe(cat.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List places
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List places', () => {
|
||||
it('PLACE-003 — GET /api/trips/:tripId/places returns all places', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPlace(testDb, trip.id, { name: 'Place A' });
|
||||
createPlace(testDb, trip.id, { name: 'Place B' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-003 — member can list places for a shared trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
createPlace(testDb, trip.id, { name: 'Shared Place' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('PLACE-007 — non-member cannot list places', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PLACE-017 — GET /api/trips/:tripId/places?category=X filters by category id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const cats = testDb.prepare('SELECT id, name FROM categories LIMIT 2').all() as { id: number; name: string }[];
|
||||
expect(cats.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
createPlace(testDb, trip.id, { name: 'Hotel Alpha', category_id: cats[0].id });
|
||||
createPlace(testDb, trip.id, { name: 'Hotel Beta', category_id: cats[0].id });
|
||||
createPlace(testDb, trip.id, { name: 'Restaurant Gamma', category_id: cats[1].id });
|
||||
|
||||
// The route filters by category_id, not name
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places?category=${cats[0].id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(2);
|
||||
expect(res.body.places.every((p: any) => p.category?.id === cats[0].id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Get single place
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Get place', () => {
|
||||
it('PLACE-004 — GET /api/trips/:tripId/places/:id returns place with tags', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Test Place' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.place.id).toBe(place.id);
|
||||
expect(Array.isArray(res.body.place.tags)).toBe(true);
|
||||
});
|
||||
|
||||
it('PLACE-004 — GET non-existent place returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update place
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update place', () => {
|
||||
it('PLACE-005 — PUT /api/trips/:tripId/places/:id updates place details', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'New Name', description: 'Updated description' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.place.name).toBe('New Name');
|
||||
expect(res.body.place.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('PLACE-005 — PUT returns 404 for non-existent place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'New Name' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete place
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete place', () => {
|
||||
it('PLACE-006 — DELETE /api/trips/:tripId/places/:id removes place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const get = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PLACE-007 — member with default permissions can delete a place', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tags
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Tags', () => {
|
||||
it('PLACE-013 — GET /api/tags returns user tags', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Create a tag in DB
|
||||
testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Must-see', user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toBeDefined();
|
||||
const names = (res.body.tags as any[]).map((t: any) => t.name);
|
||||
expect(names).toContain('Must-see');
|
||||
});
|
||||
|
||||
it('PLACE-010/011 — POST place with tags associates them correctly', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Pre-create a tag
|
||||
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Romantic', user.id);
|
||||
const tagId = tagResult.lastInsertRowid as number;
|
||||
|
||||
// The places API accepts `tags` as an array of tag IDs
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Dinner Spot', tags: [tagId] });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
// Get place with tags
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${res.body.place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.place.tags.some((t: any) => t.id === tagId)).toBe(true);
|
||||
});
|
||||
|
||||
it('PLACE-012 — DELETE /api/tags/:id removes tag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('OldTag', user.id);
|
||||
const tagId = tagResult.lastInsertRowid as number;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const tags = await request(app).get('/api/tags').set('Cookie', authCookie(user.id));
|
||||
expect((tags.body.tags as any[]).some((t: any) => t.id === tagId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update place tags (PLACE-011)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update place tags', () => {
|
||||
it('PLACE-011 — PUT with tags array replaces existing tags', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tag1Result = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('OldTag', user.id);
|
||||
const tag2Result = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('NewTag', user.id);
|
||||
const tag1Id = tag1Result.lastInsertRowid as number;
|
||||
const tag2Id = tag2Result.lastInsertRowid as number;
|
||||
|
||||
// Create place with tag1
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Taggable Place', tags: [tag1Id] });
|
||||
expect(createRes.status).toBe(201);
|
||||
const placeId = createRes.body.place.id;
|
||||
|
||||
// Update with tag2 only — should replace tag1
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${placeId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ tags: [tag2Id] });
|
||||
expect(updateRes.status).toBe(200);
|
||||
const tags = updateRes.body.place.tags as any[];
|
||||
expect(tags.some((t: any) => t.id === tag2Id)).toBe(true);
|
||||
expect(tags.some((t: any) => t.id === tag1Id)).toBe(false);
|
||||
});
|
||||
|
||||
it('PLACE-011 — PUT with empty tags array removes all tags', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('RemovableTag', user.id);
|
||||
const tagId = tagResult.lastInsertRowid as number;
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Place With Tag', tags: [tagId] });
|
||||
const placeId = createRes.body.place.id;
|
||||
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${placeId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ tags: [] });
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.body.place.tags).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Place notes (PLACE-018)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Place notes', () => {
|
||||
it('PLACE-018 — Create a place with notes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Noted Place', notes: 'Book in advance!' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.place.notes).toBe('Book in advance!');
|
||||
});
|
||||
|
||||
it('PLACE-018 — Update place notes via PUT', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'My Spot' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'Updated notes here' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.place.notes).toBe('Updated notes here');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Search filter (PLACE-017 search variant)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Search places', () => {
|
||||
it('PLACE-017 — GET ?search= filters places by name', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places?search=Eiffel`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(1);
|
||||
expect(res.body.places[0].name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('PLACE-017 — GET ?tag= filters by tag id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Scenic', user.id);
|
||||
const tagId = tagResult.lastInsertRowid as number;
|
||||
|
||||
// Create place with the tag and one without
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Scenic Place', tags: [tagId] });
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
createPlace(testDb, trip.id, { name: 'Plain Place' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places?tag=${tagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(1);
|
||||
expect(res.body.places[0].name).toBe('Scenic Place');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Categories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Categories', () => {
|
||||
it('PLACE-015 — GET /api/categories returns all categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||
expect(res.body.categories[0]).toHaveProperty('name');
|
||||
expect(res.body.categories[0]).toHaveProperty('color');
|
||||
expect(res.body.categories[0]).toHaveProperty('icon');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GPX Import
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GPX Import', () => {
|
||||
it('PLACE-019 — POST /import/gpx with valid GPX file creates places', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/gpx`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', GPX_FIXTURE);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.places).toBeDefined();
|
||||
expect(res.body.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('PLACE-019 — POST /import/gpx without file returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/gpx`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
302
server/tests/integration/profile.test.ts
Normal file
302
server/tests/integration/profile.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* User Profile & Settings integration tests.
|
||||
* Covers PROFILE-001 to PROFILE-015.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Profile
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PROFILE-001 — Get current user profile', () => {
|
||||
it('returns user object with expected fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toMatchObject({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
});
|
||||
expect(res.body.user.password_hash).toBeUndefined();
|
||||
expect(res.body.user.mfa_secret).toBeUndefined();
|
||||
expect(res.body.user).toHaveProperty('mfa_enabled');
|
||||
expect(res.body.user).toHaveProperty('must_change_password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('PROFILE-002 — upload valid JPEG avatar updates avatar_url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/avatar')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('avatar', FIXTURE_JPEG);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.avatar_url).toBeDefined();
|
||||
expect(typeof res.body.avatar_url).toBe('string');
|
||||
});
|
||||
|
||||
it('PROFILE-003 — uploading non-image (PDF) is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/avatar')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('avatar', FIXTURE_PDF);
|
||||
// multer fileFilter rejects non-image types (cb(null, false) → req.file undefined → 400)
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PROFILE-005 — DELETE /api/auth/avatar clears avatar_url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Upload first
|
||||
await request(app)
|
||||
.post('/api/auth/avatar')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('avatar', FIXTURE_JPEG);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/avatar')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(me.body.user.avatar_url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password change', () => {
|
||||
it('PROFILE-006 — change password with valid credentials succeeds', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/auth/me/password')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ current_password: password, new_password: 'NewStr0ng!Pass', confirm_password: 'NewStr0ng!Pass' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('PROFILE-007 — wrong current password returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/auth/me/password')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ current_password: 'WrongPass1!', new_password: 'NewStr0ng!Pass', confirm_password: 'NewStr0ng!Pass' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('PROFILE-008 — weak new password is rejected', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/auth/me/password')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ current_password: password, new_password: 'weak', confirm_password: 'weak' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('PROFILE-009 — PUT /api/settings with key+value persists and GET returns it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const put = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'dark_mode', value: 'dark' });
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
const get = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.settings).toHaveProperty('dark_mode', 'dark');
|
||||
});
|
||||
|
||||
it('PROFILE-009 — PUT /api/settings without key returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ value: 'dark' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PROFILE-010 — POST /api/settings/bulk saves multiple keys atomically', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/settings/bulk')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: { theme: 'dark', language: 'fr', timezone: 'Europe/Paris' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const get = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.body.settings).toHaveProperty('theme', 'dark');
|
||||
expect(get.body.settings).toHaveProperty('language', 'fr');
|
||||
expect(get.body.settings).toHaveProperty('timezone', 'Europe/Paris');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Keys', () => {
|
||||
it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'my-weather-key-123' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Key in DB should be encrypted (not plaintext)
|
||||
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'plaintext-key' });
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
// The key should be masked or absent, never plaintext
|
||||
const body = me.body.user;
|
||||
expect(body.openweather_api_key).not.toBe('plaintext-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account deletion', () => {
|
||||
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
// Should not be able to log in
|
||||
const login = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(login.status).toBe(401);
|
||||
});
|
||||
|
||||
it('PROFILE-013 — admin cannot delete their own account', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
// Admins are protected from self-deletion
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/me')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
// deleteAccount returns 400 when the user is the last admin
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Travel stats', () => {
|
||||
it('PROFILE-014 — GET /api/auth/travel-stats returns stats object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, {
|
||||
title: 'France Trip',
|
||||
start_date: '2024-06-01',
|
||||
end_date: '2024-06-05',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/travel-stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('totalTrips');
|
||||
expect(res.body.totalTrips).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Demo mode protections', () => {
|
||||
it('PROFILE-015 — demo user cannot upload avatar (demoUploadBlock)', async () => {
|
||||
// demoUploadBlock checks for email === 'demo@nomad.app'
|
||||
testDb.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@nomad.app', 'x', 'user')"
|
||||
).run();
|
||||
const demoUser = testDb.prepare('SELECT id FROM users WHERE email = ?').get('demo@nomad.app') as { id: number };
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/avatar')
|
||||
.set('Cookie', authCookie(demoUser.id))
|
||||
.attach('avatar', FIXTURE_JPEG);
|
||||
expect(res.status).toBe(403);
|
||||
} finally {
|
||||
delete process.env.DEMO_MODE;
|
||||
}
|
||||
});
|
||||
});
|
||||
243
server/tests/integration/reservations.test.ts
Normal file
243
server/tests/integration/reservations.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Reservations integration tests.
|
||||
* Covers RESV-001 to RESV-007.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create reservation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create reservation', () => {
|
||||
it('RESV-001 — POST /api/trips/:tripId/reservations creates a reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Hotel Check-in', type: 'hotel' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.reservation.title).toBe('Hotel Check-in');
|
||||
expect(res.body.reservation.type).toBe('hotel');
|
||||
});
|
||||
|
||||
it('RESV-001 — POST without title returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ type: 'hotel' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('RESV-001 — non-member cannot create reservation', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({ title: 'Hotel', type: 'hotel' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('RESV-002 — POST with create_accommodation creates an accommodation record', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Grand Hotel', type: 'hotel', day_id: day.id, create_accommodation: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.reservation).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List reservations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List reservations', () => {
|
||||
it('RESV-003 — GET /api/trips/:tripId/reservations returns all reservations', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight' });
|
||||
createReservation(testDb, trip.id, { title: 'Hotel Stay', type: 'hotel' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('RESV-003 — returns empty array when no reservations exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RESV-007 — non-member cannot list reservations', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update reservation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update reservation', () => {
|
||||
it('RESV-004 — PUT updates reservation fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const resv = createReservation(testDb, trip.id, { title: 'Old Flight', type: 'flight' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'New Flight', confirmation_number: 'ABC123' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservation.title).toBe('New Flight');
|
||||
expect(res.body.reservation.confirmation_number).toBe('ABC123');
|
||||
});
|
||||
|
||||
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Updated' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete reservation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete reservation', () => {
|
||||
it('RESV-005 — DELETE removes reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const resv = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.reservations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RESV-005 — DELETE non-existent reservation returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/reservations/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Batch update positions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Batch update positions', () => {
|
||||
it('RESV-006 — PUT /positions updates reservation sort order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r1 = createReservation(testDb, trip.id, { title: 'First', type: 'flight' });
|
||||
const r2 = createReservation(testDb, trip.id, { title: 'Second', type: 'hotel' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/positions`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ positions: [{ id: r2.id, position: 0 }, { id: r1.id, position: 1 }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
173
server/tests/integration/security.test.ts
Normal file
173
server/tests/integration/security.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Security integration tests.
|
||||
* Covers SEC-001 to SEC-015.
|
||||
*
|
||||
* Notes:
|
||||
* - SSRF tests (SEC-001 to SEC-004) are unit-level tests on ssrfGuard — see tests/unit/utils/ssrfGuard.test.ts
|
||||
* - SEC-015 (MFA backup codes) is covered in auth.test.ts
|
||||
* - These tests focus on HTTP-level security: headers, auth, injection protection, etc.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Authentication security', () => {
|
||||
it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// The file download endpoint accepts bearer auth
|
||||
// Other endpoints use cookie auth — but /api/auth/me works with cookie auth
|
||||
// Test that a forged/invalid JWT is rejected
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer invalid.token.here');
|
||||
// Should return 401 (auth fails)
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('unauthenticated request to protected endpoint returns 401', async () => {
|
||||
const res = await request(app).get('/api/trips');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('expired/invalid JWT cookie returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', 'trek_session=invalid.jwt.token');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security headers', () => {
|
||||
it('SEC-011 — Helmet sets X-Content-Type-Options header', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('SEC-011 — Helmet sets X-Frame-Options header', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API key encryption', () => {
|
||||
it('SEC-008 — encrypted API keys are stored with enc:v1: prefix', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'test-api-key-12345' });
|
||||
|
||||
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('SEC-008 — GET /api/auth/me does not return plaintext API key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'secret-key' });
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(me.body.user.openweather_api_key).not.toBe('secret-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA secret protection', () => {
|
||||
it('SEC-009 — GET /api/auth/me does not expose mfa_secret', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.body.user.mfa_secret).toBeUndefined();
|
||||
expect(res.body.user.password_hash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request body size limit', () => {
|
||||
it('SEC-013 — oversized JSON body is rejected', async () => {
|
||||
// Send a large body (2MB+) to exceed the default limit
|
||||
const bigData = { data: 'x'.repeat(2 * 1024 * 1024) };
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send(bigData);
|
||||
// body-parser rejects oversized payloads with 413
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File download path traversal', () => {
|
||||
it('SEC-005 — path traversal in file download is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = { id: 1 };
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/1/download`)
|
||||
.set('Authorization', `Bearer ${generateToken(user.id)}`);
|
||||
// Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
207
server/tests/integration/share.test.ts
Normal file
207
server/tests/integration/share.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Share link integration tests.
|
||||
* Covers SHARE-001 to SHARE-009.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Share link CRUD', () => {
|
||||
it('SHARE-001 — POST creates share link with default permissions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.token).toBeDefined();
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
});
|
||||
|
||||
it('SHARE-002 — POST creates share link with custom permissions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_budget: false, share_packing: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.token).toBeDefined();
|
||||
});
|
||||
|
||||
it('SHARE-003 — POST again updates share link permissions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const first = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_budget: true });
|
||||
|
||||
const second = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_budget: false });
|
||||
// Same token (update, not create)
|
||||
expect(second.body.token).toBe(first.body.token);
|
||||
});
|
||||
|
||||
it('SHARE-004 — GET returns share link status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeDefined();
|
||||
});
|
||||
|
||||
it('SHARE-004 — GET returns null token when no share link exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeNull();
|
||||
});
|
||||
|
||||
it('SHARE-005 — DELETE removes share link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const status = await request(app)
|
||||
.get(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(status.body.token).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip access', () => {
|
||||
it('SHARE-006 — GET /shared/:token returns trip data with all sections', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Adventure' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_budget: true, share_packing: true });
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.title).toBe('Paris Adventure');
|
||||
});
|
||||
|
||||
it('SHARE-007 — GET /shared/:token hides budget when share_budget=false', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_budget: false });
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
// Budget should be an empty array when share_budget is false
|
||||
expect(Array.isArray(res.body.budget)).toBe(true);
|
||||
expect(res.body.budget).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('SHARE-008 — GET /shared/:invalid-token returns 404', async () => {
|
||||
const res = await request(app).get('/api/shared/invalid-token-xyz');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('SHARE-009 — non-member cannot create share link', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(other.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
679
server/tests/integration/trips.test.ts
Normal file
679
server/tests/integration/trips.test.ts
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* Trips API integration tests.
|
||||
* Covers TRIP-001 through TRIP-022.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create trip (TRIP-001, TRIP-002, TRIP-003)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create trip', () => {
|
||||
it('TRIP-001 — POST /api/trips with start_date/end_date returns 201 and auto-generates days', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Paris Adventure', start_date: '2026-06-01', end_date: '2026-06-05' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.title).toBe('Paris Adventure');
|
||||
|
||||
// Verify days were generated (5 days: Jun 1–5)
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY date').all(res.body.trip.id) as any[];
|
||||
expect(days).toHaveLength(5);
|
||||
expect(days[0].date).toBe('2026-06-01');
|
||||
expect(days[4].date).toBe('2026-06-05');
|
||||
});
|
||||
|
||||
it('TRIP-002 — POST /api/trips without dates returns 201 and no date-specific days', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Open-ended Trip' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.start_date).toBeNull();
|
||||
expect(res.body.trip.end_date).toBeNull();
|
||||
|
||||
// Days with explicit dates should not be present
|
||||
const daysWithDate = testDb.prepare('SELECT * FROM days WHERE trip_id = ? AND date IS NOT NULL').all(res.body.trip.id) as any[];
|
||||
expect(daysWithDate).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TRIP-001 — POST /api/trips requires a title, returns 400 without one', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ description: 'No title here' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/title/i);
|
||||
});
|
||||
|
||||
it('TRIP-001 — POST /api/trips rejects end_date before start_date with 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Bad Dates', start_date: '2026-06-10', end_date: '2026-06-05' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/end date/i);
|
||||
});
|
||||
|
||||
it('TRIP-003 — trip_create permission set to admin blocks regular user with 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// Restrict trip creation to admins only
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Forbidden Trip' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/permission/i);
|
||||
});
|
||||
|
||||
it('TRIP-003 — trip_create permission set to admin allows admin user', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ title: 'Admin Trip' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('TRIP-001 — unauthenticated POST /api/trips returns 401', async () => {
|
||||
const res = await request(app).post('/api/trips').send({ title: 'No Auth' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List trips (TRIP-004, TRIP-005)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List trips', () => {
|
||||
it('TRIP-004 — GET /api/trips returns own trips and member trips, not other users trips', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
|
||||
const ownTrip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||
const memberTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip (member)" });
|
||||
createTrip(testDb, stranger.id, { title: "Stranger's Private Trip" });
|
||||
|
||||
// Add member to one of stranger's trips
|
||||
addTripMember(testDb, memberTrip.id, member.id);
|
||||
|
||||
const ownerRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(ownerRes.status).toBe(200);
|
||||
const ownerTripIds = ownerRes.body.trips.map((t: any) => t.id);
|
||||
expect(ownerTripIds).toContain(ownTrip.id);
|
||||
expect(ownerTripIds).not.toContain(memberTrip.id);
|
||||
|
||||
const memberRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(memberRes.status).toBe(200);
|
||||
const memberTripIds = memberRes.body.trips.map((t: any) => t.id);
|
||||
expect(memberTripIds).toContain(memberTrip.id);
|
||||
expect(memberTripIds).not.toContain(ownTrip.id);
|
||||
});
|
||||
|
||||
it('TRIP-005 — GET /api/trips excludes archived trips by default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
|
||||
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
|
||||
|
||||
// Archive the second trip directly in the DB
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const tripIds = res.body.trips.map((t: any) => t.id);
|
||||
expect(tripIds).toContain(activeTrip.id);
|
||||
expect(tripIds).not.toContain(archivedTrip.id);
|
||||
});
|
||||
|
||||
it('TRIP-005 — GET /api/trips?archived=1 returns only archived trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
|
||||
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
|
||||
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips?archived=1')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const tripIds = res.body.trips.map((t: any) => t.id);
|
||||
expect(tripIds).toContain(archivedTrip.id);
|
||||
expect(tripIds).not.toContain(activeTrip.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Get trip (TRIP-006, TRIP-007, TRIP-016, TRIP-017)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Get trip', () => {
|
||||
it('TRIP-006 — GET /api/trips/:id for own trip returns 200 with full trip object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A lovely trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.id).toBe(trip.id);
|
||||
expect(res.body.trip.title).toBe('My Trip');
|
||||
expect(res.body.trip.is_owner).toBe(1);
|
||||
});
|
||||
|
||||
it('TRIP-007 — GET /api/trips/:id for another users trip returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('TRIP-016 — Non-member cannot access trip → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: nonMember } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(nonMember.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-017 — Member can access trip → 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip.id).toBe(trip.id);
|
||||
expect(res.body.trip.is_owner).toBe(0);
|
||||
});
|
||||
|
||||
it('TRIP-006 — GET /api/trips/:id for non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips/999999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update trip (TRIP-008, TRIP-009, TRIP-010)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update trip', () => {
|
||||
it('TRIP-008 — PUT /api/trips/:id updates title and description for owner → 200', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original Title' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Updated Title', description: 'New description' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip.title).toBe('Updated Title');
|
||||
expect(res.body.trip.description).toBe('New description');
|
||||
});
|
||||
|
||||
it('TRIP-009 — Archive trip (PUT with is_archived:true) removes it from normal list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'To Archive' });
|
||||
|
||||
const archiveRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ is_archived: true });
|
||||
|
||||
expect(archiveRes.status).toBe(200);
|
||||
expect(archiveRes.body.trip.is_archived).toBe(1);
|
||||
|
||||
// Should not appear in the normal list
|
||||
const listRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const tripIds = listRes.body.trips.map((t: any) => t.id);
|
||||
expect(tripIds).not.toContain(trip.id);
|
||||
});
|
||||
|
||||
it('TRIP-009 — Unarchive trip reappears in normal list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Archived Trip' });
|
||||
|
||||
// Archive it first
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(trip.id);
|
||||
|
||||
// Unarchive via API
|
||||
const unarchiveRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ is_archived: false });
|
||||
|
||||
expect(unarchiveRes.status).toBe(200);
|
||||
expect(unarchiveRes.body.trip.is_archived).toBe(0);
|
||||
|
||||
// Should appear in the normal list again
|
||||
const listRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const tripIds = listRes.body.trips.map((t: any) => t.id);
|
||||
expect(tripIds).toContain(trip.id);
|
||||
});
|
||||
|
||||
it('TRIP-010 — Archive by trip member is denied when trip_archive is set to trip_owner', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Members Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Restrict archiving to trip_owner only (this is actually the default, but set explicitly)
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_archive', 'trip_owner')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({ is_archived: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/permission/i);
|
||||
});
|
||||
|
||||
it('TRIP-008 — Member cannot edit trip title when trip_edit is set to trip_owner', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Original' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Default trip_edit is trip_owner — members should be blocked
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_edit', 'trip_owner')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({ title: 'Hacked Title' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('TRIP-008 — PUT /api/trips/:id returns 404 for non-existent trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/trips/999999')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Ghost Update' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete trip (TRIP-018, TRIP-019, TRIP-022)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete trip', () => {
|
||||
it('TRIP-018 — DELETE /api/trips/:id by owner returns 200 and trip is no longer accessible', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'To Delete' });
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteRes.body.success).toBe(true);
|
||||
|
||||
// Trip should no longer be accessible
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-019 — Regular user cannot delete another users trip → 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
|
||||
// getTripOwner finds the trip (it exists); checkPermission fails for non-members → 403
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Trip still exists
|
||||
const tripInDb = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
|
||||
expect(tripInDb).toBeDefined();
|
||||
});
|
||||
|
||||
it('TRIP-019 — Trip member cannot delete trip → 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/permission/i);
|
||||
});
|
||||
|
||||
it('TRIP-022 — Trip with places and reservations can be deleted (cascade)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip With Data' });
|
||||
|
||||
// Add associated data
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteRes.body.success).toBe(true);
|
||||
|
||||
// Verify cascade: places and reservations should be gone
|
||||
const places = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').all(trip.id);
|
||||
expect(places).toHaveLength(0);
|
||||
|
||||
const reservations = testDb.prepare('SELECT id FROM reservations WHERE trip_id = ?').all(trip.id);
|
||||
expect(reservations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TRIP-018 — Admin can delete another users trip', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: "User's Trip" });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TRIP-018 — DELETE /api/trips/:id for non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/trips/999999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Members (TRIP-013, TRIP-014, TRIP-015)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Trip members', () => {
|
||||
it('TRIP-015 — GET /api/trips/:id/members returns owner and members list', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.owner).toBeDefined();
|
||||
expect(res.body.owner.id).toBe(owner.id);
|
||||
expect(Array.isArray(res.body.members)).toBe(true);
|
||||
expect(res.body.members.some((m: any) => m.id === member.id)).toBe(true);
|
||||
expect(res.body.current_user_id).toBe(owner.id);
|
||||
});
|
||||
|
||||
it('TRIP-013 — POST /api/trips/:id/members adds a member by email → 201', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ identifier: invitee.email });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.member).toBeDefined();
|
||||
expect(res.body.member.email).toBe(invitee.email);
|
||||
expect(res.body.member.role).toBe('member');
|
||||
|
||||
// Verify in DB
|
||||
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id);
|
||||
expect(dbEntry).toBeDefined();
|
||||
});
|
||||
|
||||
it('TRIP-013 — POST /api/trips/:id/members adds a member by username → 201', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ identifier: invitee.username });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.member.id).toBe(invitee.id);
|
||||
});
|
||||
|
||||
it('TRIP-013 — Adding a non-existent user returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ identifier: 'nobody@nowhere.example.com' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/user not found/i);
|
||||
});
|
||||
|
||||
it('TRIP-013 — Adding a user who is already a member returns 400', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ identifier: member.email });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/already/i);
|
||||
});
|
||||
|
||||
it('TRIP-014 — DELETE /api/trips/:id/members/:userId removes a member → 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/members/${member.id}`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify removal in DB
|
||||
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
|
||||
expect(dbEntry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TRIP-014 — Member can remove themselves from a trip → 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/members/${member.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TRIP-013 — Non-owner member cannot add other members when member_manage is trip_owner', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Restrict member management to trip_owner (default)
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_member_manage', 'trip_owner')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({ identifier: invitee.email });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/permission/i);
|
||||
});
|
||||
|
||||
it('TRIP-015 — Non-member cannot list trip members → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
306
server/tests/integration/vacay.test.ts
Normal file
306
server/tests/integration/vacay.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Vacay integration tests.
|
||||
* Covers VACAY-001 to VACAY-025.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock external holiday API (node-fetch used by some service paths)
|
||||
vi.mock('node-fetch', () => ({
|
||||
default: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([
|
||||
{ date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' },
|
||||
]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock vacayService.getCountries to avoid real HTTP call to nager.at
|
||||
vi.mock('../../src/services/vacayService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/services/vacayService')>('../../src/services/vacayService');
|
||||
return {
|
||||
...actual,
|
||||
getCountries: vi.fn().mockResolvedValue({
|
||||
data: [{ countryCode: 'DE', name: 'Germany' }, { countryCode: 'FR', name: 'France' }],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Vacay plan', () => {
|
||||
it('VACAY-001 — GET /api/addons/vacay/plan auto-creates plan on first access', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/plan')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan).toBeDefined();
|
||||
expect(res.body.plan.owner_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('VACAY-001 — second GET returns same plan (no duplicate creation)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-002 — PUT /api/addons/vacay/plan updates plan settings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// Ensure plan exists
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/addons/vacay/plan')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ vacation_days: 30, carry_over_days: 5 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay years', () => {
|
||||
it('VACAY-007 — POST /api/addons/vacay/years adds a year to the plan', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/years')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ year: 2025 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.years).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-025 — GET /api/addons/vacay/years lists years in plan', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/years')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.years)).toBe(true);
|
||||
expect(res.body.years.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('VACAY-008 — DELETE /api/addons/vacay/years/:year removes year', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2026 });
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/vacay/years/2026')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.years).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-011 — PUT /api/addons/vacay/stats/:year updates allowance', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/addons/vacay/stats/2025')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ vacation_days: 28 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay entries', () => {
|
||||
it('VACAY-003 — POST /api/addons/vacay/entries/toggle marks a day as vacation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/toggle')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ date: '2025-06-16', year: 2025, type: 'vacation' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('VACAY-004 — POST /api/addons/vacay/entries/toggle on weekend is allowed (no server-side weekend blocking)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
// 2025-06-21 is a Saturday — server does not block weekends; client-side only
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/toggle')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ date: '2025-06-21', year: 2025, type: 'vacation' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('VACAY-006 — GET /api/addons/vacay/entries/:year returns vacation entries', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/entries/2025')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-009 — GET /api/addons/vacay/stats/:year returns stats for year', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/stats/2025')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('stats');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay color', () => {
|
||||
it('VACAY-024 — PUT /api/addons/vacay/color sets user color in plan', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/addons/vacay/color')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ color: '#3b82f6' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay invite flow', () => {
|
||||
it('VACAY-022 — cannot invite yourself', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_id: user.id });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('VACAY-016 — send invite to another user', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-023 — GET /api/addons/vacay/available-users returns users who can be invited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/available-users')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.users)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay holidays', () => {
|
||||
it('VACAY-014 — GET /api/addons/vacay/holidays/countries returns available countries', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/countries')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-012 — POST /api/addons/vacay/plan/holiday-calendars adds a holiday calendar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/plan/holiday-calendars')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ region: 'DE', label: 'Germany Holidays' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay dissolve plan', () => {
|
||||
it('VACAY-020 — POST /api/addons/vacay/dissolve removes user from plan', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/dissolve')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
157
server/tests/integration/weather.test.ts
Normal file
157
server/tests/integration/weather.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Weather integration tests.
|
||||
* Covers WEATHER-001 to WEATHER-007.
|
||||
*
|
||||
* External API calls (Open-Meteo) are mocked via vi.mock.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock node-fetch / global fetch so no real HTTP calls are made
|
||||
vi.mock('node-fetch', () => ({
|
||||
default: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
|
||||
daily: {
|
||||
time: ['2025-06-01'],
|
||||
temperature_2m_max: [25],
|
||||
temperature_2m_min: [18],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [15],
|
||||
sunrise: ['2025-06-01T06:00'],
|
||||
sunset: ['2025-06-01T21:00'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Weather validation', () => {
|
||||
it('WEATHER-001 — GET /weather without lat/lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-001 — GET /weather without lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-005 — GET /weather/detailed without date returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather/detailed?lat=48.8566&lng=2.3522')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-001 — GET /weather without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weather with mocked API', () => {
|
||||
it('WEATHER-001 — GET /weather with lat/lng returns weather data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body).toHaveProperty('main');
|
||||
});
|
||||
|
||||
it('WEATHER-002 — GET /weather?date=future returns forecast data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 5);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=48.8566&lng=2.3522&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body).toHaveProperty('type');
|
||||
});
|
||||
|
||||
it('WEATHER-006 — GET /weather accepts lang parameter', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522&lang=en')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
});
|
||||
});
|
||||
9
server/tests/setup.ts
Normal file
9
server/tests/setup.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Global test setup — runs before every test file.
|
||||
// Environment variables must be set before any module import so that
|
||||
// config.ts, database.ts, etc. pick them up at import time.
|
||||
|
||||
// Fixed encryption key (64 hex chars = 32 bytes) for at-rest crypto in tests
|
||||
process.env.ENCRYPTION_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.COOKIE_SECURE = 'false';
|
||||
process.env.LOG_LEVEL = 'error'; // suppress info/debug logs in test output
|
||||
115
server/tests/unit/middleware/auth.test.ts
Normal file
115
server/tests/unit/middleware/auth.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), all: vi.fn() }) },
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||
|
||||
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
function makeReq(overrides: {
|
||||
cookies?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
} = {}): Request {
|
||||
return {
|
||||
cookies: overrides.cookies || {},
|
||||
headers: overrides.headers || {},
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||
const json = vi.fn();
|
||||
const status = vi.fn(() => ({ json }));
|
||||
const res = { status } as unknown as Response;
|
||||
return { res, status, json };
|
||||
}
|
||||
|
||||
// ── extractToken ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('extractToken', () => {
|
||||
it('returns cookie value when trek_session cookie is set', () => {
|
||||
const req = makeReq({ cookies: { trek_session: 'cookie-token' } });
|
||||
expect(extractToken(req)).toBe('cookie-token');
|
||||
});
|
||||
|
||||
it('returns Bearer token from Authorization header when no cookie', () => {
|
||||
const req = makeReq({ headers: { authorization: 'Bearer header-token' } });
|
||||
expect(extractToken(req)).toBe('header-token');
|
||||
});
|
||||
|
||||
it('prefers cookie over Authorization header when both are present', () => {
|
||||
const req = makeReq({
|
||||
cookies: { trek_session: 'cookie-token' },
|
||||
headers: { authorization: 'Bearer header-token' },
|
||||
});
|
||||
expect(extractToken(req)).toBe('cookie-token');
|
||||
});
|
||||
|
||||
it('returns null when neither cookie nor header are present', () => {
|
||||
expect(extractToken(makeReq())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for Authorization header without a token (empty Bearer)', () => {
|
||||
const req = makeReq({ headers: { authorization: 'Bearer ' } });
|
||||
expect(extractToken(req)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for Authorization header without Bearer prefix', () => {
|
||||
const req = makeReq({ headers: { authorization: 'Basic sometoken' } });
|
||||
// split(' ')[1] returns 'sometoken' — this IS returned (not a null case)
|
||||
// The function simply splits on space and takes index 1
|
||||
expect(extractToken(req)).toBe('sometoken');
|
||||
});
|
||||
});
|
||||
|
||||
// ── authenticate ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('returns 401 when no token is present', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
authenticate(makeReq(), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ code: 'AUTH_REQUIRED' }));
|
||||
});
|
||||
|
||||
it('returns 401 when JWT is invalid', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
authenticate(makeReq({ cookies: { trek_session: 'invalid.jwt.token' } }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── adminOnly ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('adminOnly', () => {
|
||||
it('returns 403 when user role is not admin', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
const req = { ...makeReq(), user: { id: 1, role: 'user' } } as unknown as Request;
|
||||
adminOnly(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('Admin') }));
|
||||
});
|
||||
|
||||
it('calls next() when user role is admin', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
const req = { ...makeReq(), user: { id: 1, role: 'admin' } } as unknown as Request;
|
||||
adminOnly(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when req.user is undefined', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
adminOnly(makeReq() as unknown as Request, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
100
server/tests/unit/middleware/mfaPolicy.test.ts
Normal file
100
server/tests/unit/middleware/mfaPolicy.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), all: vi.fn() }) },
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||
|
||||
import { isPublicApiPath, isMfaSetupExemptPath } from '../../../src/middleware/mfaPolicy';
|
||||
|
||||
// ── isPublicApiPath ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('isPublicApiPath', () => {
|
||||
// AUTH-001 — Public paths must bypass MFA
|
||||
it('AUTH-001: GET /api/health is public', () => {
|
||||
expect(isPublicApiPath('GET', '/api/health')).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/auth/app-config is public', () => {
|
||||
expect(isPublicApiPath('GET', '/api/auth/app-config')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/auth/login is public', () => {
|
||||
expect(isPublicApiPath('POST', '/api/auth/login')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/auth/register is public', () => {
|
||||
expect(isPublicApiPath('POST', '/api/auth/register')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/auth/demo-login is public', () => {
|
||||
expect(isPublicApiPath('POST', '/api/auth/demo-login')).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/auth/invite/<token> is public', () => {
|
||||
expect(isPublicApiPath('GET', '/api/auth/invite/abc123')).toBe(true);
|
||||
expect(isPublicApiPath('GET', '/api/auth/invite/xyz-789')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/auth/mfa/verify-login is public', () => {
|
||||
expect(isPublicApiPath('POST', '/api/auth/mfa/verify-login')).toBe(true);
|
||||
});
|
||||
|
||||
it('OIDC paths are public (any method)', () => {
|
||||
expect(isPublicApiPath('GET', '/api/auth/oidc/callback')).toBe(true);
|
||||
expect(isPublicApiPath('POST', '/api/auth/oidc/login')).toBe(true);
|
||||
expect(isPublicApiPath('GET', '/api/auth/oidc/discovery')).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/trips is not public', () => {
|
||||
expect(isPublicApiPath('GET', '/api/trips')).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/auth/login with wrong method (GET) is not public', () => {
|
||||
expect(isPublicApiPath('GET', '/api/auth/login')).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/auth/me is not public', () => {
|
||||
expect(isPublicApiPath('GET', '/api/auth/me')).toBe(false);
|
||||
});
|
||||
|
||||
it('DELETE /api/auth/logout is not public', () => {
|
||||
expect(isPublicApiPath('DELETE', '/api/auth/logout')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isMfaSetupExemptPath ─────────────────────────────────────────────────────
|
||||
|
||||
describe('isMfaSetupExemptPath', () => {
|
||||
it('GET /api/auth/me is MFA-setup exempt', () => {
|
||||
expect(isMfaSetupExemptPath('GET', '/api/auth/me')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/auth/mfa/setup is MFA-setup exempt', () => {
|
||||
expect(isMfaSetupExemptPath('POST', '/api/auth/mfa/setup')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/auth/mfa/enable is MFA-setup exempt', () => {
|
||||
expect(isMfaSetupExemptPath('POST', '/api/auth/mfa/enable')).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/auth/app-settings is MFA-setup exempt', () => {
|
||||
expect(isMfaSetupExemptPath('GET', '/api/auth/app-settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('PUT /api/auth/app-settings is MFA-setup exempt', () => {
|
||||
expect(isMfaSetupExemptPath('PUT', '/api/auth/app-settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/auth/app-settings is NOT exempt (wrong method)', () => {
|
||||
expect(isMfaSetupExemptPath('POST', '/api/auth/app-settings')).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/trips is NOT exempt', () => {
|
||||
expect(isMfaSetupExemptPath('GET', '/api/trips')).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/auth/logout is NOT exempt', () => {
|
||||
expect(isMfaSetupExemptPath('GET', '/api/auth/logout')).toBe(false);
|
||||
});
|
||||
});
|
||||
109
server/tests/unit/middleware/validate.test.ts
Normal file
109
server/tests/unit/middleware/validate.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { maxLength, validateStringLengths } from '../../../src/middleware/validate';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
function makeReq(body: Record<string, unknown> = {}): Request {
|
||||
return { body } as Request;
|
||||
}
|
||||
|
||||
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||
const json = vi.fn();
|
||||
const status = vi.fn(() => ({ json }));
|
||||
const res = { status } as unknown as Response;
|
||||
return { res, status, json };
|
||||
}
|
||||
|
||||
// ── maxLength ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('maxLength', () => {
|
||||
it('calls next() when field is absent from body', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
maxLength('name', 10)(makeReq({}), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() when field is not a string (number)', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
maxLength('count', 5)(makeReq({ count: 999 }), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() when string length is within limit', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
maxLength('name', 10)(makeReq({ name: 'hello' }), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() when string length equals max exactly', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
maxLength('name', 5)(makeReq({ name: 'hello' }), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when field exceeds max', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
maxLength('name', 4)(makeReq({ name: 'hello' }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('name') }));
|
||||
});
|
||||
|
||||
it('error message includes field name and max length', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, json } = makeRes();
|
||||
maxLength('title', 3)(makeReq({ title: 'toolong' }), res, next);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/title.*3|3.*title/i) }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateStringLengths ────────────────────────────────────────────────────
|
||||
|
||||
describe('validateStringLengths', () => {
|
||||
it('calls next() when all fields are within limits', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
validateStringLengths({ name: 10, bio: 100 })(makeReq({ name: 'Alice', bio: 'A short bio' }), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 on first field that exceeds its limit', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
validateStringLengths({ name: 3 })(makeReq({ name: 'toolong' }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('skips fields not present in body', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
validateStringLengths({ name: 10, missing: 5 })(makeReq({ name: 'Alice' }), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-string fields', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
validateStringLengths({ count: 5 })(makeReq({ count: 999999 }), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty maxLengths object — calls next()', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
validateStringLengths({})(makeReq({ anything: 'value' }), res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next() only once even if multiple fields are valid', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
validateStringLengths({ a: 10, b: 10 })(makeReq({ a: 'ok', b: 'ok' }), res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
132
server/tests/unit/scheduler.test.ts
Normal file
132
server/tests/unit/scheduler.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Prevent node-cron from scheduling anything at import time
|
||||
vi.mock('node-cron', () => ({
|
||||
default: { schedule: vi.fn(), validate: vi.fn(() => true) },
|
||||
schedule: vi.fn(),
|
||||
validate: vi.fn(() => true),
|
||||
}));
|
||||
// Prevent archiver from causing side effects
|
||||
vi.mock('archiver', () => ({ default: vi.fn() }));
|
||||
// Prevent fs side effects (creating directories, reading files)
|
||||
vi.mock('node:fs', () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(() => '{}'),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||
},
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(() => '{}'),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }) },
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { buildCronExpression } from '../../src/scheduler';
|
||||
|
||||
interface BackupSettings {
|
||||
enabled: boolean;
|
||||
interval: string;
|
||||
keep_days: number;
|
||||
hour: number;
|
||||
day_of_week: number;
|
||||
day_of_month: number;
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<BackupSettings> = {}): BackupSettings {
|
||||
return {
|
||||
enabled: true,
|
||||
interval: 'daily',
|
||||
keep_days: 7,
|
||||
hour: 2,
|
||||
day_of_week: 0,
|
||||
day_of_month: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildCronExpression', () => {
|
||||
describe('hourly', () => {
|
||||
it('returns 0 * * * * regardless of hour/dow/dom', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'hourly', hour: 5, day_of_week: 3, day_of_month: 15 }))).toBe('0 * * * *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daily', () => {
|
||||
it('returns 0 <hour> * * *', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'daily', hour: 3 }))).toBe('0 3 * * *');
|
||||
});
|
||||
|
||||
it('handles midnight (hour 0)', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'daily', hour: 0 }))).toBe('0 0 * * *');
|
||||
});
|
||||
|
||||
it('handles last valid hour (23)', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'daily', hour: 23 }))).toBe('0 23 * * *');
|
||||
});
|
||||
|
||||
it('falls back to hour 2 for invalid hour (24)', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'daily', hour: 24 }))).toBe('0 2 * * *');
|
||||
});
|
||||
|
||||
it('falls back to hour 2 for negative hour', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'daily', hour: -1 }))).toBe('0 2 * * *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly', () => {
|
||||
it('returns 0 <hour> * * <dow>', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'weekly', hour: 5, day_of_week: 3 }))).toBe('0 5 * * 3');
|
||||
});
|
||||
|
||||
it('handles Sunday (dow 0)', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'weekly', hour: 2, day_of_week: 0 }))).toBe('0 2 * * 0');
|
||||
});
|
||||
|
||||
it('handles Saturday (dow 6)', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'weekly', hour: 2, day_of_week: 6 }))).toBe('0 2 * * 6');
|
||||
});
|
||||
|
||||
it('falls back to dow 0 for invalid day_of_week (7)', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'weekly', hour: 2, day_of_week: 7 }))).toBe('0 2 * * 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthly', () => {
|
||||
it('returns 0 <hour> <dom> * *', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 15 }))).toBe('0 2 15 * *');
|
||||
});
|
||||
|
||||
it('handles day_of_month 1', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 1 }))).toBe('0 2 1 * *');
|
||||
});
|
||||
|
||||
it('handles max valid day_of_month (28)', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 28 }))).toBe('0 2 28 * *');
|
||||
});
|
||||
|
||||
it('falls back to dom 1 for day_of_month 29', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 29 }))).toBe('0 2 1 * *');
|
||||
});
|
||||
|
||||
it('falls back to dom 1 for day_of_month 0', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'monthly', hour: 2, day_of_month: 0 }))).toBe('0 2 1 * *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown interval', () => {
|
||||
it('defaults to daily pattern', () => {
|
||||
expect(buildCronExpression(settings({ interval: 'unknown', hour: 4 }))).toBe('0 4 * * *');
|
||||
});
|
||||
});
|
||||
});
|
||||
79
server/tests/unit/services/apiKeyCrypto.test.ts
Normal file
79
server/tests/unit/services/apiKeyCrypto.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { encrypt_api_key, decrypt_api_key, maybe_encrypt_api_key } from '../../../src/services/apiKeyCrypto';
|
||||
|
||||
describe('apiKeyCrypto', () => {
|
||||
const PLAINTEXT_KEY = 'my-secret-api-key-12345';
|
||||
const ENC_PREFIX = 'enc:v1:';
|
||||
|
||||
// SEC-008 — Encrypted API keys not returned in plaintext
|
||||
describe('encrypt_api_key', () => {
|
||||
it('SEC-008: returns encrypted string with enc:v1: prefix', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
expect(encrypted).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('different calls produce different ciphertext (random IV)', () => {
|
||||
const enc1 = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const enc2 = encrypt_api_key(PLAINTEXT_KEY);
|
||||
expect(enc1).not.toBe(enc2);
|
||||
});
|
||||
|
||||
it('encrypted value does not contain the plaintext', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
expect(encrypted).not.toContain(PLAINTEXT_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt_api_key', () => {
|
||||
it('SEC-008: decrypts an encrypted key back to original', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const decrypted = decrypt_api_key(encrypted);
|
||||
expect(decrypted).toBe(PLAINTEXT_KEY);
|
||||
});
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(decrypt_api_key(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(decrypt_api_key('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns plaintext as-is if not prefixed (legacy)', () => {
|
||||
expect(decrypt_api_key('plain-legacy-key')).toBe('plain-legacy-key');
|
||||
});
|
||||
|
||||
it('returns null for tampered ciphertext', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const tampered = encrypted.replace(ENC_PREFIX, ENC_PREFIX) + 'TAMPER';
|
||||
expect(decrypt_api_key(tampered)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybe_encrypt_api_key', () => {
|
||||
it('encrypts a new plaintext value', () => {
|
||||
const result = maybe_encrypt_api_key('my-key');
|
||||
expect(result).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('returns null for empty/falsy values', () => {
|
||||
expect(maybe_encrypt_api_key('')).toBeNull();
|
||||
expect(maybe_encrypt_api_key(null)).toBeNull();
|
||||
expect(maybe_encrypt_api_key(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns already-encrypted value as-is (no double-encryption)', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const result = maybe_encrypt_api_key(encrypted);
|
||||
expect(result).toBe(encrypted);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
server/tests/unit/services/auditLog.test.ts
Normal file
70
server/tests/unit/services/auditLog.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Prevent file I/O side effects at module load time
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
mkdirSync: vi.fn(),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ size: 0 })),
|
||||
appendFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
},
|
||||
mkdirSync: vi.fn(),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ size: 0 })),
|
||||
appendFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), run: vi.fn() }) },
|
||||
}));
|
||||
|
||||
import { getClientIp } from '../../../src/services/auditLog';
|
||||
import type { Request } from 'express';
|
||||
|
||||
function makeReq(options: {
|
||||
xff?: string | string[];
|
||||
remoteAddress?: string;
|
||||
} = {}): Request {
|
||||
return {
|
||||
headers: {
|
||||
...(options.xff !== undefined ? { 'x-forwarded-for': options.xff } : {}),
|
||||
},
|
||||
socket: { remoteAddress: options.remoteAddress ?? undefined },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('getClientIp', () => {
|
||||
it('returns first IP from comma-separated X-Forwarded-For string', () => {
|
||||
expect(getClientIp(makeReq({ xff: '1.2.3.4, 5.6.7.8, 9.10.11.12' }))).toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('returns single IP when X-Forwarded-For has no comma', () => {
|
||||
expect(getClientIp(makeReq({ xff: '10.0.0.1' }))).toBe('10.0.0.1');
|
||||
});
|
||||
|
||||
it('returns first element when X-Forwarded-For is an array', () => {
|
||||
expect(getClientIp(makeReq({ xff: ['203.0.113.1', '10.0.0.1'] }))).toBe('203.0.113.1');
|
||||
});
|
||||
|
||||
it('trims whitespace from extracted IP', () => {
|
||||
expect(getClientIp(makeReq({ xff: ' 192.168.1.1 , 10.0.0.1' }))).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
it('falls back to req.socket.remoteAddress when no X-Forwarded-For', () => {
|
||||
expect(getClientIp(makeReq({ remoteAddress: '172.16.0.1' }))).toBe('172.16.0.1');
|
||||
});
|
||||
|
||||
it('returns null when no forwarded header and no socket address', () => {
|
||||
expect(getClientIp(makeReq({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string X-Forwarded-For', () => {
|
||||
const req = {
|
||||
headers: { 'x-forwarded-for': '' },
|
||||
socket: { remoteAddress: undefined },
|
||||
} as unknown as Request;
|
||||
expect(getClientIp(req)).toBeNull();
|
||||
});
|
||||
});
|
||||
299
server/tests/unit/services/authService.test.ts
Normal file
299
server/tests/unit/services/authService.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), all: vi.fn(), run: vi.fn() }) },
|
||||
canAccessTrip: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
vi.mock('../../../src/services/mfaCrypto', () => ({ encryptMfaSecret: vi.fn(), decryptMfaSecret: vi.fn() }));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: vi.fn((v) => v),
|
||||
maybe_encrypt_api_key: vi.fn((v) => v),
|
||||
encrypt_api_key: vi.fn((v) => v),
|
||||
}));
|
||||
vi.mock('../../../src/services/permissions', () => ({ getAllPermissions: vi.fn(() => ({})), checkPermission: vi.fn() }));
|
||||
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), buildCronExpression: vi.fn() }));
|
||||
|
||||
import {
|
||||
utcSuffix,
|
||||
stripUserForClient,
|
||||
maskKey,
|
||||
avatarUrl,
|
||||
normalizeBackupCode,
|
||||
hashBackupCode,
|
||||
generateBackupCodes,
|
||||
parseBackupCodeHashes,
|
||||
} from '../../../src/services/authService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
// ── utcSuffix ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('utcSuffix', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(utcSuffix(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(utcSuffix(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(utcSuffix('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns timestamp unchanged when already ending with Z', () => {
|
||||
expect(utcSuffix('2024-01-01T12:00:00Z')).toBe('2024-01-01T12:00:00Z');
|
||||
});
|
||||
|
||||
it('replaces space with T and appends Z for SQLite-style datetime', () => {
|
||||
expect(utcSuffix('2024-01-01 12:00:00')).toBe('2024-01-01T12:00:00Z');
|
||||
});
|
||||
|
||||
it('appends Z when T is present but Z is missing', () => {
|
||||
expect(utcSuffix('2024-06-15T08:30:00')).toBe('2024-06-15T08:30:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
// ── stripUserForClient ───────────────────────────────────────────────────────
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
role: 'user',
|
||||
password_hash: 'supersecret',
|
||||
maps_api_key: 'maps-key',
|
||||
openweather_api_key: 'weather-key',
|
||||
unsplash_api_key: 'unsplash-key',
|
||||
mfa_secret: 'totpsecret',
|
||||
mfa_backup_codes: '["hash1","hash2"]',
|
||||
mfa_enabled: 0,
|
||||
must_change_password: 0,
|
||||
avatar: null,
|
||||
created_at: '2024-01-01 00:00:00',
|
||||
updated_at: '2024-06-01 00:00:00',
|
||||
last_login: null,
|
||||
...overrides,
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
describe('stripUserForClient', () => {
|
||||
it('SEC-008: omits password_hash', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('password_hash');
|
||||
});
|
||||
|
||||
it('SEC-008: omits maps_api_key', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('maps_api_key');
|
||||
});
|
||||
|
||||
it('SEC-008: omits openweather_api_key', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('openweather_api_key');
|
||||
});
|
||||
|
||||
it('SEC-008: omits unsplash_api_key', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('unsplash_api_key');
|
||||
});
|
||||
|
||||
it('SEC-008: omits mfa_secret', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('mfa_secret');
|
||||
});
|
||||
|
||||
it('SEC-008: omits mfa_backup_codes', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('mfa_backup_codes');
|
||||
});
|
||||
|
||||
it('preserves non-sensitive fields', () => {
|
||||
const result = stripUserForClient(makeUser({ username: 'alice', email: 'alice@example.com', role: 'user' }));
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.username).toBe('alice');
|
||||
expect(result.email).toBe('alice@example.com');
|
||||
expect(result.role).toBe('user');
|
||||
});
|
||||
|
||||
it('normalizes mfa_enabled integer 1 to true', () => {
|
||||
const result = stripUserForClient(makeUser({ mfa_enabled: 1 } as any));
|
||||
expect(result.mfa_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes mfa_enabled integer 0 to false', () => {
|
||||
const result = stripUserForClient(makeUser({ mfa_enabled: 0 } as any));
|
||||
expect(result.mfa_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes mfa_enabled boolean true to true', () => {
|
||||
const result = stripUserForClient(makeUser({ mfa_enabled: true } as any));
|
||||
expect(result.mfa_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes must_change_password integer 1 to true', () => {
|
||||
const result = stripUserForClient(makeUser({ must_change_password: 1 } as any));
|
||||
expect(result.must_change_password).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes must_change_password integer 0 to false', () => {
|
||||
const result = stripUserForClient(makeUser({ must_change_password: 0 } as any));
|
||||
expect(result.must_change_password).toBe(false);
|
||||
});
|
||||
|
||||
it('converts created_at through utcSuffix', () => {
|
||||
const result = stripUserForClient(makeUser({ created_at: '2024-01-01 00:00:00' }));
|
||||
expect(result.created_at).toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('converts updated_at through utcSuffix', () => {
|
||||
const result = stripUserForClient(makeUser({ updated_at: '2024-06-01 12:00:00' }));
|
||||
expect(result.updated_at).toBe('2024-06-01T12:00:00Z');
|
||||
});
|
||||
|
||||
it('passes null last_login through as null', () => {
|
||||
const result = stripUserForClient(makeUser({ last_login: null }));
|
||||
expect(result.last_login).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── maskKey ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('maskKey', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(maskKey(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(maskKey(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(maskKey('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns -------- for keys with 8 or fewer characters', () => {
|
||||
expect(maskKey('abcd1234')).toBe('--------');
|
||||
expect(maskKey('short')).toBe('--------');
|
||||
expect(maskKey('a')).toBe('--------');
|
||||
});
|
||||
|
||||
it('returns ---- + last 4 chars for keys longer than 8 characters', () => {
|
||||
expect(maskKey('abcdefghijkl')).toBe('----ijkl');
|
||||
expect(maskKey('sk-test-12345678')).toBe('----5678');
|
||||
});
|
||||
});
|
||||
|
||||
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarUrl', () => {
|
||||
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||
});
|
||||
|
||||
it('returns null when avatar is null', () => {
|
||||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when avatar is undefined', () => {
|
||||
expect(avatarUrl({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── normalizeBackupCode ──────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeBackupCode', () => {
|
||||
it('uppercases the input', () => {
|
||||
expect(normalizeBackupCode('abcd1234')).toBe('ABCD1234');
|
||||
});
|
||||
|
||||
it('strips non-alphanumeric characters', () => {
|
||||
expect(normalizeBackupCode('AB-CD 12!34')).toBe('ABCD1234');
|
||||
});
|
||||
|
||||
it('handles code with dashes (normal backup code format)', () => {
|
||||
expect(normalizeBackupCode('A1B2-C3D4')).toBe('A1B2C3D4');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeBackupCode('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── hashBackupCode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('hashBackupCode', () => {
|
||||
it('returns a 64-character hex string', () => {
|
||||
const hash = hashBackupCode('A1B2-C3D4');
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('is deterministic: same input always produces same output', () => {
|
||||
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('A1B2-C3D4'));
|
||||
});
|
||||
|
||||
it('normalizes before hashing: dashed and plain form produce the same hash', () => {
|
||||
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('a1b2c3d4'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateBackupCodes ──────────────────────────────────────────────────────
|
||||
|
||||
describe('generateBackupCodes', () => {
|
||||
it('returns 10 codes by default', () => {
|
||||
const codes = generateBackupCodes();
|
||||
expect(codes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('respects a custom count', () => {
|
||||
expect(generateBackupCodes(5)).toHaveLength(5);
|
||||
expect(generateBackupCodes(20)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('each code matches the XXXX-XXXX uppercase hex pattern', () => {
|
||||
const codes = generateBackupCodes();
|
||||
for (const code of codes) {
|
||||
expect(code).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates no duplicate codes', () => {
|
||||
const codes = generateBackupCodes(10);
|
||||
expect(new Set(codes).size).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseBackupCodeHashes ────────────────────────────────────────────────────
|
||||
|
||||
describe('parseBackupCodeHashes', () => {
|
||||
it('returns [] for null', () => {
|
||||
expect(parseBackupCodeHashes(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for undefined', () => {
|
||||
expect(parseBackupCodeHashes(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for empty string', () => {
|
||||
expect(parseBackupCodeHashes('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for invalid JSON', () => {
|
||||
expect(parseBackupCodeHashes('not-json')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for JSON that is not an array', () => {
|
||||
expect(parseBackupCodeHashes('{"key":"value"}')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out non-string entries', () => {
|
||||
expect(parseBackupCodeHashes('[1, "abc", null, true]')).toEqual(['abc']);
|
||||
});
|
||||
|
||||
it('returns all strings from a valid JSON string array', () => {
|
||||
expect(parseBackupCodeHashes('["hash1","hash2","hash3"]')).toEqual(['hash1', 'hash2', 'hash3']);
|
||||
});
|
||||
});
|
||||
207
server/tests/unit/services/budgetService.test.ts
Normal file
207
server/tests/unit/services/budgetService.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── DB mock setup ────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockPrepared {
|
||||
all: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const preparedMap: Record<string, MockPrepared> = {};
|
||||
let defaultAll: ReturnType<typeof vi.fn>;
|
||||
let defaultGet: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockDb = vi.hoisted(() => {
|
||||
return {
|
||||
db: {
|
||||
prepare: vi.fn((sql: string) => {
|
||||
return {
|
||||
all: vi.fn(() => []),
|
||||
get: vi.fn(() => undefined),
|
||||
run: vi.fn(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
canAccessTrip: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
|
||||
import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService';
|
||||
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
|
||||
return { id, trip_id, name: `Item ${id}`, total_price, category: 'Other' } as BudgetItem;
|
||||
}
|
||||
|
||||
function makeMember(budget_item_id: number, user_id: number, paid: boolean | 0 | 1, username: string): BudgetItemMember & { budget_item_id: number } {
|
||||
return {
|
||||
budget_item_id,
|
||||
user_id,
|
||||
paid: paid ? 1 : 0,
|
||||
username,
|
||||
avatar: null,
|
||||
} as BudgetItemMember & { budget_item_id: number };
|
||||
}
|
||||
|
||||
function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item_id: number })[]) {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT * FROM budget_items')) {
|
||||
return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
if (sql.includes('budget_item_members')) {
|
||||
return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupDb([], []);
|
||||
});
|
||||
|
||||
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarUrl', () => {
|
||||
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||
});
|
||||
|
||||
it('returns null when avatar is null', () => {
|
||||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when avatar is undefined', () => {
|
||||
expect(avatarUrl({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── calculateSettlement ──────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateSettlement', () => {
|
||||
it('returns empty balances and flows when trip has no items', () => {
|
||||
setupDb([], []);
|
||||
const result = calculateSettlement(1);
|
||||
expect(result.balances).toEqual([]);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when there are items but no members', () => {
|
||||
setupDb([makeItem(1, 100)], []);
|
||||
const result = calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when no one is marked as paid', () => {
|
||||
setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
|
||||
// Item: $100. Alice paid, Bob did not. Each owes $50. Alice net: +$50. Bob net: -$50.
|
||||
setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(50);
|
||||
expect(bob.balance).toBe(-50);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].from.user_id).toBe(2); // Bob owes
|
||||
expect(result.flows[0].to.user_id).toBe(1); // Alice is owed
|
||||
expect(result.flows[0].amount).toBe(50);
|
||||
});
|
||||
|
||||
it('3 members, 1 payer: correct 3-way split', () => {
|
||||
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
|
||||
setupDb(
|
||||
[makeItem(1, 90)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
const carol = result.balances.find(b => b.user_id === 3)!;
|
||||
expect(alice.balance).toBe(60);
|
||||
expect(bob.balance).toBe(-30);
|
||||
expect(carol.balance).toBe(-30);
|
||||
expect(result.flows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('all paid equally: all balances are zero, no flows', () => {
|
||||
// Item: $60. 3 members, all paid equally (each paid $20, each owes $20). Net: 0.
|
||||
// Actually with "paid" flag it means: paidPerPayer = item.total / numPayers.
|
||||
// If all 3 paid: each gets +20 credit, each owes -20 = net 0 for everyone.
|
||||
setupDb(
|
||||
[makeItem(1, 60)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 1, 'bob'), makeMember(1, 3, 1, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
|
||||
}
|
||||
expect(result.flows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flow direction: from is debtor (owes), to is creditor (is owed)', () => {
|
||||
// Alice paid $100 for 2 people. Bob owes Alice $50.
|
||||
setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const flow = result.flows[0];
|
||||
expect(flow.from.username).toBe('bob'); // debtor
|
||||
expect(flow.to.username).toBe('alice'); // creditor
|
||||
});
|
||||
|
||||
it('amounts are rounded to 2 decimal places', () => {
|
||||
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
|
||||
setupDb(
|
||||
[makeItem(1, 10)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
const str = b.balance.toString();
|
||||
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||
expect(decimals).toBeLessThanOrEqual(2);
|
||||
}
|
||||
for (const flow of result.flows) {
|
||||
const str = flow.amount.toString();
|
||||
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||
expect(decimals).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('2 items with different payers: aggregates balances correctly', () => {
|
||||
// Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50)
|
||||
// Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30)
|
||||
// Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20
|
||||
setupDb(
|
||||
[makeItem(1, 100), makeItem(2, 60)],
|
||||
[
|
||||
makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'),
|
||||
makeMember(2, 1, 0, 'alice'), makeMember(2, 2, 1, 'bob'),
|
||||
],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(20);
|
||||
expect(bob.balance).toBe(-20);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].amount).toBe(20);
|
||||
});
|
||||
});
|
||||
56
server/tests/unit/services/cookie.test.ts
Normal file
56
server/tests/unit/services/cookie.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { cookieOptions } from '../../../src/services/cookie';
|
||||
|
||||
describe('cookieOptions', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('always sets httpOnly: true', () => {
|
||||
expect(cookieOptions()).toHaveProperty('httpOnly', true);
|
||||
});
|
||||
|
||||
it('always sets sameSite: lax', () => {
|
||||
expect(cookieOptions()).toHaveProperty('sameSite', 'lax');
|
||||
});
|
||||
|
||||
it('always sets path: /', () => {
|
||||
expect(cookieOptions()).toHaveProperty('path', '/');
|
||||
});
|
||||
|
||||
it('sets secure: false in test environment (COOKIE_SECURE=false from setup)', () => {
|
||||
// setup.ts sets COOKIE_SECURE=false, so secure should be false
|
||||
const opts = cookieOptions();
|
||||
expect(opts.secure).toBe(false);
|
||||
});
|
||||
|
||||
it('sets secure: true when NODE_ENV=production and COOKIE_SECURE is not false', () => {
|
||||
vi.stubEnv('COOKIE_SECURE', 'true');
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
expect(cookieOptions().secure).toBe(true);
|
||||
});
|
||||
|
||||
it('sets secure: false when COOKIE_SECURE=false even in production', () => {
|
||||
vi.stubEnv('COOKIE_SECURE', 'false');
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
expect(cookieOptions().secure).toBe(false);
|
||||
});
|
||||
|
||||
it('sets secure: true when FORCE_HTTPS=true', () => {
|
||||
vi.stubEnv('COOKIE_SECURE', 'true');
|
||||
vi.stubEnv('FORCE_HTTPS', 'true');
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
expect(cookieOptions().secure).toBe(true);
|
||||
});
|
||||
|
||||
it('includes maxAge: 86400000 when clear is false (default)', () => {
|
||||
expect(cookieOptions()).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
|
||||
expect(cookieOptions(false)).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('omits maxAge when clear is true', () => {
|
||||
const opts = cookieOptions(true);
|
||||
expect(opts).not.toHaveProperty('maxAge');
|
||||
});
|
||||
});
|
||||
71
server/tests/unit/services/ephemeralTokens.test.ts
Normal file
71
server/tests/unit/services/ephemeralTokens.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Reset module between tests that need a fresh token store
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('ephemeralTokens', () => {
|
||||
async function getModule() {
|
||||
return import('../../../src/services/ephemeralTokens');
|
||||
}
|
||||
|
||||
// AUTH-030 — Resource token creation (single-use)
|
||||
describe('createEphemeralToken', () => {
|
||||
it('AUTH-030: creates a token and returns a hex string', async () => {
|
||||
const { createEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(1, 'download');
|
||||
expect(token).not.toBeNull();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token!.length).toBe(64); // 32 bytes hex
|
||||
});
|
||||
|
||||
it('AUTH-030: different calls produce different tokens', async () => {
|
||||
const { createEphemeralToken } = await getModule();
|
||||
const t1 = createEphemeralToken(1, 'download');
|
||||
const t2 = createEphemeralToken(1, 'download');
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
|
||||
// AUTH-029 — WebSocket token expiry (single-use)
|
||||
describe('consumeEphemeralToken', () => {
|
||||
it('AUTH-030: token is consumed and returns userId on first use', async () => {
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'download')!;
|
||||
const userId = consumeEphemeralToken(token, 'download');
|
||||
expect(userId).toBe(42);
|
||||
});
|
||||
|
||||
it('AUTH-030: token is single-use — second consume returns null', async () => {
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'download')!;
|
||||
consumeEphemeralToken(token, 'download'); // first use
|
||||
const second = consumeEphemeralToken(token, 'download'); // second use
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-029: purpose mismatch returns null', async () => {
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'ws')!;
|
||||
const result = consumeEphemeralToken(token, 'download');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-029: expired token returns null', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'ws')!; // 30s TTL
|
||||
vi.advanceTimersByTime(31_000); // advance past expiry
|
||||
const result = consumeEphemeralToken(token, 'ws');
|
||||
expect(result).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns null for unknown token', async () => {
|
||||
const { consumeEphemeralToken } = await getModule();
|
||||
const result = consumeEphemeralToken('nonexistent-token', 'download');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
58
server/tests/unit/services/mfaCrypto.test.ts
Normal file
58
server/tests/unit/services/mfaCrypto.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../../../src/services/mfaCrypto';
|
||||
|
||||
describe('mfaCrypto', () => {
|
||||
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; // typical base32 TOTP secret
|
||||
|
||||
// SEC-009 — Encrypted MFA secrets not exposed
|
||||
describe('encryptMfaSecret', () => {
|
||||
it('SEC-009: returns a base64 string (not the plaintext)', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
expect(encrypted).not.toBe(TOTP_SECRET);
|
||||
// Should be valid base64
|
||||
expect(() => Buffer.from(encrypted, 'base64')).not.toThrow();
|
||||
});
|
||||
|
||||
it('different calls produce different ciphertext (random IV)', () => {
|
||||
const enc1 = encryptMfaSecret(TOTP_SECRET);
|
||||
const enc2 = encryptMfaSecret(TOTP_SECRET);
|
||||
expect(enc1).not.toBe(enc2);
|
||||
});
|
||||
|
||||
it('encrypted value does not contain plaintext', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
expect(encrypted).not.toContain(TOTP_SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptMfaSecret', () => {
|
||||
it('SEC-009: roundtrip — decrypt returns original secret', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
const decrypted = decryptMfaSecret(encrypted);
|
||||
expect(decrypted).toBe(TOTP_SECRET);
|
||||
});
|
||||
|
||||
it('handles secrets of varying lengths', () => {
|
||||
const short = 'ABC123';
|
||||
const long = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP';
|
||||
expect(decryptMfaSecret(encryptMfaSecret(short))).toBe(short);
|
||||
expect(decryptMfaSecret(encryptMfaSecret(long))).toBe(long);
|
||||
});
|
||||
|
||||
it('throws or returns garbage on tampered ciphertext', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
const buf = Buffer.from(encrypted, 'base64');
|
||||
buf[buf.length - 1] ^= 0xff; // flip last byte
|
||||
const tampered = buf.toString('base64');
|
||||
expect(() => decryptMfaSecret(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user