Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Core table for user-created routes.
**RLS policies (live Supabase):**
- `SELECT` — active routes publicly readable (`is_active = true`)
- `INSERT` — authenticated, `WITH CHECK (auth.uid() = creator_id)`
- `UPDATE` — authenticated creators only: `USING` / `WITH CHECK` require `auth.uid() = creator_id` (covers PATCH and soft-delete via `is_active`)
- `UPDATE` — authenticated creators only: `USING` / `WITH CHECK` must allow the row **after** the update. For soft-delete, `WITH CHECK` must **not** require `is_active = true`, or setting `is_active = false` will fail under RLS. See [fix_routes_soft_delete_rls.sql](sql/fix_routes_soft_delete_rls.sql).

---

Expand Down Expand Up @@ -140,7 +140,7 @@ Bookmarks: which routes a user has saved from the feed.

**Unique constraint:** `(user_id, route_id)` — required for `POST /api/v1/routes/:id/save`, which uses PostgREST `upsert` with `onConflict: 'user_id,route_id'`. Ensure this exists in Supabase.

**RLS:** Writes should allow the authenticated user to insert/delete their own rows (`auth.uid() = user_id`) when the API uses `createUserClient` with the caller’s JWT.
**RLS:** Writes should allow the authenticated user to insert/delete their own rows (`auth.uid() = user_id`) when the API uses `createUserClient` with the caller’s JWT. **`SELECT`** should allow each user to read their own rows (`auth.uid() = user_id`), or the server cannot enrich `is_saved` on the feed using the user JWT. Alternatively, keep `SELECT` public for `saved_routes` if acceptable for your threat model.

---

Expand Down
18 changes: 17 additions & 1 deletion src/routes/routes/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
const userId = req.user.id;
const userSupabase = supabase.createUserClient(req.token);

const { data: route, error: fetchError } = await supabase
const { data: route, error: fetchError } = await userSupabase
.from('routes')
.select('id, creator_id, is_active')
.eq('id', id)
Expand Down Expand Up @@ -419,6 +419,22 @@ router.delete('/:id', requireAuth, async (req, res) => {

if (updateError) {
console.error('Error deactivating route:', updateError);
const code = updateError.code;
const msg = String(updateError.message || '').toLowerCase();
const looksLikeRls =
code === '42501' ||
msg.includes('permission denied') ||
msg.includes('row-level security') ||
msg.includes('rls') ||
msg.includes('policy');
if (looksLikeRls) {
return res.status(403).json({
error: 'Forbidden',
message:
'Could not deactivate this route. Row-level security blocked the update — ensure UPDATE on `routes` allows the creator to set `is_active` to false (see backend/docs/sql/fix_routes_soft_delete_rls.sql).',
details: updateError.message,
});
}
return res.status(500).json({
error: 'Failed to deactivate route',
message: updateError.message,
Expand Down
16 changes: 15 additions & 1 deletion src/routes/routes/feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ router.get(
try {
const { tab, limit, offset, lat, lng, radius } = req.query;
const currentUserId = req.user?.id ?? null;
const enrichSavedOpts =
currentUserId && req.token
? {
savedRoutesSupabase:
supabase.createUserClient(req.token),
}
: {};
const parsedLat = lat ?? null;
const parsedLng = lng ?? null;
const parsedRadius = radius;
Expand Down Expand Up @@ -183,7 +190,12 @@ router.get(
offset,
offset + limit,
);
const { items } = await enrichRoutesForList(supabase, pageRows, currentUserId);
const { items } = await enrichRoutesForList(
supabase,
pageRows,
currentUserId,
enrichSavedOpts,
);
const finalRoutes = items.map(
({ vote_count: _v, ...route }) => route,
);
Expand Down Expand Up @@ -228,6 +240,7 @@ router.get(
supabase,
routeRows || [],
currentUserId,
enrichSavedOpts,
);
const finalRoutes = items.map(
({ vote_count: _v, ...route }) => route,
Expand Down Expand Up @@ -274,6 +287,7 @@ router.get(
supabase,
candidates || [],
currentUserId,
enrichSavedOpts,
);

const scored = [...items];
Expand Down
5 changes: 4 additions & 1 deletion src/routes/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,10 @@ router.get('/me/saved', async (req, res) => {
return res.status(500).json({ error: 'Failed to fetch routes', message: routesError.message });
}

const { items } = await enrichRoutesForList(supabase, routes || [], userId);
const userSupabase = supabase.createUserClient(req.token);
const { items } = await enrichRoutesForList(supabase, routes || [], userId, {
savedRoutesSupabase: userSupabase,
});
const finalRoutes = items.map((r) => ({ ...r, is_saved: true }));

// Preserve saved_at order
Expand Down
14 changes: 12 additions & 2 deletions src/services/routeList.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ async function fetchNearbyRouteIds(supabase, lat, lng, radiusMeters) {
* @param {import('@supabase/supabase-js').SupabaseClient} supabase
* @param {object[]} routes
* @param {string|null} [userId] - When provided, `user_vote` ('up'|'down'|null) is included per route.
* @param {{ savedRoutesSupabase?: import('@supabase/supabase-js').SupabaseClient }} [options]
* When `userId` is set, use `savedRoutesSupabase` (e.g. JWT-scoped client) to read `saved_routes` under RLS.
* @returns {Promise<{ items: object[], votesByRoute: Record<string, { up: number, down: number, total: number }> }>}
*/
async function enrichRoutesForList(supabase, routes, userId = null) {
async function enrichRoutesForList(supabase, routes, userId = null, options = {}) {
const savedRoutesSupabase = options.savedRoutesSupabase ?? supabase;
if (!routes || routes.length === 0) {
return { items: [], votesByRoute: {} };
}
Expand All @@ -72,7 +75,7 @@ async function enrichRoutesForList(supabase, routes, userId = null) {
.in("route_id", routeIds),
supabase.from("comments").select("route_id").in("route_id", routeIds),
userId
? supabase
? savedRoutesSupabase
.from("saved_routes")
.select("route_id")
.eq("user_id", userId)
Expand All @@ -95,6 +98,13 @@ async function enrichRoutesForList(supabase, routes, userId = null) {
);
}

if (savedResult.error) {
console.error(
"Error fetching saved routes for route list:",
savedResult.error,
);
}

const savedRouteIds = new Set(
!savedResult.error && savedResult.data
? savedResult.data.map((r) => r.route_id)
Expand Down
110 changes: 110 additions & 0 deletions test/routes-create.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
const request = require('supertest');

jest.mock('../src/config/supabase', () => {
const api = {
auth: {
getUser: jest.fn(),
},
from: jest.fn(),
rpc: jest.fn(),
};
api.createUserClient = jest.fn(() => api);
return api;
});

const supabase = require('../src/config/supabase');
const app = require('../src/index');

const validCreateBody = {
title: 'Morning walk',
start_label: 'Jester West',
end_label: 'GDC',
start_time: '2023-10-27T10:00:00.000Z',
end_time: '2023-10-27T10:15:00.000Z',
tags: [],
points: [
{
seq: 1,
lat: 30.2849,
lng: -97.7341,
time: '2023-10-27T10:00:00.000Z',
},
{
seq: 2,
lat: 30.2855,
lng: -97.735,
time: '2023-10-27T10:15:00.000Z',
},
],
};

describe('POST /api/v1/routes (create)', () => {
beforeEach(() => {
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'creator-1', email: 'creator@utexas.edu' } },
error: null,
});

supabase.rpc.mockImplementation(async (name) => {
if (name === 'create_route_with_geography') {
return { data: 'new-route-uuid', error: null };
}
if (name === 'insert_route_points') {
return { data: null, error: null };
}
return { data: null, error: null };
});

supabase.from.mockImplementation(() => ({
insert: jest.fn(() => Promise.resolve({ data: null, error: null })),
}));
});

afterEach(() => {
jest.clearAllMocks();
});

it('accepts create payload without description (optional field omitted)', async () => {
const res = await request(app)
.post('/api/v1/routes')
.set('Authorization', 'Bearer valid-token')
.send(validCreateBody);

expect(res.status).toBe(201);
expect(res.body).toEqual({ route_id: 'new-route-uuid' });
expect(supabase.rpc).toHaveBeenCalledWith(
'create_route_with_geography',
expect.objectContaining({
p_title: 'Morning walk',
p_description: null,
}),
);
});

it('rejects description: null (Zod optional does not allow null)', async () => {
const res = await request(app)
.post('/api/v1/routes')
.set('Authorization', 'Bearer valid-token')
.send({ ...validCreateBody, description: null });

expect(res.status).toBe(400);
expect(res.body.error).toBe('Validation error');
expect(res.body.issues.some((i) => i.field === 'description')).toBe(true);
expect(supabase.rpc).not.toHaveBeenCalled();
});

it('accepts non-empty description', async () => {
const res = await request(app)
.post('/api/v1/routes')
.set('Authorization', 'Bearer valid-token')
.send({ ...validCreateBody, description: ' Nice shade ' });

expect(res.status).toBe(201);
expect(supabase.rpc).toHaveBeenCalledWith(
'create_route_with_geography',
expect.objectContaining({
p_description: 'Nice shade',
}),
);
});
});
29 changes: 29 additions & 0 deletions test/routes-detail.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,33 @@ describe('Route detail and update endpoints', () => {
expect(res.status).toBe(404);
expect(res.body.error).toBe('Route not found');
});

it('returns 403 when soft-delete update is blocked by RLS', async () => {
queryHandlers.routes = async (state) => {
if (state.operation === 'select') {
return {
data: { id: 'route-1', creator_id: 'creator-1', is_active: true },
error: null,
};
}
if (state.operation === 'update') {
return {
data: null,
error: {
message: 'new row violates row-level security policy for table "routes"',
code: '42501',
},
};
}
return { data: null, error: null };
};

const res = await request(app)
.delete('/api/v1/routes/route-1')
.set('Authorization', 'Bearer valid-token');

expect(res.status).toBe(403);
expect(res.body.error).toBe('Forbidden');
expect(res.body.message).toContain('Row-level security');
});
});
Loading