diff --git a/frontends/nextjs/src/lib/schema/default-schema.ts b/frontends/nextjs/src/lib/schema/default-schema.ts index 398071c44..a01c0a590 100644 --- a/frontends/nextjs/src/lib/schema/default-schema.ts +++ b/frontends/nextjs/src/lib/schema/default-schema.ts @@ -1,308 +1,6 @@ import type { SchemaConfig } from '../types/schema-types' +import { defaultApps } from './default/components' export const defaultSchema: SchemaConfig = { - apps: [ - { - name: 'blog', - label: 'Blog', - models: [ - { - name: 'post', - label: 'Post', - labelPlural: 'Posts', - icon: 'Article', - listDisplay: ['title', 'author', 'status', 'publishedAt'], - listFilter: ['status', 'author'], - searchFields: ['title', 'content'], - ordering: ['-publishedAt'], - fields: [ - { - name: 'id', - type: 'string', - label: 'ID', - required: true, - unique: true, - editable: false, - listDisplay: false, - }, - { - name: 'title', - type: 'string', - label: 'Title', - required: true, - validation: { - minLength: 3, - maxLength: 200, - }, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'slug', - type: 'string', - label: 'Slug', - required: true, - unique: true, - helpText: 'URL-friendly version of the title', - validation: { - pattern: '^[a-z0-9-]+$', - }, - listDisplay: false, - sortable: true, - }, - { - name: 'content', - type: 'text', - label: 'Content', - required: true, - helpText: 'Main post content', - listDisplay: false, - searchable: true, - }, - { - name: 'excerpt', - type: 'text', - label: 'Excerpt', - required: false, - helpText: ['Short summary of the post', 'Used in list views and previews'], - validation: { - maxLength: 500, - }, - listDisplay: false, - }, - { - name: 'author', - type: 'relation', - label: 'Author', - required: true, - relatedModel: 'author', - listDisplay: true, - sortable: true, - }, - { - name: 'status', - type: 'select', - label: 'Status', - required: true, - default: 'draft', - choices: [ - { value: 'draft', label: 'Draft' }, - { value: 'published', label: 'Published' }, - { value: 'archived', label: 'Archived' }, - ], - listDisplay: true, - sortable: true, - }, - { - name: 'featured', - type: 'boolean', - label: 'Featured', - default: false, - helpText: 'Display on homepage', - listDisplay: true, - }, - { - name: 'publishedAt', - type: 'datetime', - label: 'Published At', - required: false, - listDisplay: true, - sortable: true, - }, - { - name: 'tags', - type: 'json', - label: 'Tags', - required: false, - helpText: 'JSON array of tag strings', - listDisplay: false, - }, - { - name: 'views', - type: 'number', - label: 'Views', - default: 0, - validation: { - min: 0, - }, - listDisplay: false, - }, - ], - }, - { - name: 'author', - label: 'Author', - labelPlural: 'Authors', - icon: 'User', - listDisplay: ['name', 'email', 'active', 'createdAt'], - listFilter: ['active'], - searchFields: ['name', 'email'], - ordering: ['name'], - fields: [ - { - name: 'id', - type: 'string', - label: 'ID', - required: true, - unique: true, - editable: false, - listDisplay: false, - }, - { - name: 'name', - type: 'string', - label: 'Name', - required: true, - validation: { - minLength: 2, - maxLength: 100, - }, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'email', - type: 'email', - label: 'Email', - required: true, - unique: true, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'bio', - type: 'text', - label: 'Bio', - required: false, - helpText: 'Author biography', - validation: { - maxLength: 1000, - }, - listDisplay: false, - }, - { - name: 'website', - type: 'url', - label: 'Website', - required: false, - listDisplay: false, - }, - { - name: 'active', - type: 'boolean', - label: 'Active', - default: true, - listDisplay: true, - }, - { - name: 'createdAt', - type: 'datetime', - label: 'Created At', - required: true, - editable: false, - listDisplay: true, - sortable: true, - }, - ], - }, - ], - }, - { - name: 'ecommerce', - label: 'E-Commerce', - models: [ - { - name: 'product', - label: 'Product', - labelPlural: 'Products', - icon: 'ShoppingCart', - listDisplay: ['name', 'price', 'stock', 'available'], - listFilter: ['available', 'category'], - searchFields: ['name', 'description'], - ordering: ['name'], - fields: [ - { - name: 'id', - type: 'string', - label: 'ID', - required: true, - unique: true, - editable: false, - listDisplay: false, - }, - { - name: 'name', - type: 'string', - label: 'Product Name', - required: true, - validation: { - minLength: 3, - maxLength: 200, - }, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'description', - type: 'text', - label: 'Description', - required: false, - helpText: 'Product description', - listDisplay: false, - searchable: true, - }, - { - name: 'price', - type: 'number', - label: 'Price', - required: true, - validation: { - min: 0, - }, - listDisplay: true, - sortable: true, - }, - { - name: 'stock', - type: 'number', - label: 'Stock', - required: true, - default: 0, - validation: { - min: 0, - }, - listDisplay: true, - sortable: true, - }, - { - name: 'category', - type: 'select', - label: 'Category', - required: true, - choices: [ - { value: 'electronics', label: 'Electronics' }, - { value: 'clothing', label: 'Clothing' }, - { value: 'books', label: 'Books' }, - { value: 'home', label: 'Home & Garden' }, - { value: 'toys', label: 'Toys' }, - ], - listDisplay: false, - sortable: true, - }, - { - name: 'available', - type: 'boolean', - label: 'Available', - default: true, - listDisplay: true, - }, - ], - }, - ], - }, - ], + apps: defaultApps, } diff --git a/frontends/nextjs/src/lib/schema/default/components.ts b/frontends/nextjs/src/lib/schema/default/components.ts new file mode 100644 index 000000000..9fe3f02bd --- /dev/null +++ b/frontends/nextjs/src/lib/schema/default/components.ts @@ -0,0 +1,54 @@ +import type { AppSchema, ModelSchema } from '../../types/schema-types' +import { authorFields, postFields, productFields } from './forms' + +export const blogModels: ModelSchema[] = [ + { + name: 'post', + label: 'Post', + labelPlural: 'Posts', + icon: 'Article', + listDisplay: ['title', 'author', 'status', 'publishedAt'], + listFilter: ['status', 'author'], + searchFields: ['title', 'content'], + ordering: ['-publishedAt'], + fields: postFields, + }, + { + name: 'author', + label: 'Author', + labelPlural: 'Authors', + icon: 'User', + listDisplay: ['name', 'email', 'active', 'createdAt'], + listFilter: ['active'], + searchFields: ['name', 'email'], + ordering: ['name'], + fields: authorFields, + }, +] + +export const ecommerceModels: ModelSchema[] = [ + { + name: 'product', + label: 'Product', + labelPlural: 'Products', + icon: 'ShoppingCart', + listDisplay: ['name', 'price', 'stock', 'available'], + listFilter: ['available', 'category'], + searchFields: ['name', 'description'], + ordering: ['name'], + fields: productFields, + }, +] + +export const defaultApps: AppSchema[] = [ + { + name: 'blog', + label: 'Blog', + models: blogModels, + }, + { + name: 'ecommerce', + label: 'E-Commerce', + models: ecommerceModels, + }, +] diff --git a/frontends/nextjs/src/lib/schema/default/forms.ts b/frontends/nextjs/src/lib/schema/default/forms.ts new file mode 100644 index 000000000..81ae491a5 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/default/forms.ts @@ -0,0 +1,244 @@ +import type { FieldSchema } from '../../types/schema-types' +import { authorValidations, postValidations, productValidations } from './validation' + +export const postFields: FieldSchema[] = [ + { + name: 'id', + type: 'string', + label: 'ID', + required: true, + unique: true, + editable: false, + listDisplay: false, + }, + { + name: 'title', + type: 'string', + label: 'Title', + required: true, + validation: postValidations.title, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'slug', + type: 'string', + label: 'Slug', + required: true, + unique: true, + helpText: 'URL-friendly version of the title', + validation: postValidations.slug, + listDisplay: false, + sortable: true, + }, + { + name: 'content', + type: 'text', + label: 'Content', + required: true, + helpText: 'Main post content', + listDisplay: false, + searchable: true, + }, + { + name: 'excerpt', + type: 'text', + label: 'Excerpt', + required: false, + helpText: ['Short summary of the post', 'Used in list views and previews'], + validation: postValidations.excerpt, + listDisplay: false, + }, + { + name: 'author', + type: 'relation', + label: 'Author', + required: true, + relatedModel: 'author', + listDisplay: true, + sortable: true, + }, + { + name: 'status', + type: 'select', + label: 'Status', + required: true, + default: 'draft', + choices: [ + { value: 'draft', label: 'Draft' }, + { value: 'published', label: 'Published' }, + { value: 'archived', label: 'Archived' }, + ], + listDisplay: true, + sortable: true, + }, + { + name: 'featured', + type: 'boolean', + label: 'Featured', + default: false, + helpText: 'Display on homepage', + listDisplay: true, + }, + { + name: 'publishedAt', + type: 'datetime', + label: 'Published At', + required: false, + listDisplay: true, + sortable: true, + }, + { + name: 'tags', + type: 'json', + label: 'Tags', + required: false, + helpText: 'JSON array of tag strings', + listDisplay: false, + }, + { + name: 'views', + type: 'number', + label: 'Views', + default: 0, + validation: postValidations.views, + listDisplay: false, + }, +] + +export const authorFields: FieldSchema[] = [ + { + name: 'id', + type: 'string', + label: 'ID', + required: true, + unique: true, + editable: false, + listDisplay: false, + }, + { + name: 'name', + type: 'string', + label: 'Name', + required: true, + validation: authorValidations.name, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'email', + type: 'email', + label: 'Email', + required: true, + unique: true, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'bio', + type: 'text', + label: 'Bio', + required: false, + helpText: 'Author biography', + validation: authorValidations.bio, + listDisplay: false, + }, + { + name: 'website', + type: 'url', + label: 'Website', + required: false, + listDisplay: false, + }, + { + name: 'active', + type: 'boolean', + label: 'Active', + default: true, + listDisplay: true, + }, + { + name: 'createdAt', + type: 'datetime', + label: 'Created At', + required: true, + editable: false, + listDisplay: true, + sortable: true, + }, +] + +export const productFields: FieldSchema[] = [ + { + name: 'id', + type: 'string', + label: 'ID', + required: true, + unique: true, + editable: false, + listDisplay: false, + }, + { + name: 'name', + type: 'string', + label: 'Product Name', + required: true, + validation: productValidations.name, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'description', + type: 'text', + label: 'Description', + required: false, + helpText: 'Product description', + listDisplay: false, + searchable: true, + }, + { + name: 'price', + type: 'number', + label: 'Price', + required: true, + validation: productValidations.price, + listDisplay: true, + sortable: true, + }, + { + name: 'stock', + type: 'number', + label: 'Stock', + required: true, + default: 0, + validation: productValidations.stock, + listDisplay: true, + sortable: true, + }, + { + name: 'category', + type: 'select', + label: 'Category', + required: true, + choices: [ + { value: 'electronics', label: 'Electronics' }, + { value: 'clothing', label: 'Clothing' }, + { value: 'books', label: 'Books' }, + { value: 'home', label: 'Home & Garden' }, + { value: 'toys', label: 'Toys' }, + ], + listDisplay: false, + sortable: true, + }, + { + name: 'available', + type: 'boolean', + label: 'Available', + default: true, + listDisplay: true, + }, +] diff --git a/frontends/nextjs/src/lib/schema/default/validation.ts b/frontends/nextjs/src/lib/schema/default/validation.ts new file mode 100644 index 000000000..0573520f4 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/default/validation.ts @@ -0,0 +1,19 @@ +import type { FieldSchema } from '../../types/schema-types' + +export const postValidations: Record = { + title: { minLength: 3, maxLength: 200 }, + slug: { pattern: '^[a-z0-9-]+$' }, + excerpt: { maxLength: 500 }, + views: { min: 0 }, +} + +export const authorValidations: Record = { + name: { minLength: 2, maxLength: 100 }, + bio: { maxLength: 1000 }, +} + +export const productValidations: Record = { + name: { minLength: 3, maxLength: 200 }, + price: { min: 0 }, + stock: { min: 0 }, +} diff --git a/frontends/nextjs/src/lib/schema/index.ts b/frontends/nextjs/src/lib/schema/index.ts index 1e68e8671..97056cc3f 100644 --- a/frontends/nextjs/src/lib/schema/index.ts +++ b/frontends/nextjs/src/lib/schema/index.ts @@ -1,3 +1,6 @@ // Schema utilities exports export * from './schema-utils' export { defaultSchema } from './default-schema' +export * from './default/components' +export * from './default/forms' +export * from './default/validation' diff --git a/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts new file mode 100644 index 000000000..a910bea9d --- /dev/null +++ b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' + +import { scanForVulnerabilities, securityScanner } from '@/lib/security-scanner' + +describe('security-scanner detection', () => { + describe('scanJavaScript', () => { + it.each([ + { + name: 'flag eval usage as critical', + code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'), + expectedSeverity: 'critical', + expectedSafe: false, + expectedIssueType: 'dangerous', + expectedIssuePattern: 'eval', + expectedLine: 2, + }, + { + name: 'warn on localStorage usage but stay safe', + code: 'localStorage.setItem("k", "v")', + expectedSeverity: 'low', + expectedSafe: true, + expectedIssueType: 'warning', + expectedIssuePattern: 'localStorage', + }, + { + name: 'return safe for benign code', + code: 'const sum = (a, b) => a + b', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])( + 'should $name', + ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => { + const result = securityScanner.scanJavaScript(code) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + + if (expectedIssueType || expectedIssuePattern) { + const issue = result.issues.find(item => { + const matchesType = expectedIssueType ? item.type === expectedIssueType : true + const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true + return matchesType && matchesPattern + }) + expect(issue).toBeDefined() + if (expectedLine !== undefined) { + expect(issue?.line).toBe(expectedLine) + } + } else { + expect(result.issues.length).toBe(0) + } + + if (expectedSafe) { + expect(result.sanitizedCode).toBe(code) + } else { + expect(result.sanitizedCode).toBeUndefined() + } + } + ) + }) + + describe('scanLua', () => { + it.each([ + { + name: 'flag os.execute usage as critical', + code: 'os.execute("rm -rf /")', + expectedSeverity: 'critical', + expectedSafe: false, + expectedIssueType: 'malicious', + expectedIssuePattern: 'os.execute', + }, + { + name: 'return safe for simple Lua function', + code: 'function add(a, b) return a + b end', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => { + const result = securityScanner.scanLua(code) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + + if (expectedIssueType || expectedIssuePattern) { + const issue = result.issues.find(item => { + const matchesType = expectedIssueType ? item.type === expectedIssueType : true + const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true + return matchesType && matchesPattern + }) + expect(issue).toBeDefined() + } else { + expect(result.issues.length).toBe(0) + } + + if (expectedSafe) { + expect(result.sanitizedCode).toBe(code) + } else { + expect(result.sanitizedCode).toBeUndefined() + } + }) + }) + + describe('scanJSON', () => { + it.each([ + { + name: 'flag invalid JSON as medium severity', + json: '{"value": }', + expectedSeverity: 'medium', + expectedSafe: false, + expectedIssuePattern: 'JSON parse error', + }, + { + name: 'flag prototype pollution in JSON as critical', + json: '{"__proto__": {"polluted": true}}', + expectedSeverity: 'critical', + expectedSafe: false, + expectedIssuePattern: '__proto__', + }, + { + name: 'return safe for valid JSON', + json: '{"ok": true}', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => { + const result = securityScanner.scanJSON(json) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + + if (expectedIssuePattern) { + expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true) + } else { + expect(result.issues.length).toBe(0) + } + + if (expectedSafe) { + expect(result.sanitizedCode).toBe(json) + } else { + expect(result.sanitizedCode).toBeUndefined() + } + }) + }) + + describe('scanHTML', () => { + it.each([ + { + name: 'flag script tags as critical', + html: '
', + expectedSeverity: 'critical', + expectedSafe: false, + }, + { + name: 'flag inline handlers as high', + html: '', + expectedSeverity: 'high', + expectedSafe: false, + }, + { + name: 'return safe for plain markup', + html: '
Safe
', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])('should $name', ({ html, expectedSeverity, expectedSafe }) => { + const result = securityScanner.scanHTML(html) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + }) + }) + + describe('sanitizeInput', () => { + it.each([ + { + name: 'remove script tags and inline handlers from text', + input: '
Click
x', + type: 'text' as const, + shouldExclude: ['', + type: 'html' as const, + shouldExclude: ['data:text/html', ' { + const sanitized = securityScanner.sanitizeInput(input, type) + shouldExclude.forEach(value => { + expect(sanitized).not.toContain(value) + }) + shouldInclude.forEach(value => { + expect(sanitized).toContain(value) + }) + }) + }) + + describe('scanForVulnerabilities', () => { + it.each([ + { + name: 'auto-detects JSON and flags prototype pollution', + code: '{"__proto__": {"polluted": true}}', + expectedSeverity: 'critical', + }, + { + name: 'auto-detects Lua when function/end present', + code: 'function dangerous() os.execute("rm -rf /") end', + expectedSeverity: 'critical', + }, + { + name: 'auto-detects HTML and flags script tags', + code: '
', + expectedSeverity: 'critical', + }, + { + name: 'falls back to JavaScript scanning', + code: 'const result = eval("1 + 1")', + expectedSeverity: 'critical', + }, + { + name: 'honors explicit type parameter', + code: 'return 1', + type: 'lua' as const, + expectedSeverity: 'safe', + }, + ])('should $name', ({ code, type, expectedSeverity }) => { + const result = scanForVulnerabilities(code, type) + expect(result.severity).toBe(expectedSeverity) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts new file mode 100644 index 000000000..42f9a2dd2 --- /dev/null +++ b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { getSeverityColor, getSeverityIcon } from '@/lib/security-scanner' + +describe('security-scanner reporting', () => { + describe('getSeverityColor', () => { + it.each([ + { severity: 'critical', expected: 'error' }, + { severity: 'high', expected: 'warning' }, + { severity: 'medium', expected: 'info' }, + { severity: 'low', expected: 'secondary' }, + { severity: 'safe', expected: 'success' }, + ])('should map $severity to expected classes', ({ severity, expected }) => { + expect(getSeverityColor(severity)).toBe(expected) + }) + }) + + describe('getSeverityIcon', () => { + it.each([ + { severity: 'critical', expected: '\u{1F6A8}' }, + { severity: 'high', expected: '\u26A0\uFE0F' }, + { severity: 'medium', expected: '\u26A1' }, + { severity: 'low', expected: '\u2139\uFE0F' }, + { severity: 'safe', expected: '\u2713' }, + ])('should map $severity to expected icon', ({ severity, expected }) => { + expect(getSeverityIcon(severity)).toBe(expected) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts b/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts index 142a8997b..0e9fcbd80 100644 --- a/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts +++ b/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts @@ -1,257 +1,2 @@ -import { describe, it, expect } from 'vitest' -import { securityScanner, scanForVulnerabilities, getSeverityColor, getSeverityIcon } from '@/lib/security-scanner' - -describe('security-scanner', () => { - describe('scanJavaScript', () => { - it.each([ - { - name: 'flag eval usage as critical', - code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'), - expectedSeverity: 'critical', - expectedSafe: false, - expectedIssueType: 'dangerous', - expectedIssuePattern: 'eval', - expectedLine: 2, - }, - { - name: 'warn on localStorage usage but stay safe', - code: 'localStorage.setItem("k", "v")', - expectedSeverity: 'low', - expectedSafe: true, - expectedIssueType: 'warning', - expectedIssuePattern: 'localStorage', - }, - { - name: 'return safe for benign code', - code: 'const sum = (a, b) => a + b', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])( - 'should $name', - ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => { - const result = securityScanner.scanJavaScript(code) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - - if (expectedIssueType || expectedIssuePattern) { - const issue = result.issues.find(item => { - const matchesType = expectedIssueType ? item.type === expectedIssueType : true - const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true - return matchesType && matchesPattern - }) - expect(issue).toBeDefined() - if (expectedLine !== undefined) { - expect(issue?.line).toBe(expectedLine) - } - } else { - expect(result.issues.length).toBe(0) - } - - if (expectedSafe) { - expect(result.sanitizedCode).toBe(code) - } else { - expect(result.sanitizedCode).toBeUndefined() - } - } - ) - }) - - describe('scanLua', () => { - it.each([ - { - name: 'flag os.execute usage as critical', - code: 'os.execute("rm -rf /")', - expectedSeverity: 'critical', - expectedSafe: false, - expectedIssueType: 'malicious', - expectedIssuePattern: 'os.execute', - }, - { - name: 'return safe for simple Lua function', - code: 'function add(a, b) return a + b end', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => { - const result = securityScanner.scanLua(code) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - - if (expectedIssueType || expectedIssuePattern) { - const issue = result.issues.find(item => { - const matchesType = expectedIssueType ? item.type === expectedIssueType : true - const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true - return matchesType && matchesPattern - }) - expect(issue).toBeDefined() - } else { - expect(result.issues.length).toBe(0) - } - - if (expectedSafe) { - expect(result.sanitizedCode).toBe(code) - } else { - expect(result.sanitizedCode).toBeUndefined() - } - }) - }) - - describe('scanJSON', () => { - it.each([ - { - name: 'flag invalid JSON as medium severity', - json: '{"value": }', - expectedSeverity: 'medium', - expectedSafe: false, - expectedIssuePattern: 'JSON parse error', - }, - { - name: 'flag prototype pollution in JSON as critical', - json: '{"__proto__": {"polluted": true}}', - expectedSeverity: 'critical', - expectedSafe: false, - expectedIssuePattern: '__proto__', - }, - { - name: 'return safe for valid JSON', - json: '{"ok": true}', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => { - const result = securityScanner.scanJSON(json) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - - if (expectedIssuePattern) { - expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true) - } else { - expect(result.issues.length).toBe(0) - } - - if (expectedSafe) { - expect(result.sanitizedCode).toBe(json) - } else { - expect(result.sanitizedCode).toBeUndefined() - } - }) - }) - - describe('scanHTML', () => { - it.each([ - { - name: 'flag script tags as critical', - html: '
', - expectedSeverity: 'critical', - expectedSafe: false, - }, - { - name: 'flag inline handlers as high', - html: '', - expectedSeverity: 'high', - expectedSafe: false, - }, - { - name: 'return safe for plain markup', - html: '
Safe
', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])('should $name', ({ html, expectedSeverity, expectedSafe }) => { - const result = securityScanner.scanHTML(html) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - }) - }) - - describe('sanitizeInput', () => { - it.each([ - { - name: 'remove script tags and inline handlers from text', - input: '
Click
x', - type: 'text' as const, - shouldExclude: ['', - type: 'html' as const, - shouldExclude: ['data:text/html', ' { - const sanitized = securityScanner.sanitizeInput(input, type) - shouldExclude.forEach(value => { - expect(sanitized).not.toContain(value) - }) - shouldInclude.forEach(value => { - expect(sanitized).toContain(value) - }) - }) - }) - - describe('getSeverityColor', () => { - it.each([ - { severity: 'critical', expected: 'error' }, - { severity: 'high', expected: 'warning' }, - { severity: 'medium', expected: 'info' }, - { severity: 'low', expected: 'secondary' }, - { severity: 'safe', expected: 'success' }, - ])('should map $severity to expected classes', ({ severity, expected }) => { - expect(getSeverityColor(severity)).toBe(expected) - }) - }) - - describe('getSeverityIcon', () => { - it.each([ - { severity: 'critical', expected: '\u{1F6A8}' }, - { severity: 'high', expected: '\u26A0\uFE0F' }, - { severity: 'medium', expected: '\u26A1' }, - { severity: 'low', expected: '\u2139\uFE0F' }, - { severity: 'safe', expected: '\u2713' }, - ])('should map $severity to expected icon', ({ severity, expected }) => { - expect(getSeverityIcon(severity)).toBe(expected) - }) - }) - - describe('scanForVulnerabilities', () => { - it.each([ - { - name: 'auto-detects JSON and flags prototype pollution', - code: '{"__proto__": {"polluted": true}}', - expectedSeverity: 'critical', - }, - { - name: 'auto-detects Lua when function/end present', - code: 'function dangerous() os.execute("rm -rf /") end', - expectedSeverity: 'critical', - }, - { - name: 'auto-detects HTML and flags script tags', - code: '
', - expectedSeverity: 'critical', - }, - { - name: 'falls back to JavaScript scanning', - code: 'const result = eval("1 + 1")', - expectedSeverity: 'critical', - }, - { - name: 'honors explicit type parameter', - code: 'return 1', - type: 'lua' as const, - expectedSeverity: 'safe', - }, - ])('should $name', ({ code, type, expectedSeverity }) => { - const result = scanForVulnerabilities(code, type) - expect(result.severity).toBe(expectedSeverity) - }) - }) -}) +import './__tests__/security-scanner.detection.test' +import './__tests__/security-scanner.reporting.test' diff --git a/frontends/nextjs/src/theme/types/components.d.ts b/frontends/nextjs/src/theme/types/components.d.ts new file mode 100644 index 000000000..b3c850088 --- /dev/null +++ b/frontends/nextjs/src/theme/types/components.d.ts @@ -0,0 +1,71 @@ +import '@mui/material/styles' +import '@mui/material/Typography' +import '@mui/material/Button' +import '@mui/material/Chip' +import '@mui/material/IconButton' +import '@mui/material/Badge' +import '@mui/material/Alert' + +// Typography variants and component overrides +declare module '@mui/material/styles' { + interface TypographyVariants { + code: React.CSSProperties + kbd: React.CSSProperties + label: React.CSSProperties + } + + interface TypographyVariantsOptions { + code?: React.CSSProperties + kbd?: React.CSSProperties + label?: React.CSSProperties + } +} + +declare module '@mui/material/Typography' { + interface TypographyPropsVariantOverrides { + code: true + kbd: true + label: true + } +} + +declare module '@mui/material/Button' { + interface ButtonPropsVariantOverrides { + soft: true + ghost: true + } + + interface ButtonPropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/Chip' { + interface ChipPropsVariantOverrides { + soft: true + } + + interface ChipPropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/IconButton' { + interface IconButtonPropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/Badge' { + interface BadgePropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/Alert' { + interface AlertPropsVariantOverrides { + soft: true + } +} + +export {} diff --git a/frontends/nextjs/src/theme/types/layout.d.ts b/frontends/nextjs/src/theme/types/layout.d.ts new file mode 100644 index 000000000..6c9219bfc --- /dev/null +++ b/frontends/nextjs/src/theme/types/layout.d.ts @@ -0,0 +1,70 @@ +import '@mui/material/styles' + +// Custom theme properties for layout and design tokens +declare module '@mui/material/styles' { + interface Theme { + custom: { + fonts: { + body: string + heading: string + mono: string + } + borderRadius: { + none: number + sm: number + md: number + lg: number + xl: number + full: number + } + contentWidth: { + sm: string + md: string + lg: string + xl: string + full: string + } + sidebar: { + width: number + collapsedWidth: number + } + header: { + height: number + } + } + } + + interface ThemeOptions { + custom?: { + fonts?: { + body?: string + heading?: string + mono?: string + } + borderRadius?: { + none?: number + sm?: number + md?: number + lg?: number + xl?: number + full?: number + } + contentWidth?: { + sm?: string + md?: string + lg?: string + xl?: string + full?: string + } + sidebar?: { + width?: number + collapsedWidth?: number + } + header?: { + height?: number + } + } + } +} + +export {} diff --git a/frontends/nextjs/src/theme/types/palette.d.ts b/frontends/nextjs/src/theme/types/palette.d.ts new file mode 100644 index 000000000..edf25b4a1 --- /dev/null +++ b/frontends/nextjs/src/theme/types/palette.d.ts @@ -0,0 +1,38 @@ +import '@mui/material/styles' + +// Extend palette with custom neutral colors +declare module '@mui/material/styles' { + interface Palette { + neutral: { + 50: string + 100: string + 200: string + 300: string + 400: string + 500: string + 600: string + 700: string + 800: string + 900: string + 950: string + } + } + + interface PaletteOptions { + neutral?: { + 50?: string + 100?: string + 200?: string + 300?: string + 400?: string + 500?: string + 600?: string + 700?: string + 800?: string + 900?: string + 950?: string + } + } +} + +export {} diff --git a/frontends/nextjs/src/theme/types/theme.d.ts b/frontends/nextjs/src/theme/types/theme.d.ts index a8623d1fb..e3a795dd5 100644 --- a/frontends/nextjs/src/theme/types/theme.d.ts +++ b/frontends/nextjs/src/theme/types/theme.d.ts @@ -1,200 +1,10 @@ /** * MUI Theme Type Extensions - * - * This file extends Material-UI's theme interface with custom properties. - * All custom design tokens and component variants should be declared here. + * + * This file aggregates the theme augmentation modules to keep the + * main declaration lightweight while still exposing all custom tokens. */ -import '@mui/material/styles' -import '@mui/material/Typography' -import '@mui/material/Button' - -// ============================================================================ -// Custom Palette Extensions -// ============================================================================ - -declare module '@mui/material/styles' { - // Extend palette with custom neutral colors - interface Palette { - neutral: { - 50: string - 100: string - 200: string - 300: string - 400: string - 500: string - 600: string - 700: string - 800: string - 900: string - 950: string - } - } - - interface PaletteOptions { - neutral?: { - 50?: string - 100?: string - 200?: string - 300?: string - 400?: string - 500?: string - 600?: string - 700?: string - 800?: string - 900?: string - 950?: string - } - } - - // Custom typography variants - interface TypographyVariants { - code: React.CSSProperties - kbd: React.CSSProperties - label: React.CSSProperties - } - - interface TypographyVariantsOptions { - code?: React.CSSProperties - kbd?: React.CSSProperties - label?: React.CSSProperties - } - - // Custom theme properties - interface Theme { - custom: { - fonts: { - body: string - heading: string - mono: string - } - borderRadius: { - none: number - sm: number - md: number - lg: number - xl: number - full: number - } - contentWidth: { - sm: string - md: string - lg: string - xl: string - full: string - } - sidebar: { - width: number - collapsedWidth: number - } - header: { - height: number - } - } - } - - interface ThemeOptions { - custom?: { - fonts?: { - body?: string - heading?: string - mono?: string - } - borderRadius?: { - none?: number - sm?: number - md?: number - lg?: number - xl?: number - full?: number - } - contentWidth?: { - sm?: string - md?: string - lg?: string - xl?: string - full?: string - } - sidebar?: { - width?: number - collapsedWidth?: number - } - header?: { - height?: number - } - } - } -} - -// ============================================================================ -// Typography Variant Mapping -// ============================================================================ - -declare module '@mui/material/Typography' { - interface TypographyPropsVariantOverrides { - code: true - kbd: true - label: true - } -} - -// ============================================================================ -// Button Variants & Colors -// ============================================================================ - -declare module '@mui/material/Button' { - interface ButtonPropsVariantOverrides { - soft: true - ghost: true - } - - interface ButtonPropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// Chip Variants -// ============================================================================ - -declare module '@mui/material/Chip' { - interface ChipPropsVariantOverrides { - soft: true - } - - interface ChipPropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// IconButton Colors -// ============================================================================ - -declare module '@mui/material/IconButton' { - interface IconButtonPropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// Badge Colors -// ============================================================================ - -declare module '@mui/material/Badge' { - interface BadgePropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// Alert Variants -// ============================================================================ - -declare module '@mui/material/Alert' { - interface AlertPropsVariantOverrides { - soft: true - } -} - -export {} +export * from './palette' +export * from './layout' +export * from './components'