CognxSafeTrack commited on
Commit
ec1b111
Β·
1 Parent(s): ea8815c

feat(settings): expose branding logoUrl and primaryColor fields

Browse files

Adds a Branding section to SettingsPage that reads/writes
org.brandingData.logoUrl and org.brandingData.primaryColor.
Includes a live logo preview and a color picker + hex input.

test(security): add 15 SQL injection and auth tests

Pure unit checks for the DANGEROUS_PATTERNS set used in the
text-to-SQL guard (UNION, DELETE, DROP, comments, stacked queries).
Integration auth checks for super-admin JWT enforcement.

apps/admin/src/pages/SettingsPage.tsx CHANGED
@@ -338,6 +338,46 @@ export default function SettingsPage() {
338
  </p>
339
  </section>
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  {/* AI API Keys β€” gated by subscription plan */}
342
  {(() => {
343
  const plan = org.subscriptionPlan ?? 'STARTER';
 
338
  </p>
339
  </section>
340
 
341
+ {/* Branding */}
342
+ <section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
343
+ <h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
344
+ <span>🎨</span> Branding
345
+ </h2>
346
+ <div className="space-y-4">
347
+ <div>
348
+ <label className="block text-sm font-medium text-slate-600 mb-1">URL du logo</label>
349
+ <input
350
+ type="url"
351
+ placeholder="https://storage.com/logo.png"
352
+ value={(org.brandingData as any)?.logoUrl || ''}
353
+ onChange={e => setOrg({ ...org, brandingData: { ...(org.brandingData ?? {}), logoUrl: e.target.value } })}
354
+ className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none text-sm"
355
+ />
356
+ {(org.brandingData as any)?.logoUrl && (
357
+ <img src={(org.brandingData as any).logoUrl} alt="logo preview" className="mt-2 h-10 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
358
+ )}
359
+ </div>
360
+ <div>
361
+ <label className="block text-sm font-medium text-slate-600 mb-1">Couleur principale</label>
362
+ <div className="flex items-center gap-3">
363
+ <input
364
+ type="color"
365
+ value={(org.brandingData as any)?.primaryColor || '#6366f1'}
366
+ onChange={e => setOrg({ ...org, brandingData: { ...(org.brandingData ?? {}), primaryColor: e.target.value } })}
367
+ className="w-10 h-10 rounded-lg border border-slate-200 cursor-pointer p-0.5"
368
+ />
369
+ <input
370
+ type="text"
371
+ value={(org.brandingData as any)?.primaryColor || '#6366f1'}
372
+ onChange={e => setOrg({ ...org, brandingData: { ...(org.brandingData ?? {}), primaryColor: e.target.value } })}
373
+ placeholder="#6366f1"
374
+ className="flex-1 px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none text-sm font-mono"
375
+ />
376
+ </div>
377
+ </div>
378
+ </div>
379
+ </section>
380
+
381
  {/* AI API Keys β€” gated by subscription plan */}
382
  {(() => {
383
  const plan = org.subscriptionPlan ?? 'STARTER';
apps/api/test/security.test.ts ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { buildApp } from '../src/app';
3
+
4
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
5
+
6
+ function makeJwt(app: any, payload: object) {
7
+ return (app as any).jwt.sign(payload);
8
+ }
9
+
10
+ // ─── Suite ────────────────────────────────────────────────────────────────────
11
+
12
+ describe('Security β€” SQL Injection & Role Guards', () => {
13
+ let app: any;
14
+
15
+ beforeAll(async () => {
16
+ app = await buildApp();
17
+ });
18
+
19
+ afterAll(async () => {
20
+ await app.close();
21
+ });
22
+
23
+ // ── Analytics text-to-SQL injection protection (pure logic) ─────────────
24
+ // HTTP integration tests for analytics/query require a real DB org (the
25
+ // injectTenantConfig middleware runs before the route). Pattern validation
26
+ // is tested here as pure unit checks.
27
+
28
+ describe('SQL injection DANGEROUS_PATTERNS β€” unit checks', () => {
29
+ // Mirror the exact patterns from analytics.ts
30
+ const DANGEROUS_PATTERNS = [/\bUNION\b/, /\bINSERT\b/, /\bUPDATE\b/, /\bDELETE\b/, /\bDROP\b/, /\bEXEC\b/, /\bEXECUTE\b/, /--/, /\/\*/, /;\s*SELECT/i];
31
+
32
+ it('blocks UNION SELECT', () => {
33
+ const sql = "SELECT * FROM \"User\" WHERE \"organizationId\" = 'x' UNION SELECT * FROM \"User\"";
34
+ expect(DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase()))).toBe(true);
35
+ });
36
+
37
+ it('blocks DROP TABLE', () => {
38
+ expect(DANGEROUS_PATTERNS.some(p => p.test('DROP TABLE "User"'.toUpperCase()))).toBe(true);
39
+ });
40
+
41
+ it('blocks -- SQL comment', () => {
42
+ expect(DANGEROUS_PATTERNS.some(p => p.test('SELECT 1 -- bypass filter'))).toBe(true);
43
+ });
44
+
45
+ it('blocks /* block comment */', () => {
46
+ expect(DANGEROUS_PATTERNS.some(p => p.test('SELECT 1 /* comment */'))).toBe(true);
47
+ });
48
+
49
+ it('blocks DELETE', () => {
50
+ expect(DANGEROUS_PATTERNS.some(p => p.test('DELETE FROM "User" WHERE 1=1'.toUpperCase()))).toBe(true);
51
+ });
52
+
53
+ it('blocks INSERT', () => {
54
+ expect(DANGEROUS_PATTERNS.some(p => p.test('INSERT INTO "User" VALUES (1)'.toUpperCase()))).toBe(true);
55
+ });
56
+
57
+ it('blocks UPDATE', () => {
58
+ expect(DANGEROUS_PATTERNS.some(p => p.test('UPDATE "User" SET name=\'x\''.toUpperCase()))).toBe(true);
59
+ });
60
+
61
+ it('blocks stacked query with ; SELECT', () => {
62
+ expect(DANGEROUS_PATTERNS.some(p => p.test('SELECT 1; SELECT * FROM "User"'))).toBe(true);
63
+ });
64
+
65
+ it('allows safe SELECT without dangerous patterns', () => {
66
+ const sql = 'SELECT id, name FROM "User" WHERE "organizationId" = \'abc\' LIMIT 10';
67
+ expect(DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase()))).toBe(false);
68
+ });
69
+
70
+ it('allows SELECT with GROUP BY and ORDER BY', () => {
71
+ const sql = 'SELECT feature, COUNT(*) as total FROM "UsageEvent" WHERE "organizationId" = \'abc\' GROUP BY feature ORDER BY total DESC LIMIT 5';
72
+ expect(DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase()))).toBe(false);
73
+ });
74
+ });
75
+
76
+ // ── Admin route unauthenticated ───────────────────────────────────────────
77
+ // Role guard (STUDENT/ORG_MEMBER β†’ 403) requires a real DB org record because
78
+ // injectTenantConfig runs before the route-level role check. The integration
79
+ // tests in critical-flows.test.ts cover the authenticated path.
80
+
81
+ describe('GET /v1/admin/stats β€” auth check', () => {
82
+ it('returns 401 with no token', async () => {
83
+ const res = await app.inject({ method: 'GET', url: '/v1/admin/stats' });
84
+ expect(res.statusCode).toBe(401);
85
+ });
86
+ });
87
+
88
+ // ── Super-admin route authentication & authorization ─────────────────────
89
+
90
+ describe('GET /v1/super-admin/platform/stats β€” auth & role', () => {
91
+ it('returns 401 with no token', async () => {
92
+ const res = await app.inject({ method: 'GET', url: '/v1/super-admin/platform/stats' });
93
+ expect(res.statusCode).toBe(401);
94
+ });
95
+
96
+ it('returns 403 for ORG_ADMIN role', async () => {
97
+ const token = makeJwt(app, { id: 'u1', role: 'ORG_ADMIN', organizationId: 'org1' });
98
+ const res = await app.inject({
99
+ method: 'GET',
100
+ url: '/v1/super-admin/platform/stats',
101
+ headers: { Authorization: `Bearer ${token}` },
102
+ });
103
+ expect(res.statusCode).toBe(403);
104
+ });
105
+
106
+ it('returns 403 for STUDENT role', async () => {
107
+ const token = makeJwt(app, { id: 'u1', role: 'STUDENT', organizationId: 'org1' });
108
+ const res = await app.inject({
109
+ method: 'GET',
110
+ url: '/v1/super-admin/platform/stats',
111
+ headers: { Authorization: `Bearer ${token}` },
112
+ });
113
+ expect(res.statusCode).toBe(403);
114
+ });
115
+ });
116
+
117
+ // ── JWT secret enforcement ────────────────────────────────────────────────
118
+
119
+ describe('JWT tampered token', () => {
120
+ it('returns 401 for a tampered JWT', async () => {
121
+ const res = await app.inject({
122
+ method: 'GET',
123
+ url: '/v1/admin/stats',
124
+ headers: { Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZha2UifQ.invalidsignature' },
125
+ });
126
+ expect(res.statusCode).toBe(401);
127
+ });
128
+ });
129
+ });