Skip to content

Commit 0b38af7

Browse files
authored
Merge pull request #4 from hexawulf/fix/data-isolation-snippets
Fix: Implement user-specific snippet filtering
2 parents 0d91934 + e06aa13 commit 0b38af7

File tree

4 files changed

+221
-53
lines changed

4 files changed

+221
-53
lines changed

server/__tests__/routes.test.ts

Lines changed: 164 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,46 @@ import { registerRoutes, authMiddleware } from '../routes'; // Adjust path
66
import { storage } from '../storage'; // Adjust path
77
import { type Snippet } from '@shared/schema'; // Adjust path
88

9+
// Mock the storage module
10+
import admin from 'firebase-admin'; // Import firebase-admin
11+
12+
// Mock firebase-admin
13+
jest.mock('firebase-admin', () => ({
14+
credential: {
15+
cert: jest.fn(),
16+
},
17+
initializeApp: jest.fn(),
18+
auth: () => ({
19+
verifyIdToken: jest.fn(),
20+
}),
21+
}));
22+
923
// Mock the storage module
1024
jest.mock('../storage', () => ({
1125
storage: {
1226
getSnippets: jest.fn(),
1327
getSnippet: jest.fn(),
14-
// Add any other storage methods if they get called by routes indirectly
15-
// For GET /api/public/snippets/:id, incrementSnippetViewCount might be called
16-
// Let's add it to avoid potential undefined function errors if the route calls it.
17-
incrementSnippetViewCount: jest.fn().mockResolvedValue(undefined),
28+
getUser: jest.fn(), // Add getUser to the mock
29+
upsertUser: jest.fn(), // Add upsertUser if it's called
30+
incrementSnippetViewCount: jest.fn().mockResolvedValue(undefined),
1831
},
1932
}));
2033

21-
// Mock the authMiddleware to prevent actual auth logic from running
34+
// Mock the authMiddleware
35+
// We will use the actual authMiddleware but mock its dependencies (firebase-admin, storage.getUser)
36+
// This is a change from the original approach of completely mocking out authMiddleware.
37+
// This allows us to test the middleware's logic more accurately.
38+
const originalRoutes = jest.requireActual('../routes');
2239
jest.mock('../routes', () => {
23-
const originalModule = jest.requireActual('../routes');
40+
const original = jest.requireActual('../routes');
2441
return {
25-
...originalModule,
26-
authMiddleware: jest.fn((req, res, next) => {
27-
// Simulate an authenticated user if needed for other tests, but not for public routes
28-
// req.user = { id: 'test-user', email: 'test@example.com' };
29-
next();
30-
}),
42+
...original,
43+
// authMiddleware is NOT mocked here anymore, we use the real one.
3144
};
3245
});
3346

34-
// Mock pool for health check, not strictly necessary for these tests but good practice
35-
// if registerRoutes touches it.
47+
48+
// Mock pool for health check
3649
jest.mock('../db', () => ({
3750
pool: {
3851
connect: jest.fn().mockResolvedValue({
@@ -47,14 +60,122 @@ let app: Express;
4760

4861
beforeEach(async () => { // Make beforeEach async if registerRoutes is async
4962
app = express();
50-
app.use(express.json()); // Required for Express to parse JSON request bodies
51-
await registerRoutes(app); // Register all routes, ensure this completes if it's async
63+
app.use(express.json());
64+
// Use originalRoutes.registerRoutes from the actual module
65+
await originalRoutes.registerRoutes(app);
5266

5367
// Clear mock history before each test
5468
(storage.getSnippets as jest.Mock).mockClear();
5569
(storage.getSnippet as jest.Mock).mockClear();
70+
(storage.getUser as jest.Mock).mockClear();
5671
(storage.incrementSnippetViewCount as jest.Mock).mockClear();
57-
(authMiddleware as jest.Mock).mockClear();
72+
// Clear firebase-admin mocks
73+
(admin.auth().verifyIdToken as jest.Mock).mockClear();
74+
});
75+
76+
// Define mock users and snippets
77+
const user_A_id = 'user_A_id';
78+
const user_B_id = 'user_B_id';
79+
const user_C_id = 'user_C_id';
80+
81+
const mockUserA = { id: user_A_id, email: 'a@example.com', displayName: 'User A', photoURL: null, createdAt: new Date(), updatedAt: new Date(), username: 'usera', githubId: null, bio: null, location: null, website: null };
82+
const mockUserB = { id: user_B_id, email: 'b@example.com', displayName: 'User B', photoURL: null, createdAt: new Date(), updatedAt: new Date(), username: 'userb', githubId: null, bio: null, location: null, website: null };
83+
const mockUserC = { id: user_C_id, email: 'c@example.com', displayName: 'User C', photoURL: null, createdAt: new Date(), updatedAt: new Date(), username: 'userc', githubId: null, bio: null, location: null, website: null };
84+
85+
86+
const snippet1: Partial<Snippet> = { id: 1, title: 'User A Snippet 1', code: 'code1', language: 'js', userId: user_A_id, isPublic: false };
87+
const snippet2: Partial<Snippet> = { id: 2, title: 'User A Snippet 2', code: 'code2', language: 'ts', userId: user_A_id, isPublic: false };
88+
const snippet3: Partial<Snippet> = { id: 3, title: 'User B Snippet 1', code: 'code3', language: 'py', userId: user_B_id, isPublic: false };
89+
const publicSnippet: Partial<Snippet> = { id: 4, title: 'Public Snippet', code: 'public', language: 'md', userId: user_A_id, isPublic: true };
90+
91+
describe('GET /api/snippets (User-Specific)', () => {
92+
it('should return 401 if no token is provided', async () => {
93+
const response = await request(app).get('/api/snippets');
94+
expect(response.status).toBe(401);
95+
expect(response.body.message).toContain('Unauthorized');
96+
});
97+
98+
it('should return snippets for User A when authenticated as User A', async () => {
99+
(admin.auth().verifyIdToken as jest.Mock).mockResolvedValue({ uid: user_A_id });
100+
(storage.getUser as jest.Mock).mockResolvedValue(mockUserA);
101+
(storage.getSnippets as jest.Mock).mockImplementation(async (filters) => {
102+
if (filters.userId === user_A_id) {
103+
return [snippet1, snippet2];
104+
}
105+
return [];
106+
});
107+
108+
const response = await request(app)
109+
.get('/api/snippets')
110+
.set('Authorization', 'Bearer test-token-user-a');
111+
112+
expect(response.status).toBe(200);
113+
expect(response.body).toEqual([snippet1, snippet2]);
114+
expect(storage.getSnippets).toHaveBeenCalledWith(expect.objectContaining({ userId: user_A_id }));
115+
expect(admin.auth().verifyIdToken).toHaveBeenCalledWith('test-token-user-a');
116+
expect(storage.getUser).toHaveBeenCalledWith(user_A_id);
117+
});
118+
119+
it('should return snippets for User B when authenticated as User B', async () => {
120+
(admin.auth().verifyIdToken as jest.Mock).mockResolvedValue({ uid: user_B_id });
121+
(storage.getUser as jest.Mock).mockResolvedValue(mockUserB);
122+
(storage.getSnippets as jest.Mock).mockImplementation(async (filters) => {
123+
if (filters.userId === user_B_id) {
124+
return [snippet3];
125+
}
126+
return [];
127+
});
128+
129+
const response = await request(app)
130+
.get('/api/snippets')
131+
.set('Authorization', 'Bearer test-token-user-b');
132+
133+
expect(response.status).toBe(200);
134+
expect(response.body).toEqual([snippet3]);
135+
expect(storage.getSnippets).toHaveBeenCalledWith(expect.objectContaining({ userId: user_B_id }));
136+
});
137+
138+
it('should return an empty array for User C (who has no snippets) when authenticated as User C', async () => {
139+
(admin.auth().verifyIdToken as jest.Mock).mockResolvedValue({ uid: user_C_id });
140+
(storage.getUser as jest.Mock).mockResolvedValue(mockUserC);
141+
(storage.getSnippets as jest.Mock).mockImplementation(async (filters) => {
142+
if (filters.userId === user_C_id) {
143+
return [];
144+
}
145+
return [snippet1, snippet2, snippet3]; // Should not happen if filter is correct
146+
});
147+
148+
const response = await request(app)
149+
.get('/api/snippets')
150+
.set('Authorization', 'Bearer test-token-user-c');
151+
152+
expect(response.status).toBe(200);
153+
expect(response.body).toEqual([]);
154+
expect(storage.getSnippets).toHaveBeenCalledWith(expect.objectContaining({ userId: user_C_id }));
155+
});
156+
157+
it('should return 401 if token is invalid', async () => {
158+
(admin.auth().verifyIdToken as jest.Mock).mockRejectedValue(new Error('Invalid token'));
159+
160+
const response = await request(app)
161+
.get('/api/snippets')
162+
.set('Authorization', 'Bearer invalid-token');
163+
164+
expect(response.status).toBe(401);
165+
expect(response.body.message).toContain('Invalid token');
166+
});
167+
168+
it('should return 404 if user from token is not found in storage', async () => {
169+
(admin.auth().verifyIdToken as jest.Mock).mockResolvedValue({ uid: 'nonexistent-user-id' });
170+
(storage.getUser as jest.Mock).mockResolvedValue(undefined); // User not found
171+
172+
const response = await request(app)
173+
.get('/api/snippets')
174+
.set('Authorization', 'Bearer token-for-nonexistent-user');
175+
176+
expect(response.status).toBe(404);
177+
expect(response.body.message).toContain('User not found');
178+
});
58179
});
59180

60181
describe('Public API Snippet Routes', () => {
@@ -68,29 +189,39 @@ describe('Public API Snippet Routes', () => {
68189

69190
const response = await request(app).get('/api/public/snippets');
70191

192+
const mockPublicSnippetsActual: Snippet[] = [ // Use actual Snippet type
193+
{ id: 1, title: 'Public Snippet 1', isPublic: true, language: 'javascript', code: '', userId: user_A_id, createdAt: new Date(), updatedAt: new Date(), viewCount:0, isFavorite: false, shareId: null, tags: [] },
194+
{ id: 2, title: 'Public Snippet 2', isPublic: true, language: 'python', code: '', userId: user_B_id, createdAt: new Date(), updatedAt: new Date(), viewCount:0, isFavorite: false, shareId: null, tags: [] },
195+
];
196+
(storage.getSnippets as jest.Mock).mockResolvedValue(mockPublicSnippetsActual);
197+
198+
const response = await request(app).get('/api/public/snippets');
199+
71200
expect(response.status).toBe(200);
72-
expect(response.body).toEqual(mockPublicSnippets);
73-
expect(storage.getSnippets).toHaveBeenCalledWith(expect.objectContaining({ // Use objectContaining for flexibility
201+
expect(response.body).toEqual(mockPublicSnippetsActual.map(s => ({...s, createdAt: s.createdAt.toISOString(), updatedAt: s.updatedAt.toISOString() }))); // Adjust for date serialization
202+
expect(storage.getSnippets).toHaveBeenCalledWith(expect.objectContaining({
74203
isPublic: true,
75204
}));
76-
expect(authMiddleware).not.toHaveBeenCalled(); // Ensure authMiddleware is not called for this public route
205+
// authMiddleware is part of the actual routes, so it WILL be called by Express
206+
// but it won't find a token and thus won't authenticate for public routes.
207+
// The important thing is that the route handler itself doesn't *require* req.user.
77208
});
78209

79-
it('should pass query parameters (search, language, tag) to storage.getSnippets', async () => {
210+
it('should pass query parameters (search, language, tag) to storage.getSnippets for public snippets', async () => {
80211
(storage.getSnippets as jest.Mock).mockResolvedValue([]);
81-
const query = { search: 'test', language: 'javascript', tag: 'example' };
212+
const query = { search: 'public test', language: 'javascript', tag: 'public_example' };
82213

83214
await request(app).get('/api/public/snippets').query(query);
84215

85-
expect(storage.getSnippets).toHaveBeenCalledWith({
216+
expect(storage.getSnippets).toHaveBeenCalledWith(expect.objectContaining({ // objectContaining for flexibility
86217
isPublic: true,
87218
search: query.search,
88219
language: query.language,
89220
tag: query.tag,
90-
});
221+
}));
91222
});
92223

93-
it('should handle errors from storage.getSnippets gracefully', async () => {
224+
it('should handle errors from storage.getSnippets gracefully for public snippets', async () => {
94225
(storage.getSnippets as jest.Mock).mockRejectedValue(new Error('Storage failure'));
95226

96227
const response = await request(app).get('/api/public/snippets');
@@ -103,22 +234,18 @@ describe('Public API Snippet Routes', () => {
103234

104235
describe('GET /api/public/snippets/:id', () => {
105236
it('should return a single public snippet if it exists and isPublic is true', async () => {
106-
const mockSnippet: Partial<Snippet> = { id: 1, title: 'Test Snippet', isPublic: true, code: 'code', language: 'js' };
107-
(storage.getSnippet as jest.Mock).mockResolvedValue(mockSnippet);
237+
const mockSnippetActual: Snippet = { id: 1, title: 'Test Snippet', isPublic: true, code: 'code', language: 'js', userId: user_A_id, createdAt: new Date(), updatedAt: new Date(), viewCount:0, isFavorite: false, shareId: null, tags: [] };
238+
(storage.getSnippet as jest.Mock).mockResolvedValue(mockSnippetActual);
108239

109240
const response = await request(app).get('/api/public/snippets/1');
110241

111242
expect(response.status).toBe(200);
112-
expect(response.body).toEqual(mockSnippet);
113-
expect(storage.getSnippet).toHaveBeenCalledWith(1); // ID is number
114-
// The route for /api/public/snippets/:id in the provided routes.ts does NOT call incrementSnippetViewCount.
115-
// The /api/snippets/:id (non-public) and /api/shared/:shareId do.
116-
// So, we should expect it NOT to be called here.
243+
expect(response.body).toEqual({...mockSnippetActual, createdAt: mockSnippetActual.createdAt.toISOString(), updatedAt: mockSnippetActual.updatedAt.toISOString() });
244+
expect(storage.getSnippet).toHaveBeenCalledWith(1);
117245
expect(storage.incrementSnippetViewCount).not.toHaveBeenCalled();
118-
expect(authMiddleware).not.toHaveBeenCalled();
119246
});
120247

121-
it('should return 404 if snippet is not found by storage.getSnippet', async () => {
248+
it('should return 404 if public snippet is not found by storage.getSnippet', async () => {
122249
(storage.getSnippet as jest.Mock).mockResolvedValue(null); // Snippet not found
123250

124251
const response = await request(app).get('/api/public/snippets/999');
@@ -129,17 +256,16 @@ describe('Public API Snippet Routes', () => {
129256
});
130257

131258
it('should return 404 if snippet is found but isPublic is false', async () => {
132-
const mockPrivateSnippet: Partial<Snippet> = { id: 2, title: 'Private Snippet', isPublic: false, code: 'private', language: 'js' };
133-
(storage.getSnippet as jest.Mock).mockResolvedValue(mockPrivateSnippet);
259+
const mockPrivateSnippetActual: Snippet = { id: 2, title: 'Private Snippet', isPublic: false, code: 'private', language: 'js', userId: user_A_id, createdAt: new Date(), updatedAt: new Date(), viewCount:0, isFavorite: false, shareId: null, tags: [] };
260+
(storage.getSnippet as jest.Mock).mockResolvedValue(mockPrivateSnippetActual);
134261

135262
const response = await request(app).get('/api/public/snippets/2');
136263

137264
expect(response.status).toBe(404);
138-
// Check against the actual error message in your route handler
139265
expect(response.body).toEqual({ message: "Snippet not found or not public" });
140266
});
141267

142-
it('should handle errors from storage.getSnippet gracefully', async () => {
268+
it('should handle errors from storage.getSnippet gracefully for public snippets', async () => {
143269
(storage.getSnippet as jest.Mock).mockRejectedValue(new Error('Storage failure for single snippet'));
144270

145271
const response = await request(app).get('/api/public/snippets/1');

server/routes.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,18 +132,20 @@ export async function registerRoutes(app: Express): Promise<Server> {
132132
// ─── 3.2) Snippets endpoints ─────────────────────────────────────
133133
// ────────────────────────────────────────────────────────────────
134134

135-
// GET all snippets (public access)
136-
app.get("/api/snippets", async (req, res) => {
135+
// GET all snippets (requires authentication)
136+
app.get("/api/snippets", authMiddleware, async (req, res) => {
137137
try {
138138
console.log("[GET_ALL] Get all snippets request received");
139+
const userId = (req as any).user.id;
139140

140-
const filters: any = {};
141+
const filters: any = { userId }; // Add userId to filters
141142
if (req.query.search) filters.search = String(req.query.search);
142143
if (req.query.language) filters.language = req.query.language;
143144
if (req.query.tag) filters.tag = req.query.tag;
144145
if (req.query.favorites === "true") filters.favorites = true;
145146

146147
try {
148+
// Assuming simpleStorage.getSnippets is updated or we prioritize storage
147149
const list = await simpleStorage.getSnippets(filters);
148150
console.log(`[GET_ALL] Found ${list.length} snippets using simpleStorage`);
149151
return res.json(list);
@@ -163,9 +165,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
163165
SELECT id, title, code, language, description, tags, userid, createdat, updatedat,
164166
isfavorite, ispublic, shareid, viewcount
165167
FROM snippets
166-
WHERE 1=1
168+
WHERE userid = $1
167169
`;
168-
const params: any[] = [];
170+
const params: any[] = [userId];
169171

170172
if (filters.search) {
171173
query += ` AND (title ILIKE $${params.length + 1} OR description ILIKE $${params.length + 1})`;

server/simple-storage.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,29 @@ class SimpleStorage {
55
try {
66
console.log('SimpleStorage: Getting snippets with filters:', filters);
77

8-
// Simple implementation without filters for debugging
9-
const result = await query('SELECT * FROM snippets ORDER BY id DESC', []);
10-
console.log(`SimpleStorage: Found ${result.rows.length} snippets`);
8+
let sqlQuery = 'SELECT * FROM snippets';
9+
const params: any[] = [];
10+
let whereClauseAdded = false;
11+
12+
if (filters?.userId) {
13+
sqlQuery += ' WHERE userid = $1';
14+
params.push(filters.userId);
15+
whereClauseAdded = true;
16+
}
17+
18+
// Add other potential filters here if simpleStorage is ever expanded
19+
// For example:
20+
// if (filters?.language) {
21+
// sqlQuery += whereClauseAdded ? ' AND' : ' WHERE';
22+
// sqlQuery += ` language = $${params.length + 1}`;
23+
// params.push(filters.language);
24+
// whereClauseAdded = true;
25+
// }
26+
27+
sqlQuery += ' ORDER BY updatedat DESC'; // Changed to updatedat
28+
29+
const result = await query(sqlQuery, params);
30+
console.log(`SimpleStorage: Found ${result.rows.length} snippets with query: ${sqlQuery}`);
1131

1232
return result.rows;
1333
} catch (error) {

0 commit comments

Comments
 (0)