test: stabilize e2e auth and rate limits
This commit is contained in:
@@ -108,7 +108,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# Start backend server in background
|
# Start backend server in background
|
||||||
cd backend
|
cd backend
|
||||||
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
|
NODE_ENV="test" DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
@@ -150,7 +150,11 @@ jobs:
|
|||||||
|
|
||||||
exit $TEST_EXIT_CODE
|
exit $TEST_EXIT_CODE
|
||||||
env:
|
env:
|
||||||
|
AUTH_USERNAME: admin
|
||||||
|
AUTH_PASSWORD: admin123
|
||||||
DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db
|
DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db
|
||||||
|
CSRF_MAX_REQUESTS: "10000"
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: "20000"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
prisma/dev.db
|
prisma/dev.db
|
||||||
prisma/dev.db-journal
|
prisma/dev.db-journal
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-*
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ if [ -f "/app/prisma/dev.db" ]; then
|
|||||||
chmod 666 /app/prisma/dev.db
|
chmod 666 /app/prisma/dev.db
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Optionally reset the database (used for E2E runs)
|
||||||
|
if [ "${RESET_DB_ON_START}" = "true" ]; then
|
||||||
|
DB_PATH="${DATABASE_URL#file:}"
|
||||||
|
if [ "$DB_PATH" != "$DATABASE_URL" ]; then
|
||||||
|
echo "Resetting database at ${DB_PATH}..."
|
||||||
|
rm -f "${DB_PATH}" "${DB_PATH}-journal" "${DB_PATH}-wal" "${DB_PATH}-shm"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 3. Run Migrations (Drop privileges to nodejs)
|
# 3. Run Migrations (Drop privileges to nodejs)
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
su-exec nodejs npx prisma migrate deploy
|
su-exec nodejs npx prisma migrate deploy
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ describe("Authentication flows", () => {
|
|||||||
setupTestDb();
|
setupTestDb();
|
||||||
prisma = getTestPrisma();
|
prisma = getTestPrisma();
|
||||||
await initTestDb(prisma);
|
await initTestDb(prisma);
|
||||||
const appModule = await import("../index");
|
const appModule = (await import("../index")) as { default: unknown };
|
||||||
app = appModule.default || appModule.app || appModule;
|
app = appModule.default;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -51,7 +51,9 @@ describe("Authentication flows", () => {
|
|||||||
.set("x-csrf-token", token)
|
.set("x-csrf-token", token)
|
||||||
.send({ username: "admin", password: "password123" });
|
.send({ username: "admin", password: "password123" });
|
||||||
|
|
||||||
return login.headers["set-cookie"] as string[] | undefined;
|
const cookies = login.headers["set-cookie"];
|
||||||
|
if (!cookies) return undefined;
|
||||||
|
return Array.isArray(cookies) ? cookies : [cookies];
|
||||||
};
|
};
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -804,6 +804,55 @@ app.post("/auth/password", async (req, res) => {
|
|||||||
return res.json(authChangePasswordResponse(updated));
|
return res.json(authChangePasswordResponse(updated));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/auth/test/must-reset", async (req, res) => {
|
||||||
|
if (process.env.NODE_ENV !== "test") {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Not found",
|
||||||
|
message: "Endpoint is only available in test environments.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: "Unauthorized",
|
||||||
|
message: "Authentication required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { id: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentUser || currentUser.role !== "ADMIN") {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Forbidden",
|
||||||
|
message: "Admin privileges required.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadSchema = z.object({ enabled: z.boolean() });
|
||||||
|
const parsed = payloadSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid payload",
|
||||||
|
message: "Expected { enabled: boolean }.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: currentUser.id },
|
||||||
|
data: { mustResetPassword: parsed.data.enabled },
|
||||||
|
select: { id: true, username: true, email: true, role: true, mustResetPassword: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
return res.json({
|
||||||
|
user: toAuthUserWithResetFlag(updated as AuthenticatedUser & { mustResetPassword: boolean }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/auth/register", async (req, res) => {
|
app.post("/auth/register", async (req, res) => {
|
||||||
const config = await getSystemConfig();
|
const config = await getSystemConfig();
|
||||||
const existingUsers = await prisma.user.count();
|
const existingUsers = await prisma.user.count();
|
||||||
@@ -1930,11 +1979,16 @@ const ensureTrashCollection = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldEnsureInitialAdmin =
|
||||||
|
process.env.NODE_ENV !== "test" && process.env.SKIP_INITIAL_ADMIN !== "true";
|
||||||
|
|
||||||
httpServer.listen(PORT, async () => {
|
httpServer.listen(PORT, async () => {
|
||||||
await initializeUploadDir();
|
await initializeUploadDir();
|
||||||
await ensureTrashCollection();
|
await ensureTrashCollection();
|
||||||
await ensureSystemConfig();
|
await ensureSystemConfig();
|
||||||
|
if (shouldEnsureInitialAdmin) {
|
||||||
await ensureInitialAdminUser();
|
await ensureInitialAdminUser();
|
||||||
|
}
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ services:
|
|||||||
- DATABASE_URL=file:/app/prisma/e2e-test.db
|
- DATABASE_URL=file:/app/prisma/e2e-test.db
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- NODE_ENV=test
|
- NODE_ENV=test
|
||||||
|
- RESET_DB_ON_START=true
|
||||||
# Include both with and without :80 because browsers omit default ports in Origin.
|
# Include both with and without :80 because browsers omit default ports in Origin.
|
||||||
- FRONTEND_URL=http://frontend,http://frontend:80,http://localhost:5173
|
- FRONTEND_URL=http://frontend,http://frontend:80,http://localhost:5173
|
||||||
ports:
|
ports:
|
||||||
@@ -70,7 +71,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=http://frontend:80
|
- BASE_URL=http://frontend:80
|
||||||
- API_URL=http://backend:8000
|
- API_URL=http://frontend:80/api
|
||||||
- NO_SERVER=true
|
- NO_SERVER=true
|
||||||
- CI=true
|
- CI=true
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,21 @@ import {
|
|||||||
* - Drag multiple selected drawings
|
* - Drag multiple selected drawings
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const waitForDrawingImports = async (page: Page, count: number) => {
|
||||||
|
const waiters = Array.from({ length: count }, () =>
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes("/api/drawings") &&
|
||||||
|
response.request().method() === "POST"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all(waiters);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
test.describe("Drag and Drop - Collections", () => {
|
test.describe("Drag and Drop - Collections", () => {
|
||||||
let createdDrawingIds: string[] = [];
|
let createdDrawingIds: string[] = [];
|
||||||
let createdCollectionIds: string[] = [];
|
let createdCollectionIds: string[] = [];
|
||||||
@@ -272,17 +288,15 @@ test.describe("Drag and Drop - File Import", () => {
|
|||||||
|
|
||||||
// Find the hidden file input and upload the file
|
// Find the hidden file input and upload the file
|
||||||
const fileInput = page.locator("#dashboard-import");
|
const fileInput = page.locator("#dashboard-import");
|
||||||
|
const importResponses = waitForDrawingImports(page, 1);
|
||||||
await fileInput.setInputFiles(fixturePath);
|
await fileInput.setInputFiles(fixturePath);
|
||||||
|
await importResponses;
|
||||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
|
||||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Search for the imported drawing (it uses the filename as name)
|
// Search for the imported drawing (it uses the filename as name)
|
||||||
await page.getByPlaceholder("Search drawings...").fill("small-image");
|
await page.getByPlaceholder("Search drawings...").fill("small-image");
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Verify at least one drawing was imported
|
// Verify at least one drawing was imported
|
||||||
const importedCards = page.locator("[id^='drawing-card-']");
|
const importedCards = page.locator("[id^='drawing-card-']");
|
||||||
await expect(importedCards.first()).toBeVisible();
|
await expect(importedCards.first()).toBeVisible({ timeout: 30000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -388,9 +388,16 @@ test.describe("Drawing Deletion", () => {
|
|||||||
// Card should be gone
|
// Card should be gone
|
||||||
await expect(card).not.toBeVisible();
|
await expect(card).not.toBeVisible();
|
||||||
|
|
||||||
// Verify via API that drawing is deleted
|
// Verify via API that drawing is deleted (allow a short window for backend completion)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
const response = await request.get(`${API_URL}/drawings/${drawing.id}`);
|
const response = await request.get(`${API_URL}/drawings/${drawing.id}`);
|
||||||
expect(response.status()).toBe(404);
|
return response.status();
|
||||||
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
)
|
||||||
|
.toBe(404);
|
||||||
|
|
||||||
// Remove from cleanup list since it's already deleted
|
// Remove from cleanup list since it's already deleted
|
||||||
createdDrawingIds = createdDrawingIds.filter(id => id !== drawing.id);
|
createdDrawingIds = createdDrawingIds.filter(id => id !== drawing.id);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
API_URL,
|
API_URL,
|
||||||
createDrawing,
|
createDrawing,
|
||||||
@@ -20,6 +21,21 @@ import {
|
|||||||
* - Import database backup
|
* - Import database backup
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const waitForDrawingImports = async (page: Page, count: number) => {
|
||||||
|
const waiters = Array.from({ length: count }, () =>
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes("/api/drawings") &&
|
||||||
|
response.request().method() === "POST"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all(waiters);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
test.describe("Export Functionality", () => {
|
test.describe("Export Functionality", () => {
|
||||||
let createdDrawingIds: string[] = [];
|
let createdDrawingIds: string[] = [];
|
||||||
let createdCollectionIds: string[] = [];
|
let createdCollectionIds: string[] = [];
|
||||||
@@ -214,24 +230,18 @@ test.describe.serial("Import Functionality", () => {
|
|||||||
const fileInput = page.locator("#dashboard-import");
|
const fileInput = page.locator("#dashboard-import");
|
||||||
|
|
||||||
// Create a buffer from the fixture content
|
// Create a buffer from the fixture content
|
||||||
|
const importResponses = waitForDrawingImports(page, 1);
|
||||||
await fileInput.setInputFiles({
|
await fileInput.setInputFiles({
|
||||||
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
|
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
|
||||||
mimeType: "application/json",
|
mimeType: "application/json",
|
||||||
buffer: Buffer.from(fixtureContent),
|
buffer: Buffer.from(fixtureContent),
|
||||||
});
|
});
|
||||||
|
await importResponses;
|
||||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
|
||||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Reload to ensure dashboard state reflects the newly imported drawing
|
|
||||||
await page.reload({ waitUntil: "networkidle" });
|
|
||||||
|
|
||||||
// Verify the drawing was imported - the drawing name is the filename without extension
|
// Verify the drawing was imported - the drawing name is the filename without extension
|
||||||
await page.getByPlaceholder("Search drawings...").fill("Import_ExcalidrawTest");
|
await page.getByPlaceholder("Search drawings...").fill("Import_ExcalidrawTest");
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
const importedCards = page.locator("[id^='drawing-card-']");
|
const importedCards = page.locator("[id^='drawing-card-']");
|
||||||
await expect(importedCards.first()).toBeVisible({ timeout: 10000 });
|
await expect(importedCards.first()).toBeVisible({ timeout: 30000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should import JSON drawing file from Dashboard", async ({ page }) => {
|
test("should import JSON drawing file from Dashboard", async ({ page }) => {
|
||||||
@@ -281,31 +291,18 @@ test.describe.serial("Import Functionality", () => {
|
|||||||
|
|
||||||
const fileInput = page.locator("#dashboard-import");
|
const fileInput = page.locator("#dashboard-import");
|
||||||
|
|
||||||
|
const importResponses = waitForDrawingImports(page, 1);
|
||||||
await fileInput.setInputFiles({
|
await fileInput.setInputFiles({
|
||||||
name: `${testName}.json`,
|
name: `${testName}.json`,
|
||||||
mimeType: "application/json",
|
mimeType: "application/json",
|
||||||
buffer: Buffer.from(jsonContent),
|
buffer: Buffer.from(jsonContent),
|
||||||
});
|
});
|
||||||
|
await importResponses;
|
||||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
|
||||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Check if upload failed (shows "Failed" text in the upload status)
|
|
||||||
const failedIndicator = page.getByText("Failed");
|
|
||||||
if (await failedIndicator.isVisible()) {
|
|
||||||
console.log("Import failed - skipping rest of test");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload to force a fresh fetch of drawings after import
|
|
||||||
await page.reload({ waitUntil: "networkidle" });
|
|
||||||
|
|
||||||
// Clear any existing search and search for the imported drawing
|
// Clear any existing search and search for the imported drawing
|
||||||
const searchInput = page.getByPlaceholder("Search drawings...");
|
const searchInput = page.getByPlaceholder("Search drawings...");
|
||||||
await searchInput.clear();
|
await searchInput.clear();
|
||||||
await searchInput.fill(testName);
|
await searchInput.fill(testName);
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
// Wait for the card to appear - the drawing should be visible in the UI
|
// Wait for the card to appear - the drawing should be visible in the UI
|
||||||
const importedCards = page.locator("[id^='drawing-card-']");
|
const importedCards = page.locator("[id^='drawing-card-']");
|
||||||
await expect(importedCards.first()).toBeVisible({ timeout: 15000 });
|
await expect(importedCards.first()).toBeVisible({ timeout: 15000 });
|
||||||
@@ -326,10 +323,8 @@ test.describe.serial("Import Functionality", () => {
|
|||||||
buffer: Buffer.from(invalidContent),
|
buffer: Buffer.from(invalidContent),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for upload to complete and check for failure indicator
|
// Should show error modal for invalid file
|
||||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 });
|
||||||
// Should show "Failed" status in the upload status component
|
|
||||||
await expect(page.getByText("Failed")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should import multiple drawings at once", async ({ page }) => {
|
test("should import multiple drawings at once", async ({ page }) => {
|
||||||
@@ -364,17 +359,15 @@ test.describe.serial("Import Functionality", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const fileInput = page.locator("#dashboard-import");
|
const fileInput = page.locator("#dashboard-import");
|
||||||
|
const importResponses = waitForDrawingImports(page, files.length);
|
||||||
await fileInput.setInputFiles(files);
|
await fileInput.setInputFiles(files);
|
||||||
|
await importResponses;
|
||||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
|
||||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify both were imported by searching for the unique prefix
|
// Verify both were imported by searching for the unique prefix
|
||||||
await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
|
await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const importedCards = page.locator("[id^='drawing-card-']");
|
const importedCards = page.locator("[id^='drawing-card-']");
|
||||||
await expect(importedCards).toHaveCount(2);
|
await expect(importedCards).toHaveCount(2, { timeout: 30000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { PrismaClient } from "../../backend/src/generated/client";
|
|
||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
||||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
|
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
|
||||||
const DATABASE_URL = process.env.DATABASE_URL;
|
|
||||||
|
|
||||||
const ensureLoggedOut = async (page: Page) => {
|
const ensureLoggedOut = async (page: Page) => {
|
||||||
await page.context().clearCookies();
|
await page.context().clearCookies();
|
||||||
@@ -57,33 +56,62 @@ const ensureDashboard = async (page: Page) => {
|
|||||||
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
|
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setMustResetPassword = async (enabled: boolean) => {
|
type CsrfInfo = {
|
||||||
if (!DATABASE_URL) {
|
token: string;
|
||||||
throw new Error("DATABASE_URL is not set for e2e test.");
|
headerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCsrfInfo = async (page: Page): Promise<CsrfInfo> => {
|
||||||
|
const response = await page.request.get(`${BASE_URL}/api/csrf-token`, {
|
||||||
|
headers: { origin: BASE_URL },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch CSRF token: ${response.status()} ${text || "(empty response)"}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const data = (await response.json()) as { token: string; header?: string };
|
||||||
datasources: {
|
if (!data || typeof data.token !== "string" || data.token.trim().length === 0) {
|
||||||
db: { url: DATABASE_URL },
|
throw new Error("Failed to fetch CSRF token: missing token in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerName =
|
||||||
|
typeof data.header === "string" && data.header.trim().length > 0
|
||||||
|
? data.header
|
||||||
|
: "x-csrf-token";
|
||||||
|
|
||||||
|
return { token: data.token, headerName };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMustResetPassword = async (page: Page, enabled: boolean) => {
|
||||||
|
const csrfInfo = await fetchCsrfInfo(page);
|
||||||
|
let response = await page.request.post(`${BASE_URL}/api/auth/test/must-reset`, {
|
||||||
|
headers: {
|
||||||
|
origin: BASE_URL,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
[csrfInfo.headerName]: csrfInfo.token,
|
||||||
},
|
},
|
||||||
|
data: { enabled },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (!response.ok() && response.status() === 403) {
|
||||||
const admin = await prisma.user.findFirst({
|
const refreshed = await fetchCsrfInfo(page);
|
||||||
where: { username: AUTH_USERNAME },
|
response = await page.request.post(`${BASE_URL}/api/auth/test/must-reset`, {
|
||||||
select: { id: true },
|
headers: {
|
||||||
|
origin: BASE_URL,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
[refreshed.headerName]: refreshed.token,
|
||||||
|
},
|
||||||
|
data: { enabled },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!admin) {
|
|
||||||
throw new Error(`Admin user ${AUTH_USERNAME} not found.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
if (!response.ok()) {
|
||||||
where: { id: admin.id },
|
const text = await response.text();
|
||||||
data: { mustResetPassword: enabled },
|
throw new Error(`Failed to toggle mustResetPassword: ${response.status()} ${text}`);
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +135,7 @@ test.describe("Admin password reset", () => {
|
|||||||
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
|
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await setMustResetPassword(true);
|
await setMustResetPassword(page, true);
|
||||||
await ensureLoggedOut(page);
|
await ensureLoggedOut(page);
|
||||||
|
|
||||||
await login(page, AUTH_PASSWORD);
|
await login(page, AUTH_PASSWORD);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
import { importDrawings } from '../utils/importUtils';
|
import { importDrawings, type ImportResult } from '../utils/importUtils';
|
||||||
|
|
||||||
export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error';
|
export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error';
|
||||||
|
|
||||||
@@ -11,9 +11,35 @@ export interface UploadTask {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generateUploadId = (): string => {
|
||||||
|
const cryptoObj: Crypto | undefined =
|
||||||
|
typeof globalThis !== "undefined"
|
||||||
|
? globalThis.crypto || (globalThis as any).msCrypto
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (cryptoObj?.randomUUID) {
|
||||||
|
return cryptoObj.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cryptoObj?.getRandomValues) {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
cryptoObj.getRandomValues(bytes);
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // RFC 4122 version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||||
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
||||||
|
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
||||||
|
.slice(6, 8)
|
||||||
|
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `upload-${Date.now().toString(16)}-${Math.random()
|
||||||
|
.toString(16)
|
||||||
|
.slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
interface UploadContextType {
|
interface UploadContextType {
|
||||||
tasks: UploadTask[];
|
tasks: UploadTask[];
|
||||||
uploadFiles: (files: File[], targetCollectionId: string | null) => Promise<void>;
|
uploadFiles: (files: File[], targetCollectionId: string | null) => Promise<ImportResult>;
|
||||||
clearCompleted: () => void;
|
clearCompleted: () => void;
|
||||||
removeTask: (id: string) => void;
|
removeTask: (id: string) => void;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
@@ -48,7 +74,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
|
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
|
||||||
const newTasks: UploadTask[] = files.map(f => ({
|
const newTasks: UploadTask[] = files.map(f => ({
|
||||||
id: crypto.randomUUID(),
|
id: generateUploadId(),
|
||||||
fileName: f.name,
|
fileName: f.name,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0
|
progress: 0
|
||||||
@@ -68,13 +94,18 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await importDrawings(files, targetCollectionId, undefined, handleProgress);
|
return await importDrawings(files, targetCollectionId, undefined, handleProgress);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Global upload error", e);
|
console.error("Global upload error", e);
|
||||||
// Mark all new tasks as error if something crashed completely
|
// Mark all new tasks as error if something crashed completely
|
||||||
newTasks.forEach(t => {
|
newTasks.forEach(t => {
|
||||||
updateTask(t.id, { status: 'error', error: 'Upload failed unexpectedly' });
|
updateTask(t.id, { status: 'error', error: 'Upload failed unexpectedly' });
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
success: 0,
|
||||||
|
failed: newTasks.length,
|
||||||
|
errors: ['Upload failed unexpectedly'],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [updateTask]);
|
}, [updateTask]);
|
||||||
|
|
||||||
|
|||||||
@@ -306,10 +306,20 @@ export const Dashboard: React.FC = () => {
|
|||||||
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
||||||
|
|
||||||
// Use the global upload context
|
// Use the global upload context
|
||||||
uploadFiles(fileArray, targetCollectionId).finally(() => {
|
try {
|
||||||
|
const result = await uploadFiles(fileArray, targetCollectionId);
|
||||||
|
if (result.failed > 0) {
|
||||||
|
setShowImportError({
|
||||||
|
isOpen: true,
|
||||||
|
message: result.errors.length > 0
|
||||||
|
? result.errors.join("\n")
|
||||||
|
: "Some files failed to import.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
// Refresh after all uploads complete (success or failure)
|
// Refresh after all uploads complete (success or failure)
|
||||||
refreshData();
|
refreshData();
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameDrawing = async (id: string, name: string) => {
|
const handleRenameDrawing = async (id: string, name: string) => {
|
||||||
@@ -532,10 +542,20 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
|
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
|
||||||
if (drawingFiles.length > 0) {
|
if (drawingFiles.length > 0) {
|
||||||
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
|
try {
|
||||||
refreshData();
|
const result = await uploadFiles(drawingFiles, targetCollectionId);
|
||||||
|
if (result.failed > 0) {
|
||||||
|
setShowImportError({
|
||||||
|
isOpen: true,
|
||||||
|
message: result.errors.length > 0
|
||||||
|
? result.errors.join("\n")
|
||||||
|
: "Some files failed to import.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
refreshData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { exportToSvg } from "@excalidraw/excalidraw";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { type UploadStatus } from "../context/UploadContext";
|
import { type UploadStatus } from "../context/UploadContext";
|
||||||
|
|
||||||
|
export type ImportResult = {
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const importDrawings = async (
|
export const importDrawings = async (
|
||||||
files: File[],
|
files: File[],
|
||||||
targetCollectionId: string | null,
|
targetCollectionId: string | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user