feat: add eslint, image upload, tests, splash tagline, security hooks
- Restore CLAUDE.md with project conventions - ESLint config with vue3-recommended + typescript - Image upload endpoint (POST /api/admin/upload) with 5MB limit - Admin product form now supports image upload/preview/removal - Vitest config + 19 tests (crypto, validation, btcpay webhook, types) - Restore .claude/ security hooks (block-risky-bash, protect-files) - Logo splash now shows "EVERYTHING YOU LOVE IS A PSYOP" tagline - Add .vite/ to gitignore Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
tests/server/btcpay.test.ts
Normal file
38
tests/server/btcpay.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import crypto from 'node:crypto'
|
||||
import { validateWebhookSignature } from '../../server/lib/btcpay.js'
|
||||
|
||||
describe('BTCPay webhook validation', () => {
|
||||
const secret = 'test-webhook-secret-key'
|
||||
|
||||
it('validates correct HMAC signature', () => {
|
||||
process.env.BTCPAY_WEBHOOK_SECRET = secret
|
||||
const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
|
||||
const hmac = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
||||
|
||||
expect(validateWebhookSignature(body, `sha256=${hmac}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects incorrect signature', () => {
|
||||
process.env.BTCPAY_WEBHOOK_SECRET = secret
|
||||
const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
|
||||
|
||||
expect(validateWebhookSignature(body, 'sha256=deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when no secret configured', () => {
|
||||
delete process.env.BTCPAY_WEBHOOK_SECRET
|
||||
const body = '{"type":"InvoiceSettled"}'
|
||||
|
||||
expect(validateWebhookSignature(body, 'sha256=anything')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects tampered body', () => {
|
||||
process.env.BTCPAY_WEBHOOK_SECRET = secret
|
||||
const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
|
||||
const hmac = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
||||
|
||||
const tamperedBody = '{"type":"InvoiceSettled","invoiceId":"hacked"}'
|
||||
expect(validateWebhookSignature(tamperedBody, `sha256=${hmac}`)).toBe(false)
|
||||
})
|
||||
})
|
||||
55
tests/server/crypto.test.ts
Normal file
55
tests/server/crypto.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// Generate a test key and set it before importing the module
|
||||
const testKey = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
describe('server/lib/crypto', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENCRYPTION_KEY = testKey
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.ENCRYPTION_KEY
|
||||
})
|
||||
|
||||
it('encrypts and decrypts shipping address', async () => {
|
||||
const { encrypt, decrypt } = await import('../../server/lib/crypto.js')
|
||||
|
||||
const address = JSON.stringify({
|
||||
name: 'Satoshi Nakamoto',
|
||||
line1: '1 Bitcoin Ave',
|
||||
city: 'Cryptoville',
|
||||
postalCode: '21000',
|
||||
country: 'Decentraland',
|
||||
})
|
||||
|
||||
const encrypted = encrypt(address)
|
||||
expect(encrypted).not.toBe(address)
|
||||
expect(encrypted).toMatch(/^[0-9a-f]+$/)
|
||||
|
||||
const decrypted = decrypt(encrypted)
|
||||
expect(decrypted).toBe(address)
|
||||
expect(JSON.parse(decrypted).name).toBe('Satoshi Nakamoto')
|
||||
})
|
||||
|
||||
it('produces different ciphertext for same plaintext', async () => {
|
||||
const { encrypt } = await import('../../server/lib/crypto.js')
|
||||
|
||||
const plaintext = 'same input'
|
||||
const a = encrypt(plaintext)
|
||||
const b = encrypt(plaintext)
|
||||
|
||||
expect(a).not.toBe(b) // Random nonce ensures different output
|
||||
})
|
||||
|
||||
it('fails on tampered ciphertext', async () => {
|
||||
const { encrypt, decrypt } = await import('../../server/lib/crypto.js')
|
||||
|
||||
const encrypted = encrypt('secret data')
|
||||
// Flip a byte in the middle
|
||||
const tampered = encrypted.slice(0, 40) + 'ff' + encrypted.slice(42)
|
||||
|
||||
expect(() => decrypt(tampered)).toThrow()
|
||||
})
|
||||
})
|
||||
47
tests/server/validate.test.ts
Normal file
47
tests/server/validate.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitizeString, sanitizeInt } from '../../server/middleware/validate.js'
|
||||
|
||||
describe('sanitizeString', () => {
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitizeString(' hello ')).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates to 10000 chars', () => {
|
||||
const long = 'a'.repeat(20_000)
|
||||
expect(sanitizeString(long)).toHaveLength(10_000)
|
||||
})
|
||||
|
||||
it('returns empty string for non-string input', () => {
|
||||
expect(sanitizeString(123)).toBe('')
|
||||
expect(sanitizeString(null)).toBe('')
|
||||
expect(sanitizeString(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeInt', () => {
|
||||
it('accepts valid positive integers', () => {
|
||||
expect(sanitizeInt(42)).toBe(42)
|
||||
expect(sanitizeInt(0)).toBe(0)
|
||||
expect(sanitizeInt(100_000)).toBe(100_000)
|
||||
})
|
||||
|
||||
it('rejects negative numbers', () => {
|
||||
expect(sanitizeInt(-1)).toBeNull()
|
||||
expect(sanitizeInt(-100)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects floats', () => {
|
||||
expect(sanitizeInt(1.5)).toBeNull()
|
||||
expect(sanitizeInt(0.001)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-numeric input', () => {
|
||||
expect(sanitizeInt('abc')).toBeNull()
|
||||
expect(sanitizeInt(NaN)).toBeNull()
|
||||
})
|
||||
|
||||
it('converts string numbers', () => {
|
||||
expect(sanitizeInt('42')).toBe(42)
|
||||
expect(sanitizeInt('0')).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user