Migrate to Vite 7, improve UI (Copy/Download), and enhance API security

This commit is contained in:
2026-01-31 10:16:49 +01:00
parent 452e6e74cb
commit d398c34aa5
14 changed files with 2012 additions and 14637 deletions

View File

@@ -14,31 +14,16 @@ COPY package*.json ./
# Install dependencies (production + dev for build)
RUN npm ci
# Copy source code and build scripts
# Copy source code and build dependencies
COPY src/ ./src/
COPY public/ ./public/
COPY scripts/ ./scripts/
COPY server.js ./server.js
COPY vite.config.js ./vite.config.js
COPY index.html ./index.html
# Generate version.js if version info provided, otherwise run normal build
RUN if [ -n "$VERSION" ]; then \
echo "// Auto-generated version file - do not edit manually" > src/version.js && \
echo "// Generated at: $(date -Iseconds)" >> src/version.js && \
echo "" >> src/version.js && \
echo "export const VERSION = '$VERSION';" >> src/version.js && \
echo "export const IS_RELEASE = $IS_RELEASE;" >> src/version.js && \
echo "export const BUILD_TIME = '$(date -Iseconds)';" >> src/version.js && \
echo "📝 Generated version.js with VERSION=$VERSION, IS_RELEASE=$IS_RELEASE"; \
fi
# Build the application (skip prebuild if we already generated version.js)
RUN if [ -n "$VERSION" ]; then \
echo "🚀 Building with pre-generated version.js" && \
npx react-scripts build; \
else \
echo "🚀 Building with version-check.js" && \
npm run build; \
fi
# Build the application
RUN npm run build
# Production stage
FROM node:24-alpine AS production

View File

@@ -2,12 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="JMESPath Testing Tool - Validate and test JMESPath expressions against JSON data" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700&family=Noto+Sans+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
@@ -16,5 +15,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

16128
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,18 @@
{
"name": "jmespath-playground",
"version": "1.2.5",
"version": "1.3.0",
"description": "A React-based web application for testing JMESPath expressions against JSON data",
"main": "index.js",
"scripts": {
"start": "react-scripts start",
"start": "vite",
"prebuild": "node scripts/version-check.js",
"build": "react-scripts build",
"test": "react-scripts test --watchAll=false",
"test:watch": "react-scripts test",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"server": "node server.js",
"dev": "concurrently \"npm start\" \"npm run server\"",
"build-image": "node scripts/build-image.js"
},
"proxy": "http://localhost:3000",
"engines": {
"node": ">=24.0.0"
},
@@ -26,21 +25,8 @@
"jmespath": "^0.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "^5.0.1",
"uuid": "^9.0.0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/index.js"
]
},
"browserslist": {
"production": [
">0.2%",
@@ -63,7 +49,12 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.18",
"concurrently": "^8.2.2",
"supertest": "^7.2.2"
"jsdom": "^27.4.0",
"supertest": "^7.2.2",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

326
server.js
View File

@@ -1,9 +1,9 @@
const express = require('express');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { v4: uuidv4 } = require('uuid');
const { parseArgs } = require('util');
const express = require("express");
const path = require("path");
const crypto = require("crypto");
const os = require("os");
const { v4: uuidv4 } = require("uuid");
const { parseArgs } = require("util");
// Environment configuration
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 100;
@@ -11,54 +11,29 @@ const MAX_SAMPLE_SIZE = parseInt(process.env.MAX_SAMPLE_SIZE) || 1024 * 1024; //
const MAX_SESSION_TTL = parseInt(process.env.MAX_SESSION_TTL) || 60 * 60 * 1000; // 1 hour
// Utility functions for encryption
function generateSalt() {
return crypto.randomBytes(16);
}
function isLocalhostRequest(req) {
// Get client IP with fallback options
const forwarded = req.get('X-Forwarded-For');
const ip = forwarded ? forwarded.split(',')[0].trim() :
req.ip ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
'127.0.0.1';
const host = req.get('host') || '';
// Check for localhost IP addresses (IPv4 and IPv6)
const localhostIPs = ['127.0.0.1', '::1', '::ffff:127.0.0.1', 'localhost'];
const isLocalIP = localhostIPs.includes(ip) || ip.startsWith('127.') || ip === '::1';
// Check for localhost hostnames
const isLocalHost = host.startsWith('localhost:') || host.startsWith('127.0.0.1:') || host === 'localhost' || host === '127.0.0.1';
return isLocalIP || isLocalHost;
}
function encrypt(data, key) {
try {
const algorithm = 'aes-256-gcm';
const algorithm = "aes-256-gcm";
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
cipher.setAAD(Buffer.from('session-data'));
cipher.setAAD(Buffer.from("session-data"));
let encrypted = cipher.update(JSON.stringify(data), 'utf8');
let encrypted = cipher.update(JSON.stringify(data), "utf8");
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
return {
iv: iv.toString('hex'),
data: encrypted.toString('hex'),
tag: authTag.toString('hex')
iv: iv.toString("hex"),
data: encrypted.toString("hex"),
tag: authTag.toString("hex"),
};
} catch (error) {
console.error('⚠️ Encryption exception:', {
console.error("⚠️ Encryption exception:", {
message: error.message,
algorithm: 'aes-256-gcm',
keyLength: key ? key.length : 'undefined',
timestamp: new Date().toISOString()
algorithm: "aes-256-gcm",
keyLength: key ? key.length : "undefined",
timestamp: new Date().toISOString(),
});
throw new Error(`Encryption failed: ${error.message}`);
}
@@ -66,39 +41,40 @@ function encrypt(data, key) {
function decrypt(encryptedObj, key) {
try {
const algorithm = 'aes-256-gcm';
const iv = Buffer.from(encryptedObj.iv, 'hex');
const algorithm = "aes-256-gcm";
const iv = Buffer.from(encryptedObj.iv, "hex");
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAAD(Buffer.from('session-data'));
decipher.setAuthTag(Buffer.from(encryptedObj.tag, 'hex'));
decipher.setAAD(Buffer.from("session-data"));
decipher.setAuthTag(Buffer.from(encryptedObj.tag, "hex"));
let decrypted = decipher.update(Buffer.from(encryptedObj.data, 'hex'), null, 'utf8');
decrypted += decipher.final('utf8');
let decrypted = decipher.update(
Buffer.from(encryptedObj.data, "hex"),
null,
"utf8",
);
decrypted += decipher.final("utf8");
return JSON.parse(decrypted);
} catch (error) {
console.error('⚠️ Decryption exception:', {
console.error("⚠️ Decryption exception:", {
message: error.message,
algorithm: 'aes-256-gcm',
keyLength: key ? key.length : 'undefined',
algorithm: "aes-256-gcm",
keyLength: key ? key.length : "undefined",
hasIV: !!encryptedObj.iv,
hasTag: !!encryptedObj.tag,
hasData: !!encryptedObj.data,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
throw new Error(`Decryption failed: ${error.message}`);
}
}
// For localhost requests, use a consistent API key so sessions persist
const LOCALHOST_API_KEY = 'localhost0123456789abcdef0123456789';
function isValidApiKey(apiKey) {
return typeof apiKey === 'string' && /^[0-9a-f]{32}$/i.test(apiKey);
return typeof apiKey === "string" && /^[0-9a-f]{32}$/i.test(apiKey);
}
function getSessionId(apiKey) {
return crypto.createHash('sha256').update(apiKey).digest('hex');
return crypto.createHash("sha256").update(apiKey).digest("hex");
}
function generateSalt() {
@@ -106,7 +82,7 @@ function generateSalt() {
}
function deriveKey(apiKey, salt) {
return crypto.pbkdf2Sync(apiKey, salt, 10000, 32, 'sha256');
return crypto.pbkdf2Sync(apiKey, salt, 100000, 32, "sha256");
}
// Create Express app
@@ -114,24 +90,24 @@ function createApp(devMode = false) {
const app = express();
// Trust proxy to get real client IP (needed for localhost detection)
app.set('trust proxy', true);
app.set("trust proxy", true);
// Middleware
app.use(express.json({ limit: MAX_SAMPLE_SIZE }));
app.use(express.static(path.join(__dirname, 'build')));
app.use(express.static(path.join(__dirname, "build")));
// Dev mode request logging middleware
if (devMode) {
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`📨 [${timestamp}] ${req.method} ${req.path}`);
if (req.method !== 'GET' && Object.keys(req.body).length > 0) {
const bodySize = Buffer.byteLength(JSON.stringify(req.body), 'utf8');
if (req.method !== "GET" && Object.keys(req.body).length > 0) {
const bodySize = Buffer.byteLength(JSON.stringify(req.body), "utf8");
console.log(` Request body size: ${(bodySize / 1024).toFixed(2)}KB`);
}
const originalJson = res.json;
res.json = function(data) {
res.json = function (data) {
console.log(` ✓ Response: ${res.statusCode}`);
return originalJson.call(this, data);
};
@@ -148,7 +124,9 @@ function createApp(devMode = false) {
for (const [sessionId, session] of sessions.entries()) {
if (now - session.createdAt > MAX_SESSION_TTL) {
sessions.delete(sessionId);
console.log(`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`);
console.log(
`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`,
);
}
}
}
@@ -157,22 +135,15 @@ function createApp(devMode = false) {
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
// API endpoints
app.post('/api/v1/upload', (req, res) => {
app.post("/api/v1/upload", (req, res) => {
try {
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
const apiKey = req.headers["x-api-key"];
if (!isFromLocalhost) {
// Validate API key header for remote clients
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
// Cleanup expired sessions before checking limits
@@ -181,26 +152,26 @@ function createApp(devMode = false) {
// Check session limits
if (sessions.size >= MAX_SESSIONS) {
return res.status(429).json({
error: 'Maximum number of sessions reached. Please try again later.',
error: "Maximum number of sessions reached. Please try again later.",
maxSessions: MAX_SESSIONS,
currentSessions: sessions.size
currentSessions: sessions.size,
});
}
const uploadedData = req.body;
// Validate that it's valid JSON
if (!uploadedData || typeof uploadedData !== 'object') {
return res.status(400).json({ error: 'Invalid JSON data' });
if (!uploadedData || typeof uploadedData !== "object") {
return res.status(400).json({ error: "Invalid JSON data" });
}
// Check data size
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), 'utf8');
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), "utf8");
if (dataSize > MAX_SAMPLE_SIZE) {
return res.status(413).json({
error: 'Sample data too large',
error: "Sample data too large",
maxSize: MAX_SAMPLE_SIZE,
receivedSize: dataSize
receivedSize: dataSize,
});
}
@@ -213,69 +184,66 @@ function createApp(devMode = false) {
const encryptedData = encrypt(uploadedData, key);
sessions.set(sessionId, {
salt: salt.toString('hex'),
salt: salt.toString("hex"),
encryptedData,
state: stateGuid,
createdAt: Date.now(),
accessed: false
accessed: false,
});
console.log(`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`);
console.log(
`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`,
);
res.json({
message: 'Sample data uploaded successfully',
message: "Sample data uploaded successfully",
state: stateGuid,
sessionId: sessionId.substring(0, 8) + '...'
sessionId: sessionId.substring(0, 8) + "...",
});
} catch (error) {
console.error('⚠️ Upload endpoint exception occurred:', {
console.error("⚠️ Upload endpoint exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
// Provide more specific error messages based on error type
if (error.name === 'SyntaxError') {
if (error.name === "SyntaxError") {
return res.status(400).json({
error: 'Invalid JSON data format',
details: 'The uploaded data could not be parsed as valid JSON'
error: "Invalid JSON data format",
details: "The uploaded data could not be parsed as valid JSON",
});
} else if (error.message.includes('encrypt')) {
} else if (error.message.includes("encrypt")) {
return res.status(500).json({
error: 'Encryption failed',
details: 'Failed to encrypt session data. Please try again with a new API key.'
error: "Encryption failed",
details:
"Failed to encrypt session data. Please try again with a new API key.",
});
} else if (error.message.includes('PBKDF2')) {
} else if (error.message.includes("PBKDF2")) {
return res.status(500).json({
error: 'Key derivation failed',
details: 'Failed to derive encryption key from API key'
error: "Key derivation failed",
details: "Failed to derive encryption key from API key",
});
} else {
return res.status(500).json({
error: 'Upload processing failed',
details: 'An unexpected error occurred while processing your upload. Please try again.'
error: "Upload processing failed",
details:
"An unexpected error occurred while processing your upload. Please try again.",
});
}
}
});
app.get('/api/v1/sample', (req, res) => {
app.get("/api/v1/sample", (req, res) => {
try {
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
const apiKey = req.headers["x-api-key"];
if (!isFromLocalhost) {
// Validate API key header for remote clients
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
const sessionId = getSessionId(apiKey);
@@ -286,64 +254,62 @@ function createApp(devMode = false) {
}
// Decrypt data
const salt = Buffer.from(session.salt, 'hex');
const salt = Buffer.from(session.salt, "hex");
const key = deriveKey(apiKey, salt);
const decryptedData = decrypt(session.encryptedData, key);
// Remove session after first access (one-time use)
sessions.delete(sessionId);
console.log(`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`);
console.log(
`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`,
);
res.json(decryptedData);
} catch (error) {
console.error('⚠️ Sample retrieval exception occurred:', {
console.error("⚠️ Sample retrieval exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
// Provide more specific error messages based on error type
if (error.message.includes('decrypt')) {
if (error.message.includes("decrypt")) {
return res.status(500).json({
error: 'Decryption failed',
details: 'Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.'
error: "Decryption failed",
details:
"Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.",
});
} else if (error.message.includes('JSON')) {
} else if (error.message.includes("JSON")) {
return res.status(500).json({
error: 'Data corruption detected',
details: 'The stored session data appears to be corrupted and cannot be parsed.'
error: "Data corruption detected",
details:
"The stored session data appears to be corrupted and cannot be parsed.",
});
} else if (error.name === 'TypeError') {
} else if (error.name === "TypeError") {
return res.status(500).json({
error: 'Session data format error',
details: 'The session data format is invalid or corrupted.'
error: "Session data format error",
details: "The session data format is invalid or corrupted.",
});
} else {
return res.status(500).json({
error: 'Sample retrieval failed',
details: 'An unexpected error occurred while retrieving sample data. The session may have been corrupted.'
error: "Sample retrieval failed",
details:
"An unexpected error occurred while retrieving sample data. The session may have been corrupted.",
});
}
}
});
app.get('/api/v1/state', (req, res) => {
app.get("/api/v1/state", (req, res) => {
try {
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
const apiKey = req.headers["x-api-key"];
if (!isFromLocalhost) {
// Validate API key header for remote clients
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
const sessionId = getSessionId(apiKey);
@@ -356,55 +322,56 @@ function createApp(devMode = false) {
res.json({ state: session.state });
} catch (error) {
console.error('⚠️ State retrieval exception occurred:', {
console.error("⚠️ State retrieval exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
// Provide more specific error messages
if (error.message.includes('API key')) {
if (error.message.includes("API key")) {
return res.status(403).json({
error: 'API key processing failed',
details: 'Failed to process the provided API key'
error: "API key processing failed",
details: "Failed to process the provided API key",
});
} else {
return res.status(500).json({
error: 'State retrieval failed',
details: 'An unexpected error occurred while retrieving session state. Please try again.'
error: "State retrieval failed",
details:
"An unexpected error occurred while retrieving session state. Please try again.",
});
}
}
});
// Status endpoint (no auth required) - detailed information
app.get('/api/v1/status', (req, res) => {
app.get("/api/v1/status", (req, res) => {
cleanupExpiredSessions(); // Cleanup on status check
res.json({
status: 'healthy',
status: "healthy",
sessions: {
current: sessions.size,
max: MAX_SESSIONS,
available: MAX_SESSIONS - sessions.size
available: MAX_SESSIONS - sessions.size,
},
limits: {
maxSessions: MAX_SESSIONS,
maxSampleSize: MAX_SAMPLE_SIZE,
maxSessionTTL: MAX_SESSION_TTL
maxSessionTTL: MAX_SESSION_TTL,
},
uptime: process.uptime()
uptime: process.uptime(),
});
});
// Health endpoint (no auth required) - simple OK response
app.get('/api/v1/health', (req, res) => {
res.type('text/plain').send('OK');
app.get("/api/v1/health", (req, res) => {
res.type("text/plain").send("OK");
});
// Serve React app for all other routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "build", "index.html"));
});
return app;
@@ -414,16 +381,27 @@ function createApp(devMode = false) {
if (require.main === module) {
const { values } = parseArgs({
options: {
'listen-addr': { type: 'string', short: 'h', default: process.env.LISTEN_ADDR || '127.0.0.1' },
'port': { type: 'string', short: 'p', default: process.env.LISTEN_PORT || '3000' },
'dev': { type: 'boolean', default: process.env.DEV_MODE === 'true' || false }
}
"listen-addr": {
type: "string",
short: "h",
default: process.env.LISTEN_ADDR || "127.0.0.1",
},
port: {
type: "string",
short: "p",
default: process.env.LISTEN_PORT || "3000",
},
dev: {
type: "boolean",
default: process.env.DEV_MODE === "true" || false,
},
},
});
const DEV_MODE = values.dev;
const app = createApp(DEV_MODE);
const PORT = parseInt(values.port);
const HOST = values['listen-addr'];
const HOST = values["listen-addr"];
app.listen(PORT, HOST, () => {
console.log(`JMESPath Playground Server running`);
@@ -432,12 +410,12 @@ if (require.main === module) {
}
// Show actual accessible URLs
if (HOST === '0.0.0.0') {
if (HOST === "0.0.0.0") {
console.log(` Listening on all interfaces:`);
const interfaces = os.networkInterfaces();
for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
if (addr.family === "IPv4" && !addr.internal) {
console.log(` http://${addr.address}:${PORT}`);
}
}
@@ -450,17 +428,24 @@ if (require.main === module) {
console.log(`Configuration:`);
console.log(` Max Sessions: ${MAX_SESSIONS}`);
console.log(` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`);
console.log(` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`);
console.log(
` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`,
);
console.log(
` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`,
);
console.log(
" Security: AES-256-GCM encryption with PBKDF2 (100k iterations)",
);
// Show base API URL
let apiBaseUrl;
if (HOST === '0.0.0.0') {
if (HOST === "0.0.0.0") {
const interfaces = os.networkInterfaces();
let firstIP = '127.0.0.1';
let firstIP = "127.0.0.1";
outer: for (const addrs of Object.values(interfaces)) {
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
if (addr.family === "IPv4" && !addr.internal) {
firstIP = addr.address;
break outer;
}
@@ -472,7 +457,6 @@ if (require.main === module) {
}
console.log(`API Base URL: ${apiBaseUrl}`);
console.log(`Security: AES-256-GCM encryption with PBKDF2 key derivation`);
});
}

View File

@@ -83,22 +83,10 @@ function App() {
// Get headers for API requests
const getApiHeaders = () => {
const headers = {
return {
Accept: "application/json",
"X-API-Key": apiKey,
};
// Only send API key for non-localhost requests
// For localhost, let server use its default LOCALHOST_API_KEY
if (
window.location.hostname !== "localhost" &&
window.location.hostname !== "127.0.0.1" &&
!window.location.hostname.startsWith("127.") &&
window.location.hostname !== "::1"
) {
headers["X-API-Key"] = apiKey;
}
return headers;
};
// Load sample data from API on startup and setup periodic state checking

View File

@@ -1,13 +1,27 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
import { vi } from 'vitest';
import '@testing-library/jest-dom';
// Mock localStorage
const localStorageMock = (function() {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = value.toString(); }),
clear: vi.fn(() => { store = {}; }),
removeItem: vi.fn((key) => { delete store[key]; })
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Mock fetch for API calls
global.fetch = jest.fn();
global.fetch = vi.fn();
describe('App Component', () => {
beforeEach(() => {
fetch.mockClear();
vi.clearAllMocks();
// Mock successful API responses
fetch.mockImplementation((url) => {
if (url.includes('/api/v1/sample')) {

View File

@@ -55,8 +55,7 @@ function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
</button>
</div>
<div className="form-text">
This API key is used to encrypt and authenticate data uploads from remote clients.
<strong>Note:</strong> Requests from localhost (127.0.0.1) do not require an API key.
This API key is used to encrypt and authenticate data uploads.
</div>
</div>
@@ -64,7 +63,7 @@ function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
<h6>📡 Remote Data Upload API</h6>
<p className="text-muted">
External tools can upload sample data remotely using the REST API.
For remote clients, the API key is required for authentication. Define two
The API key is required for authentication. Define two
environment variables in your <code>.bashrc</code>.
</p>
<pre className="bg-light p-3 rounded border">
@@ -80,10 +79,9 @@ function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
"$\{JMESPATH_PLAYGROUND_API_URL}/api/v1/upload"`}</code>
</pre>
<div className="form-text">
Replace <code>{'__JSON_FILE_NAME__'}</code> with the path to your JSON file containing the sample data.
or use <code>-</code> to read from standard input.
<br />
<strong>For localhost clients:</strong> The X-API-Key should be omitted.
Replace <code>{"__JSON_FILE_NAME__"}</code> with the path to your
JSON file containing the sample data. or use <code>-</code> to
read from standard input.
</div>
</div>
</div>

View File

@@ -1,46 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Add TextEncoder/TextDecoder for Node.js compatibility
if (typeof TextEncoder === 'undefined') {
global.TextEncoder = require('util').TextEncoder;
}
if (typeof TextDecoder === 'undefined') {
global.TextDecoder = require('util').TextDecoder;
}
// Mock crypto.getRandomValues for test environment
if (typeof global.crypto === 'undefined') {
global.crypto = {
getRandomValues: (array) => {
// Simple predictable mock for testing
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
}
};
}
// Suppress console errors during tests
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning: ReactDOMTestUtils.act is deprecated') ||
args[0].includes('Warning: An update to App inside a test was not wrapped in act'))
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});

23
vite.config.js Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'build',
},
test: {
globals: true,
environment: 'jsdom',
css: true,
},
});