Skip to content

Commit ccc9714

Browse files
author
Caleb
committed
Merge pull request #1163 from amanosiadnan-cmyk/fix-api-hardening
2 parents 3189d4f + 5504117 commit ccc9714

10 files changed

Lines changed: 658 additions & 78 deletions

File tree

Lines changed: 124 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,57 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest"
2-
import { PUT } from "../route"
2+
import { PUT, GET } from "../route"
33
import { requireAuth, requireRole } from "@/lib/api/auth"
44
import { NextRequest } from "next/server"
5+
import { ApiErrors } from "@/lib/api/errors"
6+
import {
7+
getAdminSettings,
8+
updateAdminSettings,
9+
} from "@/lib/admin-settings-store"
10+
import { emitAuditEvent } from "@/lib/api/audit"
11+
12+
vi.mock("@/lib/supabase/server", () => ({
13+
createClient: vi.fn(),
14+
}))
515

616
vi.mock("@/lib/api/auth", () => ({
717
requireAuth: vi.fn(),
818
requireRole: vi.fn(),
919
createRequestContext: vi.fn().mockReturnValue({ requestId: "test-admin-id" }),
1020
}))
1121

22+
vi.mock("@/lib/admin-settings-store", () => ({
23+
getAdminSettings: vi.fn(),
24+
updateAdminSettings: vi.fn(),
25+
resetAdminSettingsStore: vi.fn(),
26+
getCachedAdminSettings: vi.fn(),
27+
}))
28+
29+
vi.mock("@/lib/api/audit", () => ({
30+
emitAuditEvent: vi.fn(),
31+
}))
32+
1233
describe("Admin Settings API Route", () => {
1334
const mockOwner = { id: "user_owner_123", email: "owner@example.com" }
1435

1536
beforeEach(() => {
1637
vi.clearAllMocks()
1738
vi.mocked(requireAuth).mockResolvedValue(mockOwner as any)
1839
vi.mocked(requireRole).mockResolvedValue(true as any)
40+
vi.mocked(getAdminSettings).mockResolvedValue({
41+
maintenanceMode: false,
42+
enableRegistration: true,
43+
rateLimitThreshold: 100,
44+
})
1945
})
2046

21-
it("should update settings successfully with valid parameters", async () => {
47+
it("persists settings and returns the saved state", async () => {
2248
const validBody = {
2349
maintenanceMode: true,
2450
enableRegistration: false,
2551
rateLimitThreshold: 100,
2652
}
53+
const saved = { ...validBody }
54+
vi.mocked(updateAdminSettings).mockResolvedValue(saved)
2755

2856
const request = new NextRequest("http://localhost/api/admin/settings", {
2957
method: "PUT",
@@ -36,13 +64,18 @@ describe("Admin Settings API Route", () => {
3664
expect(response.status).toBe(200)
3765
expect(body.success).toBe(true)
3866
expect(body.data.updated).toBe(true)
39-
expect(body.data.settings).toEqual(validBody)
67+
expect(body.data.settings).toEqual(saved)
68+
expect(updateAdminSettings).toHaveBeenCalledWith(validBody)
4069
})
4170

42-
it("should support partial settings updates", async () => {
43-
const partialBody = {
71+
it("returns merged saved state for partial updates, not only the submitted body", async () => {
72+
const partialBody = { maintenanceMode: false }
73+
const saved = {
4474
maintenanceMode: false,
75+
enableRegistration: true,
76+
rateLimitThreshold: 100,
4577
}
78+
vi.mocked(updateAdminSettings).mockResolvedValue(saved)
4679

4780
const request = new NextRequest("http://localhost/api/admin/settings", {
4881
method: "PUT",
@@ -53,63 +86,129 @@ describe("Admin Settings API Route", () => {
5386
const body = await response.json()
5487

5588
expect(response.status).toBe(200)
56-
expect(body.success).toBe(true)
57-
expect(body.data.settings).toEqual(partialBody)
89+
expect(body.data.settings).toEqual(saved)
90+
expect(body.data.settings).not.toEqual(partialBody)
5891
})
5992

60-
it("should reject setting a negative rateLimitThreshold", async () => {
61-
const invalidBody = {
62-
rateLimitThreshold: -10,
63-
}
93+
it("emits an audit event for privileged settings changes", async () => {
94+
const validBody = { maintenanceMode: true }
95+
vi.mocked(updateAdminSettings).mockResolvedValue({
96+
maintenanceMode: true,
97+
enableRegistration: true,
98+
rateLimitThreshold: 100,
99+
})
100+
101+
const request = new NextRequest("http://localhost/api/admin/settings", {
102+
method: "PUT",
103+
body: JSON.stringify(validBody),
104+
})
105+
106+
await PUT(request)
107+
108+
expect(emitAuditEvent).toHaveBeenCalledWith(
109+
expect.objectContaining({
110+
userId: mockOwner.id,
111+
action: "admin.settings_update",
112+
resourceType: "admin_settings",
113+
metadata: expect.objectContaining({
114+
route: "/api/admin/settings",
115+
requestId: "test-admin-id",
116+
changedFields: "maintenanceMode",
117+
maintenanceMode: true,
118+
}),
119+
})
120+
)
121+
})
122+
123+
it("rejects unauthenticated users", async () => {
124+
vi.mocked(requireAuth).mockRejectedValue(ApiErrors.unauthorized())
125+
126+
const request = new NextRequest("http://localhost/api/admin/settings", {
127+
method: "PUT",
128+
body: JSON.stringify({ maintenanceMode: true }),
129+
})
130+
131+
const response = await PUT(request)
132+
const body = await response.json()
133+
134+
expect(response.status).toBe(401)
135+
expect(body.error.code).toBe("UNAUTHORIZED")
136+
expect(updateAdminSettings).not.toHaveBeenCalled()
137+
})
138+
139+
it("rejects non-owner users", async () => {
140+
vi.mocked(requireRole).mockRejectedValue(
141+
ApiErrors.forbidden("Requires one of: owner")
142+
)
143+
144+
const request = new NextRequest("http://localhost/api/admin/settings", {
145+
method: "PUT",
146+
body: JSON.stringify({ maintenanceMode: true }),
147+
})
148+
149+
const response = await PUT(request)
150+
const body = await response.json()
151+
152+
expect(response.status).toBe(403)
153+
expect(body.error.code).toBe("FORBIDDEN")
154+
expect(updateAdminSettings).not.toHaveBeenCalled()
155+
})
64156

157+
it("should reject setting a negative rateLimitThreshold", async () => {
65158
const request = new NextRequest("http://localhost/api/admin/settings", {
66159
method: "PUT",
67-
body: JSON.stringify(invalidBody),
160+
body: JSON.stringify({ rateLimitThreshold: -10 }),
68161
})
69162

70163
const response = await PUT(request)
71164
const body = await response.json()
72165

73166
expect(response.status).toBe(400)
74-
expect(body.success).toBe(false)
75167
expect(body.error.code).toBe("VALIDATION_ERROR")
76168
expect(body.error.field).toBe("rateLimitThreshold")
77169
})
78170

79171
it("should reject setting rateLimitThreshold to 0", async () => {
80-
const invalidBody = {
81-
rateLimitThreshold: 0,
82-
}
83-
84172
const request = new NextRequest("http://localhost/api/admin/settings", {
85173
method: "PUT",
86-
body: JSON.stringify(invalidBody),
174+
body: JSON.stringify({ rateLimitThreshold: 0 }),
87175
})
88176

89177
const response = await PUT(request)
90178
const body = await response.json()
91179

92180
expect(response.status).toBe(400)
93-
expect(body.success).toBe(false)
94181
expect(body.error.code).toBe("VALIDATION_ERROR")
95182
})
96183

97184
it("should reject invalid data types", async () => {
98-
const invalidBody = {
99-
maintenanceMode: "yes", // should be boolean
100-
}
101-
102185
const request = new NextRequest("http://localhost/api/admin/settings", {
103186
method: "PUT",
104-
body: JSON.stringify(invalidBody),
187+
body: JSON.stringify({ maintenanceMode: "yes" }),
105188
})
106189

107190
const response = await PUT(request)
108191
const body = await response.json()
109192

110193
expect(response.status).toBe(400)
111-
expect(body.success).toBe(false)
112194
expect(body.error.code).toBe("VALIDATION_ERROR")
113195
expect(body.error.field).toBe("maintenanceMode")
114196
})
197+
198+
it("GET returns persisted settings for owners", async () => {
199+
const settings = {
200+
maintenanceMode: true,
201+
enableRegistration: false,
202+
rateLimitThreshold: 50,
203+
}
204+
vi.mocked(getAdminSettings).mockResolvedValue(settings)
205+
206+
const response = await GET(
207+
new NextRequest("http://localhost/api/admin/settings")
208+
)
209+
const body = await response.json()
210+
211+
expect(response.status).toBe(200)
212+
expect(body.data.settings).toEqual(settings)
213+
})
115214
})
Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,81 @@
1-
import { NextRequest } from 'next/server'
2-
import { createApiRoute, createSuccessResponse, validateRequestBody } from '@/lib/api'
3-
import { z } from 'zod'
1+
import { type NextRequest } from "next/server"
2+
import {
3+
createApiRoute,
4+
createSuccessResponse,
5+
validateRequestBody,
6+
emitAuditEvent,
7+
ApiErrors,
8+
} from "@/lib/api/index"
9+
import { updateAdminSettings, getAdminSettings } from "@/lib/admin-settings-store"
10+
import { z } from "zod"
411

512
const adminSettingsSchema = z.object({
613
maintenanceMode: z.boolean().optional(),
714
enableRegistration: z.boolean().optional(),
815
rateLimitThreshold: z.number().int().positive().optional(),
916
})
1017

18+
export const GET = createApiRoute(
19+
async (_request: NextRequest, context) => {
20+
const settings = await getAdminSettings()
21+
return createSuccessResponse({ settings }, undefined, context.requestId)
22+
},
23+
{
24+
requireAuth: true,
25+
requireRole: ["owner"],
26+
}
27+
)
28+
1129
export const PUT = createApiRoute(
12-
async (request: NextRequest) => {
30+
async (request: NextRequest, context, user) => {
31+
if (!user) {
32+
throw ApiErrors.unauthorized()
33+
}
34+
1335
const body = await validateRequestBody(request, adminSettingsSchema)
14-
return createSuccessResponse({ updated: true, settings: body })
36+
const changedFields = Object.keys(body).filter(
37+
(key) => body[key as keyof typeof body] !== undefined
38+
)
39+
40+
if (changedFields.length === 0) {
41+
throw ApiErrors.validationError("At least one settings field is required")
42+
}
43+
44+
let settings
45+
try {
46+
settings = await updateAdminSettings(body)
47+
} catch (error) {
48+
throw ApiErrors.internalError(
49+
error instanceof Error ? error.message : "Failed to persist admin settings"
50+
)
51+
}
52+
53+
emitAuditEvent({
54+
userId: user.id,
55+
action: "admin.settings_update",
56+
resourceType: "admin_settings",
57+
resourceId: "platform",
58+
metadata: {
59+
route: "/api/admin/settings",
60+
requestId: context.requestId,
61+
changedFields: changedFields.join(","),
62+
...Object.fromEntries(
63+
changedFields.map((field) => [
64+
field,
65+
body[field as keyof typeof body] as string | number | boolean,
66+
])
67+
),
68+
},
69+
})
70+
71+
return createSuccessResponse(
72+
{ updated: true, settings },
73+
undefined,
74+
context.requestId
75+
)
1576
},
1677
{
1778
requireAuth: true,
18-
requireRole: ['owner'],
79+
requireRole: ["owner"],
1980
}
2081
)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { GET } from "../route"
3+
import { requireAuth, requireRole } from "@/lib/api/auth"
4+
import { NextRequest } from "next/server"
5+
import { ApiErrors } from "@/lib/api/errors"
6+
import { emitAuditEvent } from "@/lib/api/audit"
7+
8+
vi.mock("@/lib/supabase/server", () => ({
9+
createClient: vi.fn(),
10+
}))
11+
12+
vi.mock("@/lib/api/auth", () => ({
13+
requireAuth: vi.fn(),
14+
requireRole: vi.fn(),
15+
createRequestContext: vi.fn().mockReturnValue({ requestId: "admin-users-req" }),
16+
}))
17+
18+
vi.mock("@/lib/api/audit", () => ({
19+
emitAuditEvent: vi.fn(),
20+
}))
21+
22+
describe("Admin Users API Route", () => {
23+
const mockAdmin = { id: "admin_1", email: "admin@example.com" }
24+
25+
beforeEach(() => {
26+
vi.clearAllMocks()
27+
vi.mocked(requireAuth).mockResolvedValue(mockAdmin as any)
28+
vi.mocked(requireRole).mockResolvedValue(true as any)
29+
})
30+
31+
it("emits an audit event on successful privileged user-list access", async () => {
32+
const response = await GET(
33+
new NextRequest("http://localhost/api/admin/users")
34+
)
35+
const body = await response.json()
36+
37+
expect(response.status).toBe(200)
38+
expect(body.success).toBe(true)
39+
expect(emitAuditEvent).toHaveBeenCalledWith(
40+
expect.objectContaining({
41+
userId: mockAdmin.id,
42+
action: "admin.users_list",
43+
resourceType: "admin_users",
44+
metadata: expect.objectContaining({
45+
route: "/api/admin/users",
46+
requestId: "admin-users-req",
47+
}),
48+
})
49+
)
50+
})
51+
52+
it("rejects unauthenticated users without emitting audit", async () => {
53+
vi.mocked(requireAuth).mockRejectedValue(ApiErrors.unauthorized())
54+
55+
const response = await GET(
56+
new NextRequest("http://localhost/api/admin/users")
57+
)
58+
const body = await response.json()
59+
60+
expect(response.status).toBe(401)
61+
expect(body.error.code).toBe("UNAUTHORIZED")
62+
expect(emitAuditEvent).not.toHaveBeenCalled()
63+
})
64+
65+
it("rejects forbidden users without emitting audit", async () => {
66+
vi.mocked(requireRole).mockRejectedValue(
67+
ApiErrors.forbidden("Requires one of: admin, owner")
68+
)
69+
70+
const response = await GET(
71+
new NextRequest("http://localhost/api/admin/users")
72+
)
73+
const body = await response.json()
74+
75+
expect(response.status).toBe(403)
76+
expect(body.error.code).toBe("FORBIDDEN")
77+
expect(emitAuditEvent).not.toHaveBeenCalled()
78+
})
79+
})

0 commit comments

Comments
 (0)