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
47 changes: 47 additions & 0 deletions e2e/app/pages/identity.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
import {ref, onMounted} from 'vue';
import {useCroct} from '@croct/plug-nuxt/csr';

// The mock API echoes the identity of the caller
type Identity = {
token: string | null,
clientId: string | null,
};

// Evaluated and fetched through the internal routes, which the server resolves
const {data: evaluated} = await useEvaluation<Identity>('identity');
const {data: fetched} = await useContent<Identity>('identity-echo');

// Evaluated and fetched by the SDK running in the browser
const browserEvaluated = ref<Identity | null>(null);
const browserFetched = ref<Identity | null>(null);

onMounted(async () => {
const croct = useCroct();

browserEvaluated.value = await croct.evaluate<Identity>('identity');
browserFetched.value = (await croct.fetch<Identity>('identity-echo')).content;
});
</script>

<template>
<h1>Identity</h1>

<section v-if="evaluated">
<p data-testid="server-evaluation-token">{{ evaluated.token ?? 'none' }}</p>
<p data-testid="server-evaluation-client-id">{{ evaluated.clientId ?? 'none' }}</p>
</section>

<section v-if="fetched">
<p data-testid="server-fetch-token">{{ fetched.content.token ?? 'none' }}</p>
<p data-testid="server-fetch-client-id">{{ fetched.content.clientId ?? 'none' }}</p>
</section>

<section v-if="browserEvaluated">
<p data-testid="browser-evaluation-token">{{ browserEvaluated.token ?? 'none' }}</p>
</section>

<section v-if="browserFetched">
<p data-testid="browser-fetch-token">{{ browserFetched.token ?? 'none' }}</p>
</section>
</template>
3 changes: 3 additions & 0 deletions e2e/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
<li>
<NuxtLink to="/ssr/page-context?token=secret&foo=bar">page context</NuxtLink>
</li>
<li>
<NuxtLink to="/identity">identity</NuxtLink>
</li>
</ul>

<h2>CSR</h2>
Expand Down
12 changes: 12 additions & 0 deletions e2e/app/server/api/identity.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default defineEventHandler(async () => {
// Evaluated and fetched through the server composables, outside a page render
const [evaluated, fetched] = await Promise.all([
evaluate('identity'),
fetchContent('identity-echo'),
]);

return {
evaluated: evaluated,
fetched: fetched.content,
};
})
5 changes: 5 additions & 0 deletions e2e/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ export const TENANT_CREDENTIALS = {
// specs can assert which page an evaluation or fetch was based on.
export const CONTEXT_ECHO_QUERY = 'context';
export const CONTEXT_ECHO_SLOT = 'context-echo';

// Query and slot the mock server answers with the identity of the caller, so the
// specs can assert that both sides of the SDK evaluate as the same visitor.
export const IDENTITY_ECHO_QUERY = 'identity';
export const IDENTITY_ECHO_SLOT = 'identity-echo';
63 changes: 56 additions & 7 deletions e2e/mock-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import {createServer, type IncomingMessage, type ServerResponse} from 'http';
import {MOCK_SERVER_PORT, TENANT_NAME, TENANT_CREDENTIALS, CONTEXT_ECHO_QUERY, CONTEXT_ECHO_SLOT} from './constants';
import {
MOCK_SERVER_PORT,
TENANT_NAME,
TENANT_CREDENTIALS,
CONTEXT_ECHO_QUERY,
CONTEXT_ECHO_SLOT,
IDENTITY_ECHO_QUERY,
IDENTITY_ECHO_SLOT,
} from './constants';

const PORT = MOCK_SERVER_PORT;

Expand Down Expand Up @@ -33,6 +41,8 @@ const LOCALIZED_SLOT_CONTENT: Record<string, Record<string, object>> = {
},
};

const MOCK_CLIENT_ID = '11111111111111111111111111111111';

const EVALUATION_RESULTS: Record<string, unknown> = {
now: '2026-01-01T00:00:00.000000',
};
Expand All @@ -43,10 +53,21 @@ const CREDENTIALS_BY_TENANT: Record<string, object> = {
[TENANT_NAME]: TENANT_CREDENTIALS,
};

type Route = (body: Record<string, unknown>, url: URL) => {status: number, data: unknown};
type Headers = Record<string, string | string[] | undefined>;

type Route = (body: Record<string, unknown>, url: URL, headers: Headers) => {status: number, data: unknown};

function identityOf(headers: Headers): {token: string | null, clientId: string | null, preview: boolean} {
return {
token: (headers['x-token'] as string | undefined) ?? null,
clientId: (headers['x-client-id'] as string | undefined) ?? null,
preview: headers['x-preview-token'] !== undefined,
};
}

const routes: Record<string, Route> = {
track: () => ({status: 200, data: {}}),
cid: () => ({status: 200, data: MOCK_CLIENT_ID}),
credentials: (_, url) => {
const tenant = url.searchParams.get('tenant') ?? '';
const credentials = CREDENTIALS_BY_TENANT[tenant];
Expand All @@ -57,9 +78,14 @@ const routes: Record<string, Route> = {

return {status: 404, data: {error: `Unknown tenant "${tenant}".`}};
},
evaluate: body => {
evaluate: (body, _, headers) => {
const query = String(body.query ?? '');

// Echoes the identity of the caller, to compare both sides of the SDK
if (query === IDENTITY_ECHO_QUERY) {
return {status: 200, data: identityOf(headers)};
}

// Echoes the received context so the specs can assert which page
// the evaluation was based on.
if (query === CONTEXT_ECHO_QUERY) {
Expand All @@ -82,10 +108,24 @@ const routes: Record<string, Route> = {
},
};
},
content: (body, url) => {
content: (body, url, headers) => {
const slotId = String(body.slotId ?? url.searchParams.get('slotId') ?? '');
const baseSlotId = slotId.split('@')[0];

// Echoes the identity of the caller, to compare both sides of the SDK
if (baseSlotId === IDENTITY_ECHO_SLOT) {
return {
status: 200,
data: {
content: {
_component: null,
...identityOf(headers),
preview: body.previewToken !== undefined,
},
},
};
}

// Echoes the received context so the specs can assert which page
// the fetch was based on.
if (baseSlotId === CONTEXT_ECHO_SLOT) {
Expand Down Expand Up @@ -127,10 +167,19 @@ function handleRequest(request: IncomingMessage, response: ServerResponse): void
request.on('data', (chunk: Buffer) => body.push(chunk));

request.on('end', () => {
const {origin} = request.headers;

response.setHeader('Content-Type', 'application/json');
response.setHeader('Access-Control-Allow-Origin', '*');
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
response.setHeader('Access-Control-Allow-Headers', '*');
response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers'] ?? '*');

if (origin === undefined) {
response.setHeader('Access-Control-Allow-Origin', '*');
} else {
// The SDK sends credentials, which forbids a wildcard origin
response.setHeader('Access-Control-Allow-Origin', origin);
response.setHeader('Access-Control-Allow-Credentials', 'true');
}

if (request.method === 'OPTIONS') {
response.writeHead(204);
Expand All @@ -151,7 +200,7 @@ function handleRequest(request: IncomingMessage, response: ServerResponse): void
return;
}

const {status, data} = route(requestBody, url);
const {status, data} = route(requestBody, url, request.headers);

response.writeHead(status);
response.end(JSON.stringify(data));
Expand Down
30 changes: 23 additions & 7 deletions e2e/specs/middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {test, expect} from '@playwright/test';
import {Token} from '@croct/sdk/token';
import {DEFAULT_CREDENTIALS} from '../constants';

test.describe('server middleware', () => {
test.describe('cookies', () => {
Expand Down Expand Up @@ -52,10 +53,10 @@ test.describe('server middleware', () => {

test.describe('preview tokens', () => {
function createValidPreviewToken(): string {
const header = Buffer.from('{"alg":"none"}').toString('base64url');
const payload = Buffer.from('{"exp":9999999999}').toString('base64url');

return `${header}.${payload}.`;
// A token the SDK also accepts, as it manages the cookie in the browser
return Token.issue(DEFAULT_CREDENTIALS.appId)
.withDuration(3600)
.toString();
}

test('should set preview cookie from a valid query parameter', async ({page}) => {
Expand All @@ -70,6 +71,17 @@ test.describe('server middleware', () => {
expect(previewCookie!.value).toBe(previewToken);
});

test('should expose the preview token to the SDK', async ({page}) => {
const previewToken = createValidPreviewToken();

await page.goto(`/?croct-preview=${previewToken}`);

// The SDK renders the preview widget and manages the token in the browser
const cookies = await page.evaluate(() => document.cookie);

expect(cookies).toContain(`ct.preview_token=${previewToken}`);
});

test('should persist preview cookie across navigations', async ({page}) => {
const previewToken = createValidPreviewToken();

Expand Down Expand Up @@ -111,9 +123,13 @@ test.describe('server middleware', () => {

await page.goto('/?croct-preview=exit');

const cookiesAfterExit = await page.context().cookies();

expect(cookiesAfterExit.find(cookie => cookie.name === 'ct.preview_token')).toBeUndefined();
// The SDK also manages the cookie, so the exit settles asynchronously
await expect
.poll(
async () => (await page.context().cookies())
.find(cookie => cookie.name === 'ct.preview_token'),
)
.toBeUndefined();
});
});

Expand Down
131 changes: 131 additions & 0 deletions e2e/specs/ssr/identity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {test, expect} from '@playwright/test';
import type {Page} from '@playwright/test';
import {Token} from '@croct/sdk/token';
import {DEFAULT_CREDENTIALS} from '../../constants';

type Identity = {
clientId: string,
userToken: string,
};

test.describe('identity', () => {
async function getIdentity(page: Page): Promise<Identity> {
const cookies = await page.context().cookies();

return {
clientId: cookies.find(cookie => cookie.name === 'ct.client_id')!.value,
userToken: cookies.find(cookie => cookie.name === 'ct.user_token')!.value,
};
}

async function fetchIdentity(page: Page): Promise<{token: string, preview: boolean}> {
const {fetched} = await (await page.request.get('/api/identity')).json();

return fetched;
}

test('should expose the identity to the SDK', async ({page}) => {
await page.goto('/identity');

const cookies = await page.evaluate(() => document.cookie);

expect(cookies).toContain('ct.client_id=');
expect(cookies).toContain('ct.user_token=');
});

test('should evaluate and fetch as the identified visitor while rendering a first visit', async ({page}) => {
// No cookies yet, so the middleware issues the identity while rendering
await page.goto('/identity');

const {clientId, userToken} = await getIdentity(page);

await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken);
await expect(page.getByTestId('server-evaluation-client-id')).toHaveText(clientId);
await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken);
await expect(page.getByTestId('server-fetch-client-id')).toHaveText(clientId);
});

test('should evaluate and fetch as the identified visitor while rendering a later visit', async ({page}) => {
await page.goto('/identity');

const {clientId, userToken} = await getIdentity(page);

await page.goto('/identity');

await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken);
await expect(page.getByTestId('server-evaluation-client-id')).toHaveText(clientId);
await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken);
});

test('should evaluate and fetch as the identified visitor when navigating on the client', async ({page}) => {
await page.goto('/');

const {clientId, userToken} = await getIdentity(page);

await page.getByRole('link', {name: 'identity'}).click();

await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken);
await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken);
await expect(page.getByTestId('server-fetch-client-id')).toHaveText(clientId);
});

test('should evaluate and fetch as the identified visitor in the browser', async ({page}) => {
await page.goto('/identity');

const {userToken} = await getIdentity(page);

await expect(page.getByTestId('browser-evaluation-token')).toHaveText(userToken, {timeout: 10000});
await expect(page.getByTestId('browser-fetch-token')).toHaveText(userToken, {timeout: 10000});
});

test('should evaluate and fetch as the identified visitor in an application route', async ({page}) => {
await page.goto('/identity');

const {userToken} = await getIdentity(page);

// The request shares the cookies of the page
const response = await page.request.get('/api/identity');

expect(response.ok()).toBe(true);

const {evaluated, fetched} = await response.json();

expect(evaluated.token).toBe(userToken);
expect(fetched.token).toBe(userToken);
});

test('should stop previewing once the preview is exited', async ({page}) => {
const previewToken = Token.issue(DEFAULT_CREDENTIALS.appId)
.withDuration(3600)
.toString();

await page.goto(`/identity?croct-preview=${previewToken}`);

expect((await fetchIdentity(page)).preview).toBe(true);

await page.goto('/identity?croct-preview=exit');

// The SDK also manages the preview cookie, so exiting settles asynchronously
await expect
.poll(async () => (await fetchIdentity(page)).preview)
.toBe(false);
});

test('should keep the identity of an identified user', async ({page}) => {
// The application resolver identifies the user from a session cookie
await page.context().addCookies([{
name: 'app.session_user',
value: 'user-42',
url: 'http://localhost:3200',
}]);

await page.goto('/identity');

const {userToken} = await getIdentity(page);

await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken);
await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken);
await expect(page.getByTestId('browser-evaluation-token')).toHaveText(userToken, {timeout: 10000});
await expect(page.getByTestId('browser-fetch-token')).toHaveText(userToken, {timeout: 10000});
});
});
Loading
Loading