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
28 changes: 24 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ on:
- opened
- reopened
- synchronize
paths-ignore:
- "**/*.md"
- ".github/ISSUE_TEMPLATE/**"
- ".github/PULL_REQUEST_TEMPLATE/**"
- ".github/CODEOWNERS"
- "LICENSE"
- "libs/shared/util/seed/src/lib/static-data/media/**"
merge_group:
push:
branches:
Expand All @@ -22,7 +29,9 @@ permissions:
env:
CDWR_DEBUG_LOGGING: true
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
NX_NO_CLOUD: ${{ startsWith(github.head_ref || github.ref_name, 'renovate') || github.event_name == 'push' || github.event_name == 'workflow_dispatch' || vars.NX_NO_CLOUD }}
NX_NO_CLOUD: ${{ startsWith(github.head_ref || github.ref_name, 'renovate') ||
github.event_name == 'push' || github.event_name == 'workflow_dispatch' ||
vars.NX_NO_CLOUD }}
NX_PARALLEL: ${{ vars.NX_PARALLEL }}
NX_VERBOSE_LOGGING: ${{ vars.NX_VERBOSE_LOGGING }}

Expand All @@ -49,10 +58,13 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

# Not needed in the merge queue — E2E and integration tests are skipped there
- name: Install playwright browsers
if: github.event_name != 'merge_group'
run: pnpm exec playwright install --with-deps

- name: Install Fly CLI
if: github.event_name != 'merge_group'
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: ${{ vars.FLY_CLI_VERSION }}
Expand All @@ -70,10 +82,17 @@ jobs:

- run: pnpm nx affected -t lint,test,build -c ci

# nx-payload-e2e only runs in e2e-matrix workflow
- run: pnpm nx affected -t e2e -c ci --exclude nx-payload-e2e
# E2E, integration, and smoke tests are skipped in the merge queue — they were
# already verified in the PR. The merge queue only needs lint/test/build.

- run: pnpm nx affected -t integration-test -c ci
# nx-payload-e2e runs separately in the e2e-matrix workflow
- name: E2E tests
if: github.event_name != 'merge_group'
run: pnpm nx affected -t e2e -c ci --exclude nx-payload-e2e

- name: Integration tests
if: github.event_name != 'merge_group'
run: pnpm nx affected -t integration-test -c ci
env:
FLY_TEST_API_TOKEN: ${{ secrets.FLY_TEST_API_TOKEN }}
FLY_TEST_ORG: ${{ vars.FLY_TEST_ORG }}
Expand All @@ -83,6 +102,7 @@ jobs:
INFISICAL_TEST_SITE: ${{ vars.INFISICAL_SITE }}

- name: Linux nx-payload e2e smoke (pnpm)
if: github.event_name != 'merge_group'
run: pnpm nx affected -t e2e --exclude '*,!tag:scope:nx-payload-e2e' -c quick
env:
CDWR_E2E_PACKAGE_MANAGER: pnpm
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/e2e-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ jobs:
with:
lookup-only: true
path: "**/node_modules"
key: ${{ matrix.os }}-modules-${{ matrix.node }}-${{ github.run_id }}
key: ${{ matrix.os }}-modules-${{ matrix.node }}-${{ hashFiles('pnpm-lock.yaml') }}

- if: steps.cache-modules.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
Expand Down Expand Up @@ -235,7 +235,7 @@ jobs:
id: cache-modules
with:
path: "**/node_modules"
key: ${{ matrix.os }}-modules-${{ matrix.node }}-${{ github.run_id }}
key: ${{ matrix.os }}-modules-${{ matrix.node }}-${{ hashFiles('pnpm-lock.yaml') }}

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand Down
34 changes: 34 additions & 0 deletions apps/cms-e2e/src/admin/drafts.admin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Admin UI — draft/versioning status column tests
*
* Verifies that the status column is visible in the pages and posts list views
* after enabling versioning (drafts) on both collections.
*/

import { expect, test } from '../fixtures';
import { loginAs } from '../helpers/login';

test.describe('/admin/collections — status column', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test.beforeEach(
async ({ page }) => await loginAs(page, 'tenantAdmin', { navigate: true })
);

test('pages list shows status column', async ({ page }) => {
await page.goto('/admin/collections/pages');

// The column header should be visible in the table
await expect(
page.getByRole('columnheader', { name: 'Status' })
).toBeVisible();
});

test('posts list shows status column', async ({ page }) => {
await page.goto('/admin/collections/posts');

await expect(
page.getByRole('columnheader', { name: 'Status' })
).toBeVisible();
});
});
2 changes: 1 addition & 1 deletion apps/cms-e2e/src/admin/pages.admin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test.describe('/admin/collections/pages', () => {
.getByRole('textbox')
.fill('Hello from test!');

await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Publish changes' }).click();

// Payload navigates from /create to the new document URL on success.
// Waiting for the URL change is more reliable than catching the toast,
Expand Down
96 changes: 96 additions & 0 deletions apps/cms-e2e/src/api/preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* /api/preview and /api/preview/exit — draft mode route tests
*
* E2E runs in tenant mode (TENANT_ID=moon).
*
* /api/preview enables Next.js draft mode after verifying the Payload session,
* then redirects to the given path.
* /api/preview/exit disables draft mode and redirects to the given path.
*/

import { expect, test } from '../fixtures';
import { loginAs } from '../helpers/login';

test.describe('GET /api/preview', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('returns 400 when redirect param is missing', async ({ request }) => {
const res = await request.get('/api/preview');
expect(res.status()).toBe(400);
});

test('returns 400 when redirect is not a relative path', async ({
request
}) => {
const res = await request.get(
'/api/preview?redirect=https://evil.example.com'
);
expect(res.status()).toBe(400);
});

test('returns 400 for scheme-relative open-redirect', async ({ request }) => {
const res = await request.get('/api/preview?redirect=//evil.example.com');
expect(res.status()).toBe(400);
});

test('returns 401 when unauthenticated', async ({ request }) => {
const res = await request.get('/api/preview?redirect=/');
expect(res.status()).toBe(401);
});

test('sets draft mode cookie and redirects when authenticated', async ({
page
}) => {
await loginAs(page, 'tenantAdmin', { navigate: false });

// Follow the redirect and check we land on the home page
const response = await page.goto('/api/preview?redirect=/');
expect(response?.status()).toBe(200);

// Draft mode sets the __prerender_bypass cookie
const cookies = await page.context().cookies();
const bypassCookie = cookies.find((c) => c.name === '__prerender_bypass');
expect(bypassCookie).toBeDefined();
});
});

test.describe('GET /api/preview/exit', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('falls back to / for scheme-relative open-redirect', async ({
page
}) => {
await loginAs(page, 'tenantAdmin', { navigate: false });

// Enable draft mode first
await page.goto('/api/preview?redirect=/');

// Exit with a scheme-relative redirect — should land on / not external host
const response = await page.goto(
'/api/preview/exit?redirect=//evil.example.com'
);
expect(response?.url()).not.toContain('evil.example.com');
});

test('clears draft mode cookie and redirects', async ({ page }) => {
await loginAs(page, 'tenantAdmin', { navigate: false });

// Enable draft mode first
await page.goto('/api/preview?redirect=/');
const cookiesBefore = await page.context().cookies();
expect(
cookiesBefore.find((c) => c.name === '__prerender_bypass')
).toBeDefined();

// Exit draft mode
const response = await page.goto('/api/preview/exit?redirect=/');
expect(response?.status()).toBe(200);

// The bypass cookie should be gone or expired
const cookiesAfter = await page.context().cookies();
const bypassCookie = cookiesAfter.find(
(c) => c.name === '__prerender_bypass'
);
expect(bypassCookie).toBeUndefined();
});
});
100 changes: 100 additions & 0 deletions apps/cms-e2e/src/site/draft-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Draft mode — site visibility tests
*
* Verifies that draft pages are invisible to unauthenticated visitors,
* become accessible in preview mode via /api/preview, and become publicly
* visible once published.
*
* E2E runs in tenant mode (TENANT_ID=moon).
*/

import { expect, test } from '../fixtures';
import { loginAs } from '../helpers/login';

const DRAFT_SLUG = `e2e-draft-page-${Date.now()}`;
let pageId: number;

test.describe('draft page visibility', () => {
test.use({ storageState: { cookies: [], origins: [] } });

/**
* Create a draft page in beforeAll so all tests can reference the same doc.
* We open a temporary browser context logged in as tenantAdmin — this is the
* standard beforeAll pattern used across the permissions tests.
*/
test.beforeAll(async ({ browser }) => {
const ctx = await browser.newContext();
const p = await ctx.newPage();

await loginAs(p, 'tenantAdmin');

// Provide a minimal content block so the required `layout` field passes validation
const res = await p.request.post('/api/pages', {
data: {
name: 'E2E Draft Page',
slug: DRAFT_SLUG,
layout: [{ blockType: 'content', columns: [] }],
_status: 'draft'
}
});

if (!res.ok()) {
throw new Error(
`Failed to create draft page: ${res.status()} ${await res.text()}`
);
}

const body = await res.json();
pageId = body.doc.id;

await ctx.close();
});

test('draft page returns 404 for unauthenticated visitors', async ({
page
}) => {
// No session cookies — unauthenticated request
await page.goto(`/${DRAFT_SLUG}`);

await expect(
page.getByRole('heading', { name: 'Page not found' })
).toBeVisible();
});

test('draft page is accessible after enabling preview mode', async ({
page
}) => {
await loginAs(page, 'tenantAdmin');

// /api/preview enables draft mode and redirects to the given path
const redirect = encodeURIComponent(`/${DRAFT_SLUG}`);
const response = await page.goto(`/api/preview?redirect=${redirect}`);

// Should follow the redirect and render the page (not 404)
expect(response?.status()).toBe(200);
await expect(
page.getByRole('heading', { name: 'Page not found' })
).toBeHidden();
});

test('published page is visible to unauthenticated visitors', async ({
page
}) => {
await loginAs(page, 'tenantAdmin');

// Publish the draft
const res = await page.request.patch(`/api/pages/${pageId}`, {
data: { _status: 'published' }
});
expect(res.status()).toBe(200);

// Visit the page without any session or preview cookie
await page.context().clearCookies();
const response = await page.goto(`/${DRAFT_SLUG}`);

expect(response?.status()).toBe(200);
await expect(
page.getByRole('heading', { name: 'Page not found' })
).toBeHidden();
});
});
15 changes: 8 additions & 7 deletions apps/cms/.env.local
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,14 @@ POSTGRES_USER=postgres
# NEXT_PUBLIC_DEPLOY_ENV=<set your public deploy environment here>
# NEXT_PUBLIC_SENTRY_DSN=<set SENTRY_DSN value here for client-side>

# S3 storage plugin (recommended to use S3 for development until Payload properly supports run-time enable/disable)
S3_BUCKET=media-development
S3_ACCESS_KEY_ID=8735f7e49ad2f200f84c24257c04e74e
S3_SECRET_ACCESS_KEY=45cdfe62be106d18d0617d9f517e2431e32711ec4b7ebf51a8119e6356b4375d
S3_ENDPOINT=https://tiuqdqnfadzjngucaatb.supabase.co/storage/v1/s3
S3_FORCE_PATH_STYLE=true
S3_REGION=eu-central-1
# S3 storage is not used in development — Payload uses local file storage (apps/cms/public/media).
# Uncomment and fill in to use Supabase S3 in development instead.
# S3_BUCKET=media-development
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# S3_ENDPOINT=https://tiuqdqnfadzjngucaatb.supabase.co/storage/v1/s3
# S3_FORCE_PATH_STYLE=true
# S3_REGION=eu-central-1

# SendGrid credentials (higher priority than ethereal email)
SENDGRID_API_KEY=
Expand Down
4 changes: 3 additions & 1 deletion apps/cms/src/app/(site)/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';

import { getPageData } from '@codeware/app-cms/data-access';
Expand All @@ -16,8 +17,9 @@ export default async function Page({ params }: Props) {
const { slug } = await params;
const slugString = slug.join('/');

const { isEnabled: draft } = await draftMode();
const runtime = await payloadRuntime();
const data = await getPageData(runtime, slugString);
const data = await getPageData(runtime, slugString, { draft });

if (!data) {
notFound();
Expand Down
5 changes: 4 additions & 1 deletion apps/cms/src/app/(site)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';

import { getPage } from '@codeware/app-cms/data-access';
Expand All @@ -9,11 +10,13 @@ import { LandingPagePreview } from './landing-page-preview.client';
// TODO: metadata

export default async function SiteIndexPage() {
const { isEnabled: draft } = await draftMode();
const runtime = await payloadRuntime();

const page = await getPage(
runtime,
runtime.tenantConfig?.landingPage.id ?? 0
runtime.tenantConfig?.landingPage.id ?? 0,
{ draft }
);

if (!page) {
Expand Down
Loading
Loading