Compare commits

...

32 Commits

Author SHA1 Message Date
tototomate123 5d613ea550 perf(collab): reduce cursor and preview update churn 2026-02-14 16:38:40 +01:00
tototomate123 0ffe410eeb feat(collab): add server-authoritative sync and preview-only updates 2026-02-13 20:47:05 +01:00
tototomate123 fd5470ada5 fix(editor): flush lifecycle saves and recover from version conflicts 2026-02-13 19:17:25 +01:00
tototomate123 75cbe97bc0 feat(collab): restore cross-account sharing and reliable realtime sync 2026-02-13 19:02:03 +01:00
tototomate123 12da89b815 Update README.md 2026-02-13 16:33:15 +00:00
tototomate123 9e248f9751 css fix 2026-02-12 20:38:05 +01:00
tototomate123 fe58cf7e89 update to excalidraw 0.18.0 2026-02-12 20:32:53 +01:00
tototomate123 6061d4ab94 fix(auth): align frontend password validation with production policy 2026-02-12 19:58:13 +01:00
tototomate123 6fe2ab3d28 fix(deploy): align /api routing, socket path, and proxy-aware auth limits 2026-02-12 19:43:49 +01:00
tototomate123 e05edff84d fix socket in editor 2026-02-12 19:29:17 +01:00
tototomate123 da131834ce add production stuff 2026-02-12 19:22:40 +01:00
tototomate123 08d2165a70 fix(dashboard): normalize route id params for express 5 typings 2026-02-12 19:10:41 +01:00
Zimeng Xiong 2cbd11cf0d fix impersonation issues 2026-02-10 22:45:00 -08:00
Zimeng Xiong 1c71a08bbe Plan OIDC integration and audit 2026-02-10 14:45:34 -08:00
Zimeng Xiong bb028ef2db fix csrf token hardset, remove cookie from localstorage 2026-02-10 13:16:04 -08:00
Zimeng Xiong 1117dc584e resolve e2e 2026-02-07 19:24:00 -08:00
Zimeng Xiong 70103e18fb sign CSRF with cookie, Login rate-limit key hardened against identifier-only lockout 2026-02-07 18:52:00 -08:00
Zimeng Xiong fd013de325 add tests on refactor 2026-02-07 18:03:05 -08:00
Zimeng Xiong 6bee0e2ded refactor index.ts 2026-02-07 17:47:41 -08:00
Zimeng Xiong 35bbbb9599 images in preview 2026-02-07 17:21:58 -08:00
Zimeng Xiong 2aa749a2f0 prevent preview updates from overwriting drawings 2026-02-07 15:51:35 -08:00
Zimeng Xiong 02736d663a chore: pre-release v0.4.6-dev 2026-02-07 12:46:00 -08:00
Zimeng Xiong de254d46f2 concurrency 2026-02-07 12:45:33 -08:00
Zimeng Xiong dd0f381ed1 chore: pre-release v0.4.5-dev 2026-02-07 12:09:21 -08:00
Zimeng Xiong c40a5f46a0 fix colliding drawing IDs 2026-02-07 12:09:02 -08:00
Zimeng Xiong 8fcca43b0d chore: pre-release v0.4.4-dev 2026-02-07 11:58:09 -08:00
Zimeng Xiong f20412cdfb separate debounced autosave 2026-02-07 11:57:32 -08:00
Zimeng Xiong a366acfedc chore: pre-release v0.4.3-dev 2026-02-07 11:08:03 -08:00
Zimeng Xiong 154dcbb151 update resopnsiveness hamburger 2026-02-07 11:07:15 -08:00
Zimeng Xiong 2e74d2ad1a chore: pre-release v0.4.2-dev 2026-02-07 10:34:36 -08:00
Zimeng Xiong 173c050f58 fix HTTPS reuqirement when frontend URL is nto HTTPS 2026-02-07 10:31:08 -08:00
Zimeng Xiong 8161a563f0 chore: pre-release v0.4.1-dev 2026-02-07 10:08:27 -08:00
110 changed files with 21149 additions and 5994 deletions
@@ -0,0 +1 @@
../../../frontend
+53
View File
@@ -6,6 +6,8 @@
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com)
_Original repo can be found [here](https://github.com/ZimengXiong/ExcaliDash)_
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
## Screenshots
@@ -101,6 +103,8 @@ docker compose -f docker-compose.prod.yml up -d
For single-container deployments, `JWT_SECRET` can be omitted and will be auto-generated and persisted in the backend volume on first start. For portability and all multi-instance deployments, set a fixed `JWT_SECRET` explicitly.
By default, the provided Compose files set `TRUST_PROXY=false` for safer setup. Only set `TRUST_PROXY` to a positive hop count (for example, `1`) when requests always pass through a trusted reverse proxy that correctly sets forwarded headers.
## Docker Build
[Install Docker](https://docs.docker.com/desktop/)
@@ -123,6 +127,7 @@ docker compose up -d
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one trusted reverse proxy hop (for example: frontend nginx -> backend) and forwarded headers are sanitized. This ensures rate limiting and logging use the real client IP from trusted proxy headers.
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
```yaml
@@ -131,6 +136,8 @@ backend:
environment:
# Single URL
- FRONTEND_URL=https://excalidash.example.com
# Trust exactly one reverse-proxy hop
- TRUST_PROXY=1
# Or multiple URLs (comma-separated) for local + network access
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
frontend:
@@ -160,6 +167,31 @@ backend:
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
### Authentication Modes (Local + OIDC)
ExcaliDash supports three auth modes via backend `AUTH_MODE`:
- `local` (default): native email/password login only.
- `hybrid`: native login + OIDC login.
- `oidc_enforced`: OIDC-only login (native login/register disabled).
For OIDC modes (`hybrid` or `oidc_enforced`), set:
```yaml
backend:
environment:
- AUTH_MODE=oidc_enforced
- OIDC_PROVIDER_NAME=Authentik
- OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
- OIDC_CLIENT_ID=your-client-id
- OIDC_CLIENT_SECRET=your-client-secret
- OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
- OIDC_SCOPES=openid profile email
```
In `oidc_enforced` mode, unauthenticated users are automatically redirected to `/api/auth/oidc/start`.
Users are linked by `(issuer, sub)` first, then by verified email, and optionally auto-provisioned.
# Development
## Clone the Repository
@@ -200,6 +232,27 @@ npx prisma db push
npm run dev
```
### Simulate Auth Onboarding (Development)
To simulate first-run authentication choice flows in local development:
```bash
cd ExcaliDash/backend
# Preview what would change (no data modifications)
npm run dev:simulate-auth-onboarding:dry-run
# Simulate "fresh install" onboarding state
# (wipes drawings/collections/libraries and removes non-bootstrap users)
npm run dev:simulate-auth-onboarding:fresh
# Simulate "migration" onboarding state (ensures legacy data exists)
npm run dev:simulate-auth-onboarding:migration
```
After running a simulation while the backend is already running, wait about 5 seconds
(auth mode cache TTL) or restart the backend before refreshing the UI.
## Project Structure
```
+1 -1
View File
@@ -1 +1 @@
0.4.1
0.4.6
+18 -1
View File
@@ -2,7 +2,11 @@
PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
FRONTEND_URL=https://draw.louiscreates.com
API_BASE_PATH=/api
# Keep disabled unless traffic always comes through a trusted reverse proxy.
TRUST_PROXY=false
AUTH_MODE=local
JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility)
@@ -10,3 +14,16 @@ JWT_SECRET=change-this-secret-in-production-min-32-chars
# ENABLE_PASSWORD_RESET=false
# ENABLE_REFRESH_TOKEN_ROTATION=false
# ENABLE_AUDIT_LOGGING=false
# OIDC Configuration (required when AUTH_MODE=hybrid or AUTH_MODE=oidc_enforced)
# OIDC_PROVIDER_NAME=Authentik
# OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
# OIDC_CLIENT_ID=your-client-id
# OIDC_CLIENT_SECRET=your-client-secret
# OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
# OIDC_SCOPES=openid profile email
# OIDC_EMAIL_CLAIM=email
# OIDC_EMAIL_VERIFIED_CLAIM=email_verified
# OIDC_REQUIRE_EMAIL_VERIFIED=true
# OIDC_JIT_PROVISIONING=true
# OIDC_FIRST_USER_ADMIN=true
+24 -1
View File
@@ -2,6 +2,7 @@
set -e
JWT_SECRET_FILE="/app/prisma/.jwt_secret"
CSRF_SECRET_FILE="/app/prisma/.csrf_secret"
# Ensure JWT secret exists for production startup.
# Backward compatibility: older installs may not have JWT_SECRET configured.
@@ -25,6 +26,27 @@ fi
export JWT_SECRET
# Ensure CSRF secret exists for stable token validation across restarts.
# (Still recommend setting explicitly for multi-instance deployments.)
if [ -z "${CSRF_SECRET:-}" ]; then
echo "CSRF_SECRET not provided, resolving persisted secret..."
if [ -f "${CSRF_SECRET_FILE}" ]; then
CSRF_SECRET="$(tr -d '\r\n' < "${CSRF_SECRET_FILE}")"
fi
if [ -z "${CSRF_SECRET}" ]; then
echo "No persisted CSRF secret found. Generating a new secret..."
CSRF_SECRET="$(openssl rand -base64 32)"
umask 077
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
fi
else
umask 077
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
fi
export CSRF_SECRET
# 1. Hydrate volume if empty (Running as root)
if [ ! -f "/app/prisma/schema.prisma" ]; then
echo "Mount is empty. Hydrating /app/prisma..."
@@ -43,11 +65,12 @@ chown -R nodejs:nodejs /app/uploads
chown -R nodejs:nodejs /app/prisma
chmod 755 /app/uploads
chmod 600 "${JWT_SECRET_FILE}"
chmod 600 "${CSRF_SECRET_FILE}"
# Ensure database file has proper permissions
if [ -f "/app/prisma/dev.db" ]; then
echo "Database file found, ensuring write permissions..."
chmod 666 /app/prisma/dev.db
chmod 600 /app/prisma/dev.db
fi
# 3. Run Migrations (Drop privileges to nodejs)
+63 -2
View File
@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "0.4.0",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "0.4.0",
"version": "0.4.6",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.22.0",
@@ -24,6 +24,7 @@
"jszip": "^3.10.1",
"ms": "^2.1.3",
"multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
@@ -3314,6 +3315,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsdom": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
@@ -3909,6 +3919,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -3932,6 +3951,15 @@
],
"license": "MIT"
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3953,6 +3981,33 @@
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5789,6 +5844,12 @@
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+5 -1
View File
@@ -1,12 +1,15 @@
{
"name": "backend",
"version": "0.4.1",
"version": "0.4.6",
"description": "",
"main": "index.js",
"scripts": {
"predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs",
"dev:simulate-auth-onboarding:fresh": "node scripts/simulate-auth-onboarding.cjs --scenario fresh",
"dev:simulate-auth-onboarding:migration": "node scripts/simulate-auth-onboarding.cjs --scenario migration",
"dev:simulate-auth-onboarding:dry-run": "node scripts/simulate-auth-onboarding.cjs --scenario migration --dry-run",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -31,6 +34,7 @@
"jszip": "^3.10.1",
"ms": "^2.1.3",
"multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
+3783
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
-- Track whether initial auth mode choice has been explicitly completed.
ALTER TABLE "SystemConfig" ADD COLUMN "authOnboardingCompleted" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "AuthIdentity" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"emailAtLink" TEXT NOT NULL,
"lastLoginAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthIdentity_issuer_subject_key" ON "AuthIdentity"("issuer", "subject");
-- CreateIndex
CREATE UNIQUE INDEX "AuthIdentity_provider_userId_key" ON "AuthIdentity"("provider", "userId");
-- CreateIndex
CREATE INDEX "AuthIdentity_userId_idx" ON "AuthIdentity"("userId");
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "DrawingShareLink" (
"id" TEXT NOT NULL PRIMARY KEY,
"drawingId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "DrawingShareGrant" (
"id" TEXT NOT NULL PRIMARY KEY,
"drawingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"shareLinkId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token");
-- CreateIndex
CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId");
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role");
-- CreateIndex
CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId");
-- CreateIndex
CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId");
+54
View File
@@ -21,11 +21,13 @@ model User {
role String @default("USER")
mustResetPassword Boolean @default(false)
isActive Boolean @default(true)
authIdentities AuthIdentity[]
drawings Drawing[]
collections Collection[]
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
drawingShareGrants DrawingShareGrant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -33,6 +35,7 @@ model User {
model SystemConfig {
id String @id @default("default")
authEnabled Boolean @default(false)
authOnboardingCompleted Boolean @default(false)
registrationEnabled Boolean @default(false)
authLoginRateLimitEnabled Boolean @default(true)
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
@@ -65,6 +68,8 @@ model Drawing {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id])
shareLinks DrawingShareLink[]
shareGrants DrawingShareGrant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -72,6 +77,38 @@ model Drawing {
@@index([userId, collectionId, updatedAt])
}
model DrawingShareLink {
id String @id @default(uuid())
drawingId String
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
role String
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
grants DrawingShareGrant[]
@@unique([drawingId, role])
@@index([drawingId])
}
model DrawingShareGrant {
id String @id @default(uuid())
drawingId String
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shareLinkId String
shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
role String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([drawingId, userId, shareLinkId])
@@index([drawingId, userId])
@@index([userId, createdAt])
}
model Library {
id String @id // User-specific library ID (e.g., "user_<userId>")
items String @default("[]") // Stored as JSON string array of library items
@@ -110,3 +147,20 @@ model AuditLog {
details String? // JSON string for additional details
createdAt DateTime @default(now())
}
model AuthIdentity {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
issuer String
subject String
emailAtLink String
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([issuer, subject])
@@unique([provider, userId])
@@index([userId])
}
+23 -3
View File
@@ -24,7 +24,10 @@ const resolveDatabaseUrl = (rawUrl) => {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
: path.resolve(
hasLeadingPrismaDir ? backendRoot : prismaDir,
normalizedRelative,
);
return `file:${absolutePath}`;
};
@@ -91,7 +94,15 @@ const backupDbIfPresent = () => {
const isNonProd = nodeEnv !== "production";
const isFileDb = databaseUrl.startsWith("file:");
const deploy = runCapture("npx prisma migrate deploy");
let deploy = runCapture("npx prisma migrate deploy");
if (!deploy.ok) {
console.warn(
`[predev] Prisma migrate deploy failed. Attempting pnpm exec...`,
);
deploy = runCapture("pnpm exec prisma migrate deploy");
}
if (deploy.ok) {
if (deploy.stdout) process.stdout.write(deploy.stdout);
} else {
@@ -111,7 +122,16 @@ if (deploy.ok) {
` If you need to preserve local data, restore the backup and baseline manually.`,
);
run("npx prisma migrate reset --force --skip-seed");
// check for npx, if not present try pnpm exec
try {
console.log(
`[predev] Running: npx prisma migrate reset --force --skip-seed`,
);
run("npx prisma migrate reset --force --skip-seed");
} catch {
console.warn(`[predev] npx not found, trying pnpm exec...`);
run("pnpm exec prisma migrate reset --force --skip-seed");
}
} else {
throw deploy.error;
}
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env node
require("dotenv").config();
const path = require("path");
const { execSync } = require("child_process");
const { PrismaClient } = require("../src/generated/client");
const BOOTSTRAP_USER_ID = "bootstrap-admin";
const DEFAULT_SYSTEM_CONFIG_ID = "default";
const backendRoot = path.resolve(__dirname, "..");
const resolveDatabaseUrl = (rawUrl) => {
const backendRoot = path.resolve(__dirname, "..");
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
if (!rawUrl || String(rawUrl).trim().length === 0) {
return `file:${defaultDbPath}`;
}
if (!String(rawUrl).startsWith("file:")) {
return String(rawUrl);
}
const filePath = String(rawUrl).replace(/^file:/, "");
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
const parseArgs = (argv) => {
const parsed = {
scenario: "",
dryRun: false,
allowProd: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--scenario") {
parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--dry-run") {
parsed.dryRun = true;
continue;
}
if (token === "--allow-production") {
parsed.allowProd = true;
continue;
}
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
}
return parsed;
};
const usage = () => {
console.log(`Usage:
node scripts/simulate-auth-onboarding.cjs --scenario fresh
node scripts/simulate-auth-onboarding.cjs --scenario migration
Options:
--dry-run Show what would change without modifying data
--allow-production Override production safety check (not recommended)
--help, -h Show this help
`);
};
const assertScenario = (scenario) => {
if (scenario !== "fresh" && scenario !== "migration") {
throw new Error("Invalid --scenario. Use 'fresh' or 'migration'.");
}
};
const nowIso = () => new Date().toISOString();
const run = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
assertScenario(args.scenario);
const nodeEnv = process.env.NODE_ENV || "development";
if (nodeEnv === "production" && !args.allowProd) {
throw new Error(
"Refusing to run in production. Pass --allow-production only if you explicitly intend this."
);
}
// Keep migration history authoritative to avoid drift between db push and deploy.
// Includes a self-heal path for the known duplicate-column failure on
// 20260210153000_add_auth_onboarding_completed in local dev databases.
if (nodeEnv !== "production") {
const runDeploy = () =>
execSync("npx prisma migrate deploy", {
cwd: backendRoot,
stdio: "pipe",
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL,
},
});
try {
runDeploy();
} catch (error) {
const stdout =
error && error.stdout
? Buffer.isBuffer(error.stdout)
? error.stdout.toString("utf8")
: String(error.stdout)
: "";
const stderr =
error && error.stderr
? Buffer.isBuffer(error.stderr)
? error.stderr.toString("utf8")
: String(error.stderr)
: "";
const combined = `${stdout}\n${stderr}`;
const canAutoResolve =
combined.includes("Error: P3009") &&
combined.includes("20260210153000_add_auth_onboarding_completed") &&
combined.includes("duplicate column name: authOnboardingCompleted");
if (!canAutoResolve) {
throw error;
}
execSync(
"npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed",
{
cwd: backendRoot,
stdio: "pipe",
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL,
},
}
);
runDeploy();
}
}
const prisma = new PrismaClient();
try {
const before = {
activeUsers: await prisma.user.count({ where: { isActive: true } }),
users: await prisma.user.count(),
drawings: await prisma.drawing.count(),
collections: await prisma.collection.count(),
auth: await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: {
authEnabled: true,
authOnboardingCompleted: true,
registrationEnabled: true,
},
}),
};
console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`);
console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`);
console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`);
console.log("[simulate-auth-onboarding] before:", before);
if (args.dryRun) {
console.log("[simulate-auth-onboarding] dry-run only. No data changed.");
return;
}
await prisma.$transaction(async (tx) => {
await tx.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
},
create: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
authLoginRateLimitEnabled: true,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
authLoginRateLimitMax: 20,
},
});
await tx.user.updateMany({
data: {
isActive: false,
mustResetPassword: true,
},
});
await tx.user.upsert({
where: { id: BOOTSTRAP_USER_ID },
update: {
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
create: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
});
if (args.scenario === "fresh") {
await tx.drawing.deleteMany({});
await tx.collection.deleteMany({});
await tx.library.deleteMany({});
await tx.user.deleteMany({
where: {
id: {
not: BOOTSTRAP_USER_ID,
},
},
});
return;
}
// Migration simulation:
// 1) Reassign existing data ownership to bootstrap user
// 2) Ensure at least one drawing+collection exists so UI shows migration messaging
await tx.collection.updateMany({
data: { userId: BOOTSTRAP_USER_ID },
});
await tx.drawing.updateMany({
data: { userId: BOOTSTRAP_USER_ID },
});
const collectionCount = await tx.collection.count();
let targetCollectionId = null;
if (collectionCount === 0) {
targetCollectionId = `sim-migration-col-${Date.now()}`;
await tx.collection.create({
data: {
id: targetCollectionId,
name: "Migrated Collection",
userId: BOOTSTRAP_USER_ID,
},
});
} else {
const existing = await tx.collection.findFirst({
where: { userId: BOOTSTRAP_USER_ID },
select: { id: true },
orderBy: { createdAt: "asc" },
});
targetCollectionId = existing ? existing.id : null;
}
const drawingCount = await tx.drawing.count();
if (drawingCount === 0) {
await tx.drawing.create({
data: {
id: `sim-migration-draw-${Date.now()}`,
name: "Migrated Drawing",
elements: "[]",
appState: "{}",
files: "{}",
preview: null,
version: 1,
userId: BOOTSTRAP_USER_ID,
collectionId: targetCollectionId,
},
});
}
});
const after = {
activeUsers: await prisma.user.count({ where: { isActive: true } }),
users: await prisma.user.count(),
drawings: await prisma.drawing.count(),
collections: await prisma.collection.count(),
auth: await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: {
authEnabled: true,
authOnboardingCompleted: true,
registrationEnabled: true,
},
}),
};
console.log("[simulate-auth-onboarding] after:", after);
console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`);
console.log(
"[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI."
);
} finally {
await prisma.$disconnect().catch(() => {});
}
};
run().catch((error) => {
console.error("simulate-auth-onboarding failed:", error.message || error);
process.exitCode = 1;
});
@@ -0,0 +1,139 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import bcrypt from "bcrypt";
import jwt, { SignOptions } from "jsonwebtoken";
import { StringValue } from "ms";
import { PrismaClient } from "../generated/client";
import { config } from "../config";
import { getTestPrisma, setupTestDb } from "./testUtils";
describe("Auth Enabled Toggle Authorization", () => {
const userAgent = "vitest-auth-enabled";
let prisma: PrismaClient;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
let regularUserToken: string;
let adminUserToken: string;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
({ app } = await import("../index"));
await prisma.systemConfig.upsert({
where: { id: "default" },
update: {
authEnabled: true,
registrationEnabled: false,
},
create: {
id: "default",
authEnabled: true,
registrationEnabled: false,
},
});
const passwordHash = await bcrypt.hash("password123", 10);
const user = await prisma.user.create({
data: {
email: "regular-user@test.local",
passwordHash,
name: "Regular User",
role: "USER",
isActive: true,
},
select: {
id: true,
email: true,
},
});
const signOptions: SignOptions = {
expiresIn: config.jwtAccessExpiresIn as StringValue,
};
regularUserToken = jwt.sign(
{ userId: user.id, email: user.email, type: "access" },
config.jwtSecret,
signOptions
);
const admin = await prisma.user.create({
data: {
email: "admin-user@test.local",
passwordHash,
name: "Admin User",
role: "ADMIN",
isActive: true,
},
select: {
id: true,
email: true,
},
});
adminUserToken = jwt.sign(
{ userId: admin.id, email: admin.email, type: "access" },
config.jwtSecret,
signOptions
);
agent = request.agent(app);
const csrfRes = await agent
.get("/csrf-token")
.set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
});
afterAll(async () => {
await prisma.$disconnect();
});
it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => {
const response = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enabled: false });
expect(response.status).toBe(401);
});
it("rejects non-admin auth-enabled toggle", async () => {
const response = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set("Authorization", `Bearer ${regularUserToken}`)
.set(csrfHeaderName, csrfToken)
.send({ enabled: false });
expect(response.status).toBe(403);
expect(response.body?.message).toContain("Admin access required");
});
it("applies auth mode change immediately for subsequent requests", async () => {
const warmStatusResponse = await request(app)
.get("/auth/status")
.set("User-Agent", userAgent);
expect(warmStatusResponse.status).toBe(200);
expect(warmStatusResponse.body?.authEnabled).toBe(true);
const toggleResponse = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set("Authorization", `Bearer ${adminUserToken}`)
.set(csrfHeaderName, csrfToken)
.send({ enabled: false });
expect(toggleResponse.status).toBe(200);
expect(toggleResponse.body?.authEnabled).toBe(false);
const drawingsResponse = await request(app)
.get("/drawings")
.set("User-Agent", userAgent);
expect(drawingsResponse.status).toBe(200);
expect(Array.isArray(drawingsResponse.body?.drawings)).toBe(true);
});
});
@@ -0,0 +1,164 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { PrismaClient } from "../generated/client";
import { getTestPrisma, setupTestDb } from "./testUtils";
import { BOOTSTRAP_USER_ID } from "../auth/authMode";
describe("Auth onboarding decision", () => {
const userAgent = "vitest-auth-onboarding";
let prisma: PrismaClient;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
({ app } = await import("../index"));
agent = request.agent(app);
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
});
afterAll(async () => {
await prisma.$disconnect();
});
it("reports migration onboarding mode when no active users and legacy data exists", async () => {
await prisma.user.upsert({
where: { id: BOOTSTRAP_USER_ID },
update: {},
create: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
});
await prisma.systemConfig.upsert({
where: { id: "default" },
update: { authEnabled: false, authOnboardingCompleted: false },
create: {
id: "default",
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
},
});
await prisma.collection.upsert({
where: { id: "legacy-collection" },
update: {},
create: {
id: "legacy-collection",
name: "Legacy",
userId: BOOTSTRAP_USER_ID,
},
});
await prisma.drawing.upsert({
where: { id: "legacy-drawing" },
update: {},
create: {
id: "legacy-drawing",
name: "Legacy Drawing",
elements: "[]",
appState: "{}",
files: "{}",
userId: BOOTSTRAP_USER_ID,
collectionId: "legacy-collection",
},
});
const response = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(response.status).toBe(200);
expect(response.body?.authEnabled).toBe(false);
expect(response.body?.authOnboardingRequired).toBe(true);
expect(response.body?.authOnboardingMode).toBe("migration");
});
it("persists a single-user onboarding choice", async () => {
await prisma.systemConfig.update({
where: { id: "default" },
data: { authEnabled: false, authOnboardingCompleted: false },
});
const choiceResponse = await agent
.post("/auth/onboarding-choice")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enableAuth: false });
expect(choiceResponse.status).toBe(200);
expect(choiceResponse.body?.authEnabled).toBe(false);
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
});
it("enables auth and bootstrap flow from onboarding choice", async () => {
await prisma.drawing.deleteMany({});
await prisma.collection.deleteMany({ where: { id: { not: `trash:${BOOTSTRAP_USER_ID}` } } });
await prisma.systemConfig.update({
where: { id: "default" },
data: { authEnabled: false, authOnboardingCompleted: false },
});
const choiceResponse = await agent
.post("/auth/onboarding-choice")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enableAuth: true });
expect(choiceResponse.status).toBe(200);
expect(choiceResponse.body?.authEnabled).toBe(true);
expect(choiceResponse.body?.bootstrapRequired).toBe(true);
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body?.authEnabled).toBe(true);
expect(statusResponse.body?.bootstrapRequired).toBe(true);
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
});
it("requires CSRF token for bootstrap registration", async () => {
const noCsrfResponse = await agent
.post("/auth/register")
.set("User-Agent", userAgent)
.send({
email: "bootstrap-admin@test.local",
password: "StrongPass1!",
name: "Bootstrap Admin",
});
expect(noCsrfResponse.status).toBe(403);
expect(noCsrfResponse.body?.error).toBe("CSRF token missing");
const bootstrapResponse = await agent
.post("/auth/register")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({
email: "bootstrap-admin@test.local",
password: "StrongPass1!",
name: "Bootstrap Admin",
});
expect(bootstrapResponse.status).toBe(201);
expect(bootstrapResponse.body?.bootstrapped).toBe(true);
expect(bootstrapResponse.body?.user?.email).toBe("bootstrap-admin@test.local");
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { createCsrfToken, validateCsrfToken } from "../security";
describe("CSRF client identity stability", () => {
it("keeps token validation stable when using cookie-based client IDs", () => {
const cookieClientId = "cookie:fixed-client-id";
const token = createCsrfToken(cookieClientId);
expect(validateCsrfToken(cookieClientId, token)).toBe(true);
});
it("shows why legacy IP-based IDs are unstable across proxy hops", () => {
const userAgent = "Mozilla/5.0 test";
const clientIdViaProxyA = `10.0.0.5:${userAgent}`;
const clientIdViaProxyB = `10.0.0.6:${userAgent}`;
const token = createCsrfToken(clientIdViaProxyA);
expect(validateCsrfToken(clientIdViaProxyB, token)).toBe(false);
});
});
@@ -267,6 +267,90 @@ describe("Security Sanitization - Image Data URLs", () => {
});
});
describe("sanitizeDrawingData - preview svg handling", () => {
it("should preserve safe SVG layout attributes needed for thumbnail rendering", () => {
const preview = [
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 728.39453125 606.908203125" width="1456.7890625" height="1213.81640625" preserveAspectRatio="xMidYMid meet">',
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#ffffff"></rect>',
'<path d="M0 0 L20 20" stroke="#000" stroke-linecap="round"></path>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain('viewBox="0 0 728.39453125 606.908203125"');
expect(result.preview).toContain('preserveAspectRatio="xMidYMid meet"');
expect(result.preview).toContain('stroke-linecap="round"');
expect(result.preview).toContain('xmlns="http://www.w3.org/2000/svg"');
});
it("should preserve safe embedded image previews", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="data:image/png;base64,AAAA"></image>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<image");
expect(result.preview).toContain('href="data:image/png;base64,AAAA"');
});
it("should remove embedded images with unsafe href values", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="javascript:alert(1)"></image>',
'<rect x="0" y="0" width="10" height="10" fill="#000"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).not.toContain("<image");
expect(result.preview).toContain("<rect");
});
it("should preserve safe defs/pattern image structures used by Excalidraw exports", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
'<defs><pattern id="p1" width="1" height="1" patternUnits="objectBoundingBox">',
'<image href="data:image/png;base64,AAAA" width="100" height="100"></image>',
"</pattern></defs>",
'<rect x="0" y="0" width="100" height="100" fill="url(#p1)"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<defs>");
expect(result.preview).toContain("<pattern");
expect(result.preview).toContain('id="p1"');
expect(result.preview).toContain("<image");
expect(result.preview).toContain('fill="url(#p1)"');
});
});
describe("validateImportedDrawing - with files", () => {
it("should validate drawing with embedded images", () => {
const files = createSampleFilesObject(2, "large");
@@ -3,6 +3,7 @@ import request from "supertest";
import fs from "fs";
import path from "path";
import os from "os";
import JSZip from "jszip";
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
type LegacyDbOptions = {
@@ -156,11 +157,117 @@ const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
return filePath;
};
const createExcalidashArchiveWithDuplicateDrawingIds = async (): Promise<string> => {
const dir = createTempDir();
const filePath = path.join(dir, "duplicate-drawing-ids.excalidash");
const zip = new JSZip();
const manifest = {
format: "excalidash",
formatVersion: 1,
exportedAt: new Date().toISOString(),
unorganizedFolder: "Unorganized",
collections: [] as any[],
drawings: [
{
id: "duplicate-drawing-id",
name: "Drawing One",
filePath: "Unorganized/drawing-1.excalidraw",
collectionId: null,
},
{
id: "duplicate-drawing-id",
name: "Drawing Two",
filePath: "Unorganized/drawing-2.excalidraw",
collectionId: null,
},
],
};
zip.file("excalidash.manifest.json", JSON.stringify(manifest));
zip.file(
"Unorganized/drawing-1.excalidraw",
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
);
zip.file(
"Unorganized/drawing-2.excalidraw",
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
);
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(filePath, buffer);
return filePath;
};
const createLegacySqliteDbWithDuplicateDrawingIds = (): string => {
const dir = createTempDir();
const filePath = path.join(dir, "legacy-duplicate-ids.db");
const db = openWritableDb(filePath);
try {
db.exec(`
CREATE TABLE "Drawing" (
id TEXT,
name TEXT NOT NULL,
elements TEXT NOT NULL,
appState TEXT NOT NULL,
files TEXT,
preview TEXT,
version INTEGER,
collectionId TEXT,
collectionName TEXT,
createdAt TEXT,
updatedAt TEXT
);
`);
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
const insertDrawing = db.prepare(
`INSERT INTO "Drawing"
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
insertDrawing.run(
"legacy-duplicate-id",
"Legacy Drawing A",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
null,
null,
now,
now,
);
insertDrawing.run(
"legacy-duplicate-id",
"Legacy Drawing B",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
null,
null,
now,
now,
);
} finally {
db.close();
}
return filePath;
};
describe("Import compatibility (legacy exports)", () => {
const uploadsDir = path.resolve(__dirname, "../../uploads");
const userAgent = "vitest-import-compat";
let prisma: ReturnType<typeof getTestPrisma>;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
@@ -172,7 +279,8 @@ describe("Import compatibility (legacy exports)", () => {
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
({ app } = await import("../index"));
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent);
agent = request.agent(app);
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
expect(typeof csrfHeaderName).toBe("string");
@@ -195,7 +303,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: false,
});
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -217,7 +325,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: true,
});
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -239,7 +347,9 @@ describe("Import compatibility (legacy exports)", () => {
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
);
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
const trash = await prisma.collection.findUnique({
where: { id: "trash:bootstrap-admin" },
});
expect(trash).toBeTruthy();
});
@@ -251,7 +361,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: false,
});
const verify = await request(app)
const verify = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -261,7 +371,7 @@ describe("Import compatibility (legacy exports)", () => {
expect(verify.body.drawings).toBe(2);
expect(verify.body.collections).toBe(1);
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -278,7 +388,7 @@ describe("Import compatibility (legacy exports)", () => {
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
db.close();
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -287,4 +397,52 @@ describe("Import compatibility (legacy exports)", () => {
expect(res.status).toBe(400);
expect(res.body.error).toBe("Invalid legacy DB");
});
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await agent
.post("/import/excalidash/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("archive", archive);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await agent
.post("/import/excalidash")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("archive", archive);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await agent
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { sanitizeDrawingUpdateData } from "../index";
describe("sanitizeDrawingUpdateData regression", () => {
it("does not inject empty scene fields for preview-only updates", () => {
const payload: {
preview?: string | null;
elements?: unknown[];
appState?: Record<string, unknown>;
files?: Record<string, unknown>;
} = {
preview: "<svg><rect width=\"10\" height=\"10\"/></svg>",
};
const ok = sanitizeDrawingUpdateData(payload);
expect(ok).toBe(true);
expect(typeof payload.preview).toBe("string");
expect(String(payload.preview)).toContain("<svg");
expect(payload.elements).toBeUndefined();
expect(payload.appState).toBeUndefined();
expect(payload.files).toBeUndefined();
});
it("still sanitizes scene fields when scene data is provided", () => {
const payload: {
preview?: string | null;
elements?: any[];
appState?: Record<string, unknown>;
files?: Record<string, unknown>;
} = {
elements: [
{
id: "el-1",
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
version: 1,
versionNonce: 1,
isDeleted: false,
},
],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview: "<svg/>",
};
const ok = sanitizeDrawingUpdateData(payload);
expect(ok).toBe(true);
expect(Array.isArray(payload.elements)).toBe(true);
expect(typeof payload.appState).toBe("object");
});
});
+6 -7
View File
@@ -98,11 +98,9 @@ export const setupTestDb = () => {
* Clean up the test database between tests
*/
export const cleanupTestDb = async (prisma: PrismaClient) => {
// Delete all drawings and collections (except Trash)
// Delete all drawings and collections.
await prisma.drawing.deleteMany({});
await prisma.collection.deleteMany({
where: { id: { not: "trash" } },
});
await prisma.collection.deleteMany({});
};
/**
@@ -129,14 +127,15 @@ export const createTestUser = async (prisma: PrismaClient, email: string = "test
export const initTestDb = async (prisma: PrismaClient) => {
// Create a test user first
const testUser = await createTestUser(prisma);
const trashCollectionId = `trash:${testUser.id}`;
// Ensure Trash collection exists
const trash = await prisma.collection.findUnique({
where: { id: "trash" },
const trash = await prisma.collection.findFirst({
where: { id: trashCollectionId, userId: testUser.id },
});
if (!trash) {
await prisma.collection.create({
data: { id: "trash", name: "Trash", userId: testUser.id },
data: { id: trashCollectionId, name: "Trash", userId: testUser.id },
});
}
+402 -308
View File
File diff suppressed because it is too large Load Diff
+30 -5
View File
@@ -11,6 +11,7 @@ import {
updateEmailSchema,
updateProfileSchema,
} from "./schemas";
import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity";
type RegisterAccountRoutesDeps = {
router: express.Router;
@@ -33,6 +34,12 @@ type RegisterAccountRoutesDeps = {
options?: { impersonatorId?: string }
) => { accessToken: string; refreshToken: string };
getRefreshTokenExpiresAt: () => Date;
setAuthCookies: (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => void;
requireCsrf: (req: Request, res: Response) => boolean;
};
export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
@@ -47,6 +54,8 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
config,
generateTokens,
getRefreshTokenExpiresAt,
setAuthCookies,
requireCsrf,
} = deps;
router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => {
@@ -81,7 +90,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
});
await prisma.passwordResetToken.create({
data: { userId: user.id, token: resetToken, expiresAt },
data: { userId: user.id, token: hashTokenForStorage(resetToken), expiresAt },
});
if (config.enableAuditLogging) {
@@ -137,8 +146,10 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
}
const { token, password } = parsed.data;
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
const resetToken = await prisma.passwordResetToken.findFirst({
where: {
OR: getTokenLookupCandidates(token).map((candidate) => ({ token: candidate })),
},
include: { user: true },
});
@@ -207,6 +218,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.put("/profile", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({
error: "Unauthorized",
@@ -258,6 +270,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
@@ -344,11 +357,16 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
}
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
try {
await prisma.refreshToken.create({
data: { userId: updatedUser.id, token: refreshToken, expiresAt },
data: {
userId: updatedUser.id,
token: hashTokenForStorage(refreshToken),
expiresAt,
},
});
} catch {
if (process.env.NODE_ENV === "development") {
@@ -380,6 +398,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
@@ -456,6 +475,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
@@ -521,11 +541,16 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
}
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
try {
await prisma.refreshToken.create({
data: { userId: updatedUser.id, token: refreshToken, expiresAt },
data: {
userId: updatedUser.id,
token: hashTokenForStorage(refreshToken),
expiresAt,
},
});
} catch {
if (process.env.NODE_ENV === "development") {
+119 -5
View File
@@ -11,6 +11,7 @@ import {
loginRateLimitUpdateSchema,
registrationToggleSchema,
} from "./schemas";
import { hashTokenForStorage } from "./tokenSecurity";
type RegisterAdminRoutesDeps = {
router: express.Router;
@@ -63,6 +64,12 @@ type RegisterAdminRoutesDeps = {
enableRefreshTokenRotation: boolean;
};
defaultSystemConfigId: string;
setAuthCookies: (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => void;
requireCsrf: (req: Request, res: Response) => boolean;
};
export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
@@ -85,11 +92,56 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
getRefreshTokenExpiresAt,
config,
defaultSystemConfigId,
setAuthCookies,
requireCsrf,
} = deps;
const resolveImpersonationAdmin = async (req: Request, res: Response) => {
if (!req.user) {
res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
return null;
}
if (req.user.role === "ADMIN") {
return {
id: req.user.id,
email: req.user.email,
name: req.user.name,
};
}
if (!req.user.impersonatorId) {
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
return null;
}
const impersonator = await prisma.user.findUnique({
where: { id: req.user.impersonatorId },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
},
});
if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") {
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
return null;
}
return {
id: impersonator.id,
email: impersonator.email,
name: impersonator.name,
};
};
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = registrationToggleSchema.safeParse(req.body);
@@ -116,6 +168,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = adminRoleUpdateSchema.safeParse(req.body);
@@ -199,6 +252,42 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
}
});
router.get("/impersonation-targets", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
const actingAdmin = await resolveImpersonationAdmin(req, res);
if (!actingAdmin) return;
const users = await prisma.user.findMany({
where: { isActive: true, id: { not: actingAdmin.id } },
orderBy: [{ name: "asc" }, { email: "asc" }],
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
isActive: true,
},
});
res.json({
users,
impersonator: {
id: actingAdmin.id,
email: actingAdmin.email,
name: actingAdmin.name,
},
});
} catch (error) {
console.error("List impersonation targets error:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to list impersonation targets",
});
}
});
router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
@@ -219,6 +308,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = loginRateLimitUpdateSchema.safeParse(req.body);
@@ -264,6 +354,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = loginRateLimitResetSchema.safeParse(req.body);
@@ -301,10 +392,21 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = adminCreateUserSchema.safeParse(req.body);
if (!parsed.success) {
const summarizedIssues = parsed.error.issues.map((issue) => ({
code: issue.code,
path: issue.path.join("."),
message: issue.message,
}));
console.warn("[auth/users] validation failed", {
issues: summarizedIssues,
requestId: req.headers["x-request-id"],
ip: req.ip || req.connection.remoteAddress || "unknown",
});
return res.status(400).json({
error: "Validation error",
message: "Invalid user payload",
@@ -385,6 +487,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const userId = String(req.params.id || "").trim();
@@ -493,6 +596,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
if (req.user.impersonatorId) {
@@ -582,7 +686,9 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireAdmin(req, res)) return;
if (!requireCsrf(req, res)) return;
const actingAdmin = await resolveImpersonationAdmin(req, res);
if (!actingAdmin) return;
const parsed = impersonateSchema.safeParse(req.body);
if (!parsed.success) {
@@ -598,19 +704,27 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
return res.status(404).json({ error: "Not found", message: "User not found" });
}
if (target.id === actingAdmin.id) {
return res.status(409).json({
error: "Conflict",
message: "Already using the admin account. Use stop impersonation to return.",
});
}
if (!target.isActive) {
return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" });
}
const { accessToken, refreshToken } = generateTokens(target.id, target.email, {
impersonatorId: req.user.id,
impersonatorId: actingAdmin.id,
});
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
try {
await prisma.refreshToken.create({
data: { userId: target.id, token: refreshToken, expiresAt },
data: { userId: target.id, token: hashTokenForStorage(refreshToken), expiresAt },
});
} catch {
if (process.env.NODE_ENV === "development") {
@@ -621,12 +735,12 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
if (config.enableAuditLogging) {
await logAuditEvent({
userId: req.user.id,
userId: actingAdmin.id,
action: "impersonation_started",
resource: `user:${target.id}`,
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
details: { targetUserId: target.id },
details: { targetUserId: target.id, initiatedFromImpersonation: Boolean(req.user?.impersonatorId) },
});
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { PrismaClient } from "../generated/client";
import {
BOOTSTRAP_USER_ID,
DEFAULT_SYSTEM_CONFIG_ID,
createAuthModeService,
} from "./authMode";
const createPrismaMock = () =>
({
systemConfig: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
user: {
upsert: vi.fn(),
},
}) as unknown as PrismaClient;
describe("authMode service", () => {
let now = 1_000_000;
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caches authEnabled reads within TTL", async () => {
const prisma = createPrismaMock();
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
findUnique
.mockResolvedValueOnce({ authEnabled: true })
.mockResolvedValueOnce({ authEnabled: false });
const service = createAuthModeService(prisma, { authEnabledTtlMs: 5000 });
await expect(service.getAuthEnabled()).resolves.toBe(true);
now += 1000;
await expect(service.getAuthEnabled()).resolves.toBe(true);
expect(findUnique).toHaveBeenCalledTimes(1);
now += 6000;
await expect(service.getAuthEnabled()).resolves.toBe(false);
expect(findUnique).toHaveBeenCalledTimes(2);
expect(upsert).not.toHaveBeenCalled();
});
it("clears auth cache when requested", async () => {
const prisma = createPrismaMock();
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
findUnique.mockResolvedValue({ authEnabled: true });
const service = createAuthModeService(prisma);
await service.getAuthEnabled();
service.clearAuthEnabledCache();
await service.getAuthEnabled();
expect(findUnique).toHaveBeenCalledTimes(2);
expect(upsert).not.toHaveBeenCalled();
});
it("falls back to upsert when system config row is missing", async () => {
const prisma = createPrismaMock();
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
findUnique.mockResolvedValue(null);
upsert.mockResolvedValue({ authEnabled: false });
const service = createAuthModeService(prisma);
await expect(service.getAuthEnabled()).resolves.toBe(false);
expect(findUnique).toHaveBeenCalledTimes(1);
expect(upsert).toHaveBeenCalledTimes(1);
});
it("creates/bootstrap user via upsert", async () => {
const prisma = createPrismaMock();
const userUpsert = prisma.user.upsert as unknown as ReturnType<typeof vi.fn>;
userUpsert.mockResolvedValue({
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
name: "Bootstrap Admin",
role: "ADMIN",
isActive: false,
mustResetPassword: true,
username: null,
});
const service = createAuthModeService(prisma);
const bootstrapUser = await service.getBootstrapActingUser();
expect(bootstrapUser.id).toBe(BOOTSTRAP_USER_ID);
expect(userUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: BOOTSTRAP_USER_ID },
create: expect.objectContaining({
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
role: "ADMIN",
}),
})
);
});
it("ensures system config defaults", async () => {
const prisma = createPrismaMock();
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
upsert.mockResolvedValue({ authEnabled: false });
const service = createAuthModeService(prisma);
await service.ensureSystemConfig();
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
create: expect.objectContaining({
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
registrationEnabled: false,
}),
})
);
});
});

Some files were not shown because too many files have changed in this diff Show More