chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
6 changed files with 383 additions and 0 deletions
Showing only changes of commit 9bdf2247f4 - Show all commits

View File

@ -79,6 +79,20 @@ exports.createInviteLink = async (req, res) => {
} }
}; };
exports.listPendingJoinRequests = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const requests = await invitesService.listPendingJoinRequests(req.user.id, groupId);
res.json({ requests });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.listPendingJoinRequests");
}
};
exports.revokeInviteLink = async (req, res) => { exports.revokeInviteLink = async (req, res) => {
try { try {
const requestedGroupId = parseRequestedGroupId(req); const requestedGroupId = parseRequestedGroupId(req);
@ -180,6 +194,28 @@ exports.setJoinPolicy = async (req, res) => {
} }
}; };
exports.decideJoinRequest = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const decision = await invitesService.decideJoinRequest(
req.user.id,
groupId,
req.body?.requestId,
req.body?.decision,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ request: decision });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.decideJoinRequest");
}
};
exports.getInviteLinkSummary = async (req, res) => { exports.getInviteLinkSummary = async (req, res) => {
const token = req.params.token; const token = req.params.token;
const inviteLast4 = inviteCodeLast4(token); const inviteLast4 = inviteCodeLast4(token);

View File

@ -237,6 +237,53 @@ async function getPendingJoinRequest(groupId, userId, client) {
return result.rows[0] || null; return result.rows[0] || null;
} }
async function listPendingJoinRequests(groupId, client) {
const result = await getExecutor(client).query(
`SELECT
gjr.id,
gjr.group_id,
gjr.user_id,
gjr.status,
gjr.created_at,
gjr.updated_at,
u.username,
u.name,
u.display_name
FROM group_join_requests gjr
JOIN users u ON u.id = gjr.user_id
WHERE gjr.group_id = $1
AND gjr.status = 'PENDING'
ORDER BY gjr.created_at ASC`,
[groupId]
);
return result.rows;
}
async function getPendingJoinRequestById(groupId, requestId, client, forUpdate = false) {
const result = await getExecutor(client).query(
`SELECT
gjr.id,
gjr.group_id,
gjr.user_id,
gjr.status,
gjr.decided_by,
gjr.decided_at,
gjr.created_at,
gjr.updated_at,
u.username,
u.name,
u.display_name
FROM group_join_requests gjr
JOIN users u ON u.id = gjr.user_id
WHERE gjr.group_id = $1
AND gjr.id = $2
AND gjr.status = 'PENDING'
${forUpdate ? "FOR UPDATE OF gjr" : ""}`,
[groupId, requestId]
);
return result.rows[0] || null;
}
async function createOrTouchPendingJoinRequest(groupId, userId, client) { async function createOrTouchPendingJoinRequest(groupId, userId, client) {
const executor = getExecutor(client); const executor = getExecutor(client);
const existing = await executor.query( const existing = await executor.query(
@ -277,6 +324,22 @@ async function createOrTouchPendingJoinRequest(groupId, userId, client) {
} }
} }
async function updateJoinRequestDecision(groupId, requestId, status, decidedBy, client) {
const result = await getExecutor(client).query(
`UPDATE group_join_requests
SET status = $3,
decided_by = $4,
decided_at = NOW(),
updated_at = NOW()
WHERE group_id = $1
AND id = $2
AND status = 'PENDING'
RETURNING id, group_id, user_id, status, decided_by, decided_at, created_at, updated_at`,
[groupId, requestId, status, decidedBy]
);
return result.rows[0] || null;
}
async function addGroupMember(groupId, userId, role = "member", client) { async function addGroupMember(groupId, userId, role = "member", client) {
const result = await getExecutor(client).query( const result = await getExecutor(client).query(
`INSERT INTO household_members (household_id, user_id, role) `INSERT INTO household_members (household_id, user_id, role)
@ -381,12 +444,15 @@ module.exports = {
getInviteLinkById, getInviteLinkById,
getInviteLinkSummaryByToken, getInviteLinkSummaryByToken,
getManageableGroupsForUser, getManageableGroupsForUser,
getPendingJoinRequestById,
getPendingJoinRequest, getPendingJoinRequest,
getUserGroupRole, getUserGroupRole,
isGroupMember, isGroupMember,
listPendingJoinRequests,
listInviteLinks, listInviteLinks,
revokeInviteLink, revokeInviteLink,
reviveInviteLink, reviveInviteLink,
updateJoinRequestDecision,
upsertGroupSettings, upsertGroupSettings,
withTransaction, withTransaction,
}; };

View File

@ -28,6 +28,13 @@ const inviteWriteUserRateLimit = createRateLimit({
router.get("/groups/invites", auth, controller.listInviteLinks); router.get("/groups/invites", auth, controller.listInviteLinks);
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink); router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
router.get("/groups/join-requests", auth, controller.listPendingJoinRequests);
router.post(
"/groups/join-requests/decision",
auth,
inviteWriteUserRateLimit,
controller.decideJoinRequest
);
router.post( router.post(
"/groups/invites/revoke", "/groups/invites/revoke",
auth, auth,

View File

@ -179,6 +179,12 @@ async function listInviteLinks(userId, groupId) {
return invitesModel.listInviteLinks(resolvedGroupId); return invitesModel.listInviteLinks(resolvedGroupId);
} }
async function listPendingJoinRequests(userId, groupId) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
await ensureGroupAndManagerRole(userId, resolvedGroupId);
return invitesModel.listPendingJoinRequests(resolvedGroupId);
}
async function revokeInviteLink( async function revokeInviteLink(
userId, userId,
groupId, groupId,
@ -314,6 +320,116 @@ async function deleteInviteLink(
}); });
} }
async function decideJoinRequest(
userId,
groupId,
requestId,
decision,
requestIdForAudit,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedRequestId = ensurePositiveInteger(requestId, "requestId");
const normalizedDecision = typeof decision === "string" ? decision.trim().toUpperCase() : "";
if (!["APPROVE", "DENY"].includes(normalizedDecision)) {
throw new InviteServiceError("INVALID_INPUT", "Decision is required", 400);
}
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const pendingRequest = await invitesModel.getPendingJoinRequestById(
resolvedGroupId,
resolvedRequestId,
client,
true
);
if (!pendingRequest) {
throw new InviteServiceError(
"JOIN_REQUEST_NOT_FOUND",
"Pending join request not found",
404
);
}
if (normalizedDecision === "APPROVE") {
const isExistingMember = await invitesModel.isGroupMember(
resolvedGroupId,
pendingRequest.user_id,
client
);
if (!isExistingMember) {
await invitesModel.addGroupMember(
resolvedGroupId,
pendingRequest.user_id,
"member",
client
);
}
const approvedRequest = await invitesModel.updateJoinRequestDecision(
resolvedGroupId,
resolvedRequestId,
"APPROVED",
userId,
client
);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_REQUEST_APPROVED",
requestId: requestIdForAudit,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinRequestId: approvedRequest.id,
targetUserId: approvedRequest.user_id,
},
},
client
);
return approvedRequest;
}
const deniedRequest = await invitesModel.updateJoinRequestDecision(
resolvedGroupId,
resolvedRequestId,
"DENIED",
userId,
client
);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_REQUEST_DENIED",
requestId: requestIdForAudit,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinRequestId: deniedRequest.id,
targetUserId: deniedRequest.user_id,
},
},
client
);
return deniedRequest;
});
}
function getInviteStatus(link) { function getInviteStatus(link) {
const now = Date.now(); const now = Date.now();
if (link.single_use && link.used_at) return "USED"; if (link.single_use && link.used_at) return "USED";
@ -546,9 +662,11 @@ module.exports = {
JOIN_RESULTS, JOIN_RESULTS,
acceptInviteLink, acceptInviteLink,
createInviteLink, createInviteLink,
decideJoinRequest,
deleteInviteLink, deleteInviteLink,
getGroupJoinPolicy, getGroupJoinPolicy,
getInviteLinkSummaryByToken, getInviteLinkSummaryByToken,
listPendingJoinRequests,
listInviteLinks, listInviteLinks,
resolveManagedGroupId, resolveManagedGroupId,
revokeInviteLink, revokeInviteLink,

View File

@ -12,8 +12,10 @@ jest.mock("../services/group-invites.service", () => {
acceptInviteLink: jest.fn(), acceptInviteLink: jest.fn(),
createInviteLink: jest.fn(), createInviteLink: jest.fn(),
deleteInviteLink: jest.fn(), deleteInviteLink: jest.fn(),
decideJoinRequest: jest.fn(),
getGroupJoinPolicy: jest.fn(), getGroupJoinPolicy: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(), getInviteLinkSummaryByToken: jest.fn(),
listPendingJoinRequests: jest.fn(),
listInviteLinks: jest.fn(), listInviteLinks: jest.fn(),
resolveManagedGroupId: jest.fn(), resolveManagedGroupId: jest.fn(),
revokeInviteLink: jest.fn(), revokeInviteLink: jest.fn(),
@ -28,8 +30,10 @@ const app = require("../app");
describe("group invites routes", () => { describe("group invites routes", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
invitesService.resolveManagedGroupId.mockResolvedValue(1); invitesService.resolveManagedGroupId.mockResolvedValue(1);
invitesService.listInviteLinks.mockResolvedValue([]); invitesService.listInviteLinks.mockResolvedValue([]);
invitesService.listPendingJoinRequests.mockResolvedValue([]);
invitesService.createInviteLink.mockResolvedValue({ invitesService.createInviteLink.mockResolvedValue({
id: 1, id: 1,
token: "abcd", token: "abcd",
@ -71,4 +75,36 @@ describe("group invites routes", () => {
expect(response.body.request_id).toBeTruthy(); expect(response.body.request_id).toBeTruthy();
expect(response.body.link).toBeTruthy(); expect(response.body.link).toBeTruthy();
}); });
test("pending join requests can be listed with request_id", async () => {
invitesService.listPendingJoinRequests.mockResolvedValue([
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
]);
const response = await request(app).get("/api/groups/join-requests");
expect(response.status).toBe(200);
expect(response.body.request_id).toBeTruthy();
expect(response.body.requests).toEqual([
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
]);
});
test("decision route maps service validation errors", async () => {
invitesService.decideJoinRequest.mockRejectedValue(
new invitesService.InviteServiceError(
"JOIN_REQUEST_NOT_FOUND",
"Pending join request not found",
404
)
);
const response = await request(app)
.post("/api/groups/join-requests/decision")
.send({ requestId: 99, decision: "APPROVE" });
expect(response.status).toBe(404);
expect(response.body.request_id).toBeTruthy();
expect(response.body.error.code).toBe("JOIN_REQUEST_NOT_FOUND");
});
}); });

View File

@ -10,12 +10,15 @@ jest.mock("../models/group-invites.model", () => ({
getInviteLinkById: jest.fn(), getInviteLinkById: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(), getInviteLinkSummaryByToken: jest.fn(),
getManageableGroupsForUser: jest.fn(), getManageableGroupsForUser: jest.fn(),
getPendingJoinRequestById: jest.fn(),
getPendingJoinRequest: jest.fn(), getPendingJoinRequest: jest.fn(),
getUserGroupRole: jest.fn(), getUserGroupRole: jest.fn(),
isGroupMember: jest.fn(), isGroupMember: jest.fn(),
listPendingJoinRequests: jest.fn(),
listInviteLinks: jest.fn(), listInviteLinks: jest.fn(),
revokeInviteLink: jest.fn(), revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(), reviveInviteLink: jest.fn(),
updateJoinRequestDecision: jest.fn(),
upsertGroupSettings: jest.fn(), upsertGroupSettings: jest.fn(),
withTransaction: jest.fn(), withTransaction: jest.fn(),
})); }));
@ -41,6 +44,7 @@ function inviteSummary(overrides = {}) {
describe("group invites service", () => { describe("group invites service", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
invitesModel.withTransaction.mockImplementation(async (handler) => handler({})); invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
}); });
@ -186,4 +190,120 @@ describe("group invites service", () => {
expect(result.status).toBe("PENDING"); expect(result.status).toBe("PENDING");
expect(invitesModel.addGroupMember).not.toHaveBeenCalled(); expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
}); });
test("listPendingJoinRequests requires manager role and returns pending requests", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("owner");
invitesModel.listPendingJoinRequests.mockResolvedValue([
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
]);
const result = await invitesService.listPendingJoinRequests(99, 10);
expect(invitesModel.listPendingJoinRequests).toHaveBeenCalledWith(10);
expect(result).toEqual([
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
]);
});
test("approve join request adds membership, updates request, and audits decision", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("admin");
invitesModel.getPendingJoinRequestById.mockResolvedValue({
id: 77,
group_id: 10,
user_id: 55,
username: "pending-user",
status: "PENDING",
});
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.addGroupMember.mockResolvedValue(true);
invitesModel.updateJoinRequestDecision.mockResolvedValue({
id: 77,
group_id: 10,
user_id: 55,
status: "APPROVED",
decided_by: 99,
});
const result = await invitesService.decideJoinRequest(
99,
10,
77,
"APPROVE",
"req-approve",
"127.0.0.1",
"ua"
);
expect(invitesModel.getPendingJoinRequestById).toHaveBeenCalledWith(
10,
77,
{},
true
);
expect(invitesModel.addGroupMember).toHaveBeenCalledWith(10, 55, "member", {});
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
10,
77,
"APPROVED",
99,
{}
);
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
eventType: "GROUP_JOIN_REQUEST_APPROVED",
requestId: "req-approve",
metadata: {
joinRequestId: 77,
targetUserId: 55,
},
});
expect(result.status).toBe("APPROVED");
});
test("deny join request updates request and audits decision", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("owner");
invitesModel.getPendingJoinRequestById.mockResolvedValue({
id: 78,
group_id: 10,
user_id: 56,
status: "PENDING",
});
invitesModel.updateJoinRequestDecision.mockResolvedValue({
id: 78,
group_id: 10,
user_id: 56,
status: "DENIED",
decided_by: 99,
});
const result = await invitesService.decideJoinRequest(
99,
10,
78,
"DENY",
"req-deny",
"127.0.0.1",
"ua"
);
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
10,
78,
"DENIED",
99,
{}
);
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
eventType: "GROUP_JOIN_REQUEST_DENIED",
requestId: "req-deny",
metadata: {
joinRequestId: 78,
targetUserId: 56,
},
});
expect(result.status).toBe("DENIED");
});
}); });