Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"dev": "node src/server.js",
"start": "node src/server.js",
"test": "node --test src/tests"
"test": "node --test src/tests/*.test.js"
},
"dependencies": {
"cors": "^2.8.5",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { notificationRoutes } from "./routes/notificationRoutes.js";
import { uploadRoutes } from "./routes/uploadRoutes.js";
import { searchRoutes } from "./routes/searchRoutes.js";
import { adminRoutes } from "./routes/adminRoutes.js";
import { settingsRoutes } from "./routes/settingsRoutes.js";

export function createApp() {
const app = express();
Expand All @@ -38,6 +39,7 @@ export function createApp() {
app.use("/api/uploads", uploadRoutes);
app.use("/api/search", searchRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api/settings", settingsRoutes);

app.use(errorHandler);
return app;
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/controllers/settingsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ZodError } from "zod";
import { fail, ok } from "../utils/response.js";
import { changePassword, updateProfile, deleteAccount } from "../services/settingsService.js";
import { changePasswordSchema, updateProfileSchema, deleteAccountSchema } from "../validators/settings.js";

export async function handleChangePassword(req, res) {
try {
const payload = changePasswordSchema.parse(req.body);
const userId = req.user.sub;
const result = await changePassword(userId, payload.currentPassword, payload.newPassword);
return ok(res, result);
} catch (err) {
if (err instanceof ZodError) {
return fail(res, "Validation error: " + err.errors.map((e) => e.message).join("; "), 400);
}
throw err;
}
}

export async function handleUpdateProfile(req, res) {
try {
const payload = updateProfileSchema.parse(req.body);
const userId = req.user.sub;
const result = await updateProfile(userId, payload);
return ok(res, result);
} catch (err) {
if (err instanceof ZodError) {
return fail(res, "Validation error: " + err.errors.map((e) => e.message).join("; "), 400);
}
throw err;
}
}

export async function handleDeleteAccount(req, res) {
try {
const payload = deleteAccountSchema.parse(req.body);
const userId = req.user.sub;
const result = await deleteAccount(userId, payload.password);
return ok(res, result);
} catch (err) {
if (err instanceof ZodError) {
return fail(res, "Validation error: " + err.errors.map((e) => e.message).join("; "), 400);
}
throw err;
}
}
16 changes: 16 additions & 0 deletions apps/api/src/routes/settingsRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Router } from "express";
import { authMiddleware } from "../middleware/auth.js";
import {
handleChangePassword,
handleUpdateProfile,
handleDeleteAccount,
} from "../controllers/settingsController.js";

export const settingsRoutes = Router();

// All settings routes require authentication
settingsRoutes.use(authMiddleware);

settingsRoutes.put("/password", handleChangePassword);
settingsRoutes.put("/profile", handleUpdateProfile);
settingsRoutes.delete("/account", handleDeleteAccount);
40 changes: 40 additions & 0 deletions apps/api/src/services/settingsService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Settings service — handles account control operations.
* Currently backed by an in-memory store; swap for Prisma once DB is wired.
*/

const userStore = [];

/**
* Change the authenticated user's password.
*/
export async function changePassword(userId, currentPassword, newPassword) {
// TODO: verify currentPassword against stored hash via bcrypt
// TODO: hash newPassword and persist
const user = userStore.find((u) => u.id === userId);
if (!user) {
// Simulate for now — in production fetch from DB
return { success: true, message: "Password changed successfully" };
}
return { success: true, message: "Password changed successfully" };
}

/**
* Update profile fields (fullName, email, bio).
*/
export async function updateProfile(userId, updates) {
// TODO: persist via Prisma
return {
id: userId,
...updates,
updatedAt: new Date().toISOString(),
};
}

/**
* Soft-delete (or hard-delete) the user's account.
*/
export async function deleteAccount(userId, password) {
// TODO: verify password, then mark user as deleted / remove from DB
return { success: true, message: "Account deleted successfully" };
}
185 changes: 185 additions & 0 deletions apps/api/src/tests/settings.auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createApp } from "../app.js";
import { signAccessToken } from "../utils/jwt.js";

/**
* Tests for issue #1810: Settings/account controls require authentication
*
* These tests verify that all settings endpoints require authentication
* and that dangerous operations (delete account, change password) validate input.
*/

const BASE = (port) => `http://127.0.0.1:${port}/api/settings`;

function makeToken(sub = "usr_test123", role = "client") {
return signAccessToken({ sub, role });
}

async function withServer(fn) {
const app = createApp();
const server = app.listen(0);
await new Promise((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
const { port } = server.address();
try {
await fn(port);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}

// --- Authentication tests ---

test("PUT /api/settings/password without token returns 401", async () => {
await withServer(async (port) => {
const res = await fetch(`${BASE(port)}/password`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword: "oldpassword",
newPassword: "newpassword1",
confirmPassword: "newpassword1",
}),
});
assert.equal(res.status, 401, "Should require authentication");
});
});

test("PUT /api/settings/profile without token returns 401", async () => {
await withServer(async (port) => {
const res = await fetch(`${BASE(port)}/profile`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullName: "Test User" }),
});
assert.equal(res.status, 401, "Should require authentication");
});
});

test("DELETE /api/settings/account without token returns 401", async () => {
await withServer(async (port) => {
const res = await fetch(`${BASE(port)}/account`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "mypassword1" }),
});
assert.equal(res.status, 401, "Should require authentication");
});
});

// --- Authenticated endpoint tests ---

test("PUT /api/settings/password with valid token returns 200", async () => {
await withServer(async (port) => {
const token = makeToken();
const res = await fetch(`${BASE(port)}/password`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
currentPassword: "oldpassword1",
newPassword: "newpassword12",
confirmPassword: "newpassword12",
}),
});
assert.equal(res.status, 200, "Authenticated password change should succeed");
const body = await res.json();
assert.equal(body.success, true);
});
});

test("PUT /api/settings/profile with valid token returns 200", async () => {
await withServer(async (port) => {
const token = makeToken();
const res = await fetch(`${BASE(port)}/profile`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ fullName: "Jane Doe", bio: "Hello world" }),
});
assert.equal(res.status, 200, "Authenticated profile update should succeed");
const body = await res.json();
assert.equal(body.success, true);
});
});

test("DELETE /api/settings/account with valid token returns 200", async () => {
await withServer(async (port) => {
const token = makeToken();
const res = await fetch(`${BASE(port)}/account`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ password: "mypassword1" }),
});
assert.equal(res.status, 200, "Authenticated account deletion should succeed");
const body = await res.json();
assert.equal(body.success, true);
});
});

// --- Validation tests ---

test("PUT /api/settings/password with mismatched confirmation is rejected", async () => {
await withServer(async (port) => {
const token = makeToken();
const res = await fetch(`${BASE(port)}/password`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
currentPassword: "oldpassword1",
newPassword: "newpassword12",
confirmPassword: "differentpwd1",
}),
});
assert.ok(res.status >= 400, "Mismatched passwords should be rejected");
});
});

test("DELETE /api/settings/account without password is rejected", async () => {
await withServer(async (port) => {
const token = makeToken();
const res = await fetch(`${BASE(port)}/account`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({}),
});
assert.ok(res.status >= 400, "Delete without password should be rejected");
});
});

test("PUT /api/settings/password with short password is rejected", async () => {
await withServer(async (port) => {
const token = makeToken();
const res = await fetch(`${BASE(port)}/password`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
currentPassword: "short",
newPassword: "newpassword12",
confirmPassword: "newpassword12",
}),
});
assert.ok(res.status >= 400, "Short password should be rejected");
});
});
20 changes: 20 additions & 0 deletions apps/api/src/validators/settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from "zod";

export const changePasswordSchema = z.object({
currentPassword: z.string().min(8, "Current password must be at least 8 characters"),
newPassword: z.string().min(8, "New password must be at least 8 characters"),
confirmPassword: z.string().min(8, "Confirm password must be at least 8 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "New password and confirmation do not match",
path: ["confirmPassword"],
});

export const updateProfileSchema = z.object({
fullName: z.string().min(1, "Full name is required").max(100).optional(),
email: z.string().email("Invalid email address").optional(),
bio: z.string().max(500, "Bio must be 500 characters or less").optional(),
});

export const deleteAccountSchema = z.object({
password: z.string().min(8, "Password confirmation is required"),
});
Loading