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
72 changes: 62 additions & 10 deletions src/__tests__/pages-api/operationalRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { request, response } from 'src/test-utils/pagesApi';

const mockGetAllConfigs = jest.fn();
const mockVerifyBearerToken = jest.fn();
jest.mock('server/lib/auth', () => ({ verifyBearerToken: (...args: unknown[]) => mockVerifyBearerToken(...args) }));
const mockTTLQueueAdd = jest.fn();
const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback());
const mockNanoid = jest.fn(() => 'fixed-id');
Expand Down Expand Up @@ -124,40 +126,90 @@ describe('legacy operational API routes', () => {
});

describe('/config/cache', () => {
const priorAuth = process.env.ENABLE_AUTH;
const priorIssuer = process.env.KEYCLOAK_ISSUER;
const adminRequest = (overrides: Parameters<typeof request>[0] = {}) =>
request({ ...overrides, headers: { authorization: 'Bearer verified-test-token' } });
beforeEach(() => {
process.env.ENABLE_AUTH = 'true';
process.env.KEYCLOAK_ISSUER = 'https://idp.test/realms/lifecycle';
mockVerifyBearerToken.mockResolvedValue({
success: true,
payload: { sub: 'admin-1', iss: 'https://idp.test/realms/lifecycle', realm_access: { roles: ['admin'] } },
});
});
afterAll(() => {
if (priorAuth === undefined) delete process.env.ENABLE_AUTH;
else process.env.ENABLE_AUTH = priorAuth;
if (priorIssuer === undefined) delete process.env.KEYCLOAK_ISSUER;
else process.env.KEYCLOAK_ISSUER = priorIssuer;
});
it('denies ordinary/missing identity before loading configuration', async () => {
const res = response();
await cacheHandler(request(), res);
expect(res.statusCode).toBe(403);
expect(mockGetAllConfigs).not.toHaveBeenCalled();
});
it('denies auth-off even with cached admin claims', async () => {
process.env.ENABLE_AUTH = 'false';
const res = response();
await cacheHandler(adminRequest(), res);
expect(res.statusCode).toBe(403);
expect(mockGetAllConfigs).not.toHaveBeenCalled();
});
it.each([
['GET', false],
['PUT', true],
])('%s returns cached configuration with the expected refresh flag', async (method, refresh) => {
mockGetAllConfigs.mockResolvedValueOnce({ feature: 'value' });
const res = response();
await cacheHandler(request({ method }), res);
await cacheHandler(adminRequest({ method }), res);
expect(mockGetAllConfigs).toHaveBeenCalledWith(refresh);
expect(res.body).toEqual({ configs: { feature: 'value' } });
});

it('advertises allowed methods', async () => {
const res = response();
await cacheHandler(request({ method: 'POST' }), res);
await cacheHandler(adminRequest({ method: 'POST' }), res);
expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'PUT']);
expect(res.statusCode).toBe(405);
});

it('maps config retrieval failures to the route-specific error', async () => {
mockGetAllConfigs.mockRejectedValueOnce(new Error('config unavailable'));
const res = response();
await cacheHandler(request(), res);
await cacheHandler(adminRequest(), res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: 'Unable to retrieve global config values' });
});

it('maps response failures to the outer stable error', async () => {
it('ignores forged x-user admin claims even with an invalid bearer', async () => {
mockVerifyBearerToken.mockResolvedValue({ success: false });
const res = response();
(res.setHeader as jest.Mock).mockImplementationOnce(() => {
throw new Error('response unavailable');
});
await cacheHandler(request({ method: 'POST' }), res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: 'An unexpected error occurred.' });
await cacheHandler(
request({
headers: {
authorization: 'Bearer forged',
'x-user': Buffer.from(
JSON.stringify({
sub: 'admin-1',
iss: 'https://idp.test/realms/lifecycle',
realm_access: { roles: ['admin'] },
})
).toString('base64'),
},
}),
res
);
expect(res.statusCode).toBe(403);
expect(mockGetAllConfigs).not.toHaveBeenCalled();
expect(mockVerifyBearerToken).toHaveBeenCalledWith('forged');
});
it('denies malformed claims before configuration reads', async () => {
const res = response();
await cacheHandler(request({ headers: { 'x-user': 'malformed' } }), res);
expect(res.statusCode).toBe(403);
expect(mockGetAllConfigs).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ jest.mock('server/services/apiToken', () => {
const actual = jest.requireActual('server/services/apiToken');
return { __esModule: true, ...actual, default: { verifyToken: jest.fn(), touchLastUsed: jest.fn() } };
});
jest.mock('server/lib/get-user', () => ({ getRequestUserIdentity: jest.fn() }));
jest.mock('server/lib/get-user', () => ({
getRequestUserIdentity: jest.fn(),
getUser: jest.fn(),
getOAuthCredentialFromClaims: jest.fn(),
}));
jest.mock('server/services/globalConfig', () => {
const getAllConfigs = jest.fn();
const getConfig = jest.fn();
Expand Down
1 change: 1 addition & 0 deletions src/app/api/v2/me/tokens/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const postHandler = async (req: NextRequest) => {
email: identity.email,
preferredUsername: identity.preferredUsername,
displayName: identity.displayName,
issuer: identity.issuer ?? null,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New personal keys retain the verified issuer with the subject. Site ownership must not depend on a mutable email or username.

roleAtIssue: identity.roles.includes('admin') ? 'admin' : 'user',
},
});
Expand Down
71 changes: 71 additions & 0 deletions src/app/api/v2/sites/[siteId]/access/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest } from 'next/server';
import { createPrincipalApiHandler } from 'server/lib/createApiHandler';
import type { Principal } from 'server/lib/principal';
import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers';
import { readSiteRevision, readSiteVisibility, sitesErrorResponse } from 'server/lib/sites/routeHelpers';
import SitesService, { SitesServiceError } from 'server/services/sites';

/**
* @openapi
* /api/v2/sites/{siteId}/access:
* patch:
* summary: Change Site visibility
* operationId: setSiteVisibility
* tags: [Sites]
* security:
* - BearerAuth: []
* - LifecycleApiKey: []
* parameters:
* - in: path
* name: siteId
* required: true
* schema: { type: string }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* additionalProperties: false
* properties:
* visibility: { type: string, enum: [private, public] }
* expectedAccessRevision: { type: integer, minimum: 1, maximum: 2147483647 }
* required: [visibility, expectedAccessRevision]
* responses:
* '200':
* description: Visibility changed; the Site ID and content URL stay the same.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SiteSuccessResponse'
* '409':
* description: Access revision changed; refetch before retrying.
*/
export const PATCH = createPrincipalApiHandler(
{ scope: 'sites:write' },
async (req: NextRequest, principal: Principal, { params }: { params: Promise<{ siteId: string }> }) => {
try {
let body: unknown;
try {
body = await req.json();
} catch {
throw new SitesServiceError('Invalid JSON.', 400);
}
if (
!body ||
typeof body !== 'object' ||
Array.isArray(body) ||
Object.keys(body).some((key) => !['visibility', 'expectedAccessRevision'].includes(key))
) {
throw new SitesServiceError('Invalid access request.', 400);
}
const input = body as Record<string, unknown>;
const visibility = readSiteVisibility(input.visibility);
const revision = readSiteRevision(input.expectedAccessRevision, true)!;
const site = await new SitesService().setVisibility((await params).siteId, visibility, principal, revision);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visibility has its own owner-authorized mutation. Requiring the access revision prevents an old page from overwriting a newer publish or privacy decision.

return successResponse({ site }, { status: 200 }, req);
} catch (error) {
return sitesErrorResponse(error, req);
}
}
);
12 changes: 9 additions & 3 deletions src/app/api/v2/sites/[siteId]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
import { NextRequest } from 'next/server';
import { createPrincipalApiHandler } from 'server/lib/createApiHandler';
import type { Principal } from 'server/lib/principal';
import { successResponse } from 'server/lib/response';
import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers';
import { readUploadFile, sitesErrorResponse } from 'server/lib/sites/routeHelpers';
import { SitesServiceError } from 'server/services/sites';
import SitesService from 'server/services/sites';

export const runtime = 'nodejs';
Expand Down Expand Up @@ -76,11 +77,16 @@ type RouteContext = {
const putHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => {
const routeParams = await params;
try {
const upload = await readUploadFile(req);
const service = new SitesService();
const existing = await service.getSite(routeParams.siteId, principal);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check access before reading the upload body, then let the service recheck ownership and revisions when it commits the replacement.

if (!existing.permissions.canEdit) throw new SitesServiceError('Site content editing is not permitted.', 403);
const capabilities = await service.getCapabilities(principal);
const upload = await readUploadFile(req, capabilities.upload.maxUploadBytes);
if (upload.visibility !== undefined)
throw new SitesServiceError('Use the access endpoint to change visibility.', 400);
const site = await service.replaceSiteContent(routeParams.siteId, {
...upload,
user: principal.identity,
principal,
});
return successResponse({ site }, { status: 200 }, req);
} catch (error) {
Expand Down
16 changes: 12 additions & 4 deletions src/app/api/v2/sites/[siteId]/extend/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import { NextRequest } from 'next/server';
import { createPrincipalApiHandler } from 'server/lib/createApiHandler';
import type { Principal } from 'server/lib/principal';
import { successResponse } from 'server/lib/response';
import { sitesErrorResponse } from 'server/lib/sites/routeHelpers';
import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers';
import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers';
import SitesService from 'server/services/sites';

type RouteContext = {
Expand All @@ -44,6 +44,10 @@ type RouteContext = {
* required: true
* schema:
* type: string
* - in: query
* name: expectedAccessRevision
* required: false
* schema: { type: integer, minimum: 1, maximum: 2147483647 }
* responses:
* '200':
* description: Hosted static site expiration extended.
Expand All @@ -64,11 +68,15 @@ type RouteContext = {
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
*/
const postHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => {
const postHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => {
const routeParams = await params;
try {
const service = new SitesService();
const site = await service.extendSite(routeParams.siteId);
const site = await service.extendSite(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension now uses the caller identity and optional access revision, so only the owner can extend the current Site state.

routeParams.siteId,
principal,
readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision'))
);
return successResponse({ site }, { status: 200 }, req);
} catch (error) {
return sitesErrorResponse(error, req);
Expand Down
20 changes: 14 additions & 6 deletions src/app/api/v2/sites/[siteId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import { NextRequest } from 'next/server';
import { createPrincipalApiHandler } from 'server/lib/createApiHandler';
import type { Principal } from 'server/lib/principal';
import { successResponse } from 'server/lib/response';
import { sitesErrorResponse } from 'server/lib/sites/routeHelpers';
import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers';
import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers';
import SitesService from 'server/services/sites';

type RouteContext = {
Expand Down Expand Up @@ -71,6 +71,10 @@ type RouteContext = {
* required: true
* schema:
* type: string
* - in: query
* name: expectedAccessRevision
* required: false
* schema: { type: integer, minimum: 1, maximum: 2147483647 }
* responses:
* '200':
* description: Hosted static site deleted.
Expand All @@ -85,22 +89,26 @@ type RouteContext = {
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
*/
const getHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => {
const getHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => {
const routeParams = await params;
try {
const service = new SitesService();
const site = await service.getSite(routeParams.siteId);
const site = await service.getSite(routeParams.siteId, principal);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-Site endpoint now applies the same private metadata boundary as listing: only the owner can retrieve a private Site.

return successResponse({ site }, { status: 200 }, req);
} catch (error) {
return sitesErrorResponse(error, req);
}
};

const deleteHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => {
const deleteHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => {
const routeParams = await params;
try {
const service = new SitesService();
const site = await service.deleteSite(routeParams.siteId);
const site = await service.deleteSite(
routeParams.siteId,
principal,
readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision'))
);
return successResponse({ site }, { status: 200 }, req);
} catch (error) {
return sitesErrorResponse(error, req);
Expand Down
Loading
Loading