@akinon/projectzero 2.0.79-beta.1 → 2.0.79-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -2
- package/app-template/.env.example +1 -10
- package/app-template/AGENTS.md +8 -0
- package/app-template/CHANGELOG.md +81 -65
- package/app-template/README.md +25 -1
- package/app-template/next.config.mjs +7 -1
- package/app-template/package.json +41 -41
- package/app-template/src/app/[pz]/layout.tsx +0 -20
- package/app-template/src/app/api/client-env/route.ts +5 -0
- package/app-template/src/app/global-error.tsx +22 -0
- package/app-template/src/hooks/favourite-product-ids-context.tsx +104 -0
- package/app-template/src/hooks/index.ts +2 -0
- package/app-template/src/hooks/use-fav-button.tsx +35 -13
- package/app-template/src/plugins.js +1 -0
- package/app-template/src/proxy.ts +4 -47
- package/app-template/src/settings.js +0 -17
- package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +42 -0
- package/app-template/src/utils/favourite-product-ids-fallback.ts +51 -0
- package/app-template/src/views/category/category-info.tsx +25 -17
- package/app-template/src/views/header/search/index.tsx +13 -5
- package/app-template/src/views/product/slider.tsx +85 -38
- package/app-template/src/views/product-item/index.tsx +1 -1
- package/commands/plugins.ts +4 -0
- package/dist/commands/plugins.js +4 -0
- package/package.json +1 -1
- package/app-template/src/app/api/preview/link/route.ts +0 -115
- package/app-template/src/app/api/preview/route.ts +0 -96
- package/app-template/src/utils/__tests__/preview-contract.test.ts +0 -223
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { isValidPreviewId, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
|
|
2
|
-
import {
|
|
3
|
-
DEFAULT_PREVIEW_LINK_TTL_SECONDS,
|
|
4
|
-
signPreviewToken
|
|
5
|
-
} from '@akinon/next/utils/preview-token';
|
|
6
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Mints a signed, expiring preview entry token for the theme editor.
|
|
10
|
-
*
|
|
11
|
-
* The editor is a static SPA on another origin and must never hold the
|
|
12
|
-
* storefront's PREVIEW_SECRET. Instead it proves it is an editor user by
|
|
13
|
-
* forwarding its Omnitron session token; this handler verifies that token
|
|
14
|
-
* against Omnitron (`OMNITRON_URL`) and answers with a token the editor puts
|
|
15
|
-
* on the link as `?preview=<token>`. The link opens exactly one preview and
|
|
16
|
-
* stops working after PREVIEW_LINK_TTL_SECONDS (default 24h).
|
|
17
|
-
*
|
|
18
|
-
* CORS is open on purpose: the request carries no cookies, only the caller's
|
|
19
|
-
* Omnitron token, and the response is worthless without one.
|
|
20
|
-
*/
|
|
21
|
-
const CORS_HEADERS = {
|
|
22
|
-
'Access-Control-Allow-Origin': '*',
|
|
23
|
-
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
24
|
-
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
25
|
-
'Access-Control-Max-Age': '600',
|
|
26
|
-
'Cache-Control': 'no-store'
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const json = (body: Record<string, unknown>, status: number) =>
|
|
30
|
-
NextResponse.json(body, { status, headers: CORS_HEADERS });
|
|
31
|
-
|
|
32
|
-
export async function OPTIONS() {
|
|
33
|
-
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Bounded wait for Omnitron — an unreachable host must fail fast, not hang the editor. */
|
|
37
|
-
const OMNITRON_TIMEOUT_MS = 8_000;
|
|
38
|
-
|
|
39
|
-
/** True when Omnitron accepts the token as a signed-in user. */
|
|
40
|
-
const isEditorUser = async (
|
|
41
|
-
omnitronUrl: string,
|
|
42
|
-
authorization: string
|
|
43
|
-
): Promise<boolean> => {
|
|
44
|
-
try {
|
|
45
|
-
const response = await fetch(
|
|
46
|
-
`${omnitronUrl.replace(/\/+$/, '')}/api/v1/active_user/`,
|
|
47
|
-
{
|
|
48
|
-
headers: { Authorization: authorization, Accept: 'application/json' },
|
|
49
|
-
cache: 'no-store',
|
|
50
|
-
signal: AbortSignal.timeout(OMNITRON_TIMEOUT_MS)
|
|
51
|
-
}
|
|
52
|
-
);
|
|
53
|
-
return response.status === 200;
|
|
54
|
-
} catch {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export async function GET(request: NextRequest) {
|
|
60
|
-
const secret = process.env.PREVIEW_SECRET;
|
|
61
|
-
const omnitronUrl = process.env.OMNITRON_URL;
|
|
62
|
-
if (!secret || !omnitronUrl) {
|
|
63
|
-
return json(
|
|
64
|
-
{
|
|
65
|
-
error: 'preview-disabled',
|
|
66
|
-
message:
|
|
67
|
-
'Preview links are disabled: PREVIEW_SECRET and OMNITRON_URL must both be configured on the storefront.'
|
|
68
|
-
},
|
|
69
|
-
503
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const previewId =
|
|
74
|
-
request.nextUrl.searchParams.get(PREVIEW_ID_QUERY_PARAM) ?? '';
|
|
75
|
-
if (!isValidPreviewId(previewId)) {
|
|
76
|
-
return json(
|
|
77
|
-
{ error: 'invalid-preview-id', message: 'A valid preview_id is required.' },
|
|
78
|
-
400
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const authorization = request.headers.get('authorization') ?? '';
|
|
83
|
-
if (!/^Token \S+$/i.test(authorization)) {
|
|
84
|
-
return json(
|
|
85
|
-
{ error: 'unauthorized', message: 'An Omnitron token is required.' },
|
|
86
|
-
401
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
if (!(await isEditorUser(omnitronUrl, authorization))) {
|
|
90
|
-
return json(
|
|
91
|
-
{
|
|
92
|
-
error: 'unauthorized',
|
|
93
|
-
message: `Omnitron at ${omnitronUrl} did not accept the token (or did not answer).`
|
|
94
|
-
},
|
|
95
|
-
401
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const ttl =
|
|
100
|
-
Number(process.env.PREVIEW_LINK_TTL_SECONDS) ||
|
|
101
|
-
DEFAULT_PREVIEW_LINK_TTL_SECONDS;
|
|
102
|
-
const expiresAt = Math.floor(Date.now() / 1000) + ttl;
|
|
103
|
-
const token = await signPreviewToken({ previewId, expiresAt }, secret);
|
|
104
|
-
|
|
105
|
-
return json(
|
|
106
|
-
{
|
|
107
|
-
token,
|
|
108
|
-
previewId,
|
|
109
|
-
expiresAt: new Date(expiresAt * 1000).toISOString(),
|
|
110
|
-
// Ready to append to any page url of this storefront.
|
|
111
|
-
query: `preview=${encodeURIComponent(token)}`
|
|
112
|
-
},
|
|
113
|
-
200
|
|
114
|
-
);
|
|
115
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { PREVIEW_ID_COOKIE, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
|
|
2
|
-
import { verifyPreviewToken } from '@akinon/next/utils/preview-token';
|
|
3
|
-
import { cookies, draftMode } from 'next/headers';
|
|
4
|
-
import { redirect } from 'next/navigation';
|
|
5
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Enables/disables Next.js Draft Mode for widget previews, and records WHICH
|
|
9
|
-
* named preview is being viewed.
|
|
10
|
-
*
|
|
11
|
-
* Visitors never call this directly — any page url carrying `?preview` is
|
|
12
|
-
* redirected here by the middleware (see src/proxy.ts), and redirected back
|
|
13
|
-
* to the same page once the cookies are set. With draft mode on, the widget
|
|
14
|
-
* fetch layer serves `<slug>-preview-<id>` copies (falling back to live).
|
|
15
|
-
*
|
|
16
|
-
* The preview id rides in its own cookie rather than the url: pages keep
|
|
17
|
-
* their clean addresses and links followed afterwards stay inside the same
|
|
18
|
-
* preview. The cookie is folded into the pz route segment by the middleware
|
|
19
|
-
* (see settings.js `pzSegments`) because the widget pages are force-static,
|
|
20
|
-
* where Next.js blanks `cookies()` inside the render.
|
|
21
|
-
*
|
|
22
|
-
* Deny-by-default: entering preview ALWAYS requires `?preview=<token>` — a
|
|
23
|
-
* signed, expiring token minted by /api/preview/link for an editor user (see
|
|
24
|
-
* utils/preview-token.ts); PREVIEW_SECRET is the signing key and never
|
|
25
|
-
* travels in a url. Environments without the env have preview disabled
|
|
26
|
-
* entirely. Exiting never requires a token.
|
|
27
|
-
*/
|
|
28
|
-
export async function GET(request: NextRequest) {
|
|
29
|
-
const { searchParams } = request.nextUrl;
|
|
30
|
-
|
|
31
|
-
// Only ever redirect within the site — the target comes from the query.
|
|
32
|
-
const target = searchParams.get('redirect') ?? '/';
|
|
33
|
-
let safeTarget =
|
|
34
|
-
target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
|
35
|
-
// Never bounce back to a url that still carries ?preview — that would
|
|
36
|
-
// re-enter the middleware redirect and loop.
|
|
37
|
-
const targetUrl = new URL(safeTarget, request.nextUrl.origin);
|
|
38
|
-
targetUrl.searchParams.delete('preview');
|
|
39
|
-
targetUrl.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
|
|
40
|
-
safeTarget = `${targetUrl.pathname}${targetUrl.search}`;
|
|
41
|
-
// URL normalization can reintroduce a protocol-relative target the first
|
|
42
|
-
// guard already rejected (e.g. '/..//evil.com' normalizes to pathname
|
|
43
|
-
// '//evil.com', which browsers resolve to https://evil.com) — re-check the
|
|
44
|
-
// FINAL string, not just the raw input.
|
|
45
|
-
if (!safeTarget.startsWith('/') || safeTarget.startsWith('//')) {
|
|
46
|
-
safeTarget = '/';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const draft = await draftMode();
|
|
50
|
-
const cookieStore = await cookies();
|
|
51
|
-
|
|
52
|
-
if (searchParams.has('exit')) {
|
|
53
|
-
draft.disable();
|
|
54
|
-
cookieStore.delete(PREVIEW_ID_COOKIE);
|
|
55
|
-
redirect(safeTarget);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const secret = process.env.PREVIEW_SECRET;
|
|
59
|
-
if (!secret) {
|
|
60
|
-
return new NextResponse(
|
|
61
|
-
'Preview is disabled: PREVIEW_SECRET is not configured.',
|
|
62
|
-
{ status: 401 }
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const verdict = await verifyPreviewToken(
|
|
67
|
-
searchParams.get('token') ?? '',
|
|
68
|
-
secret
|
|
69
|
-
);
|
|
70
|
-
if (verdict.ok !== true) {
|
|
71
|
-
const { reason } = verdict;
|
|
72
|
-
return new NextResponse(
|
|
73
|
-
reason === 'expired'
|
|
74
|
-
? 'This preview link has expired — open the page from the editor again to get a fresh one.'
|
|
75
|
-
: 'Preview link is invalid or missing. Preview pages are opened from the theme editor.',
|
|
76
|
-
{ status: 401 }
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
const { previewId } = verdict.claims;
|
|
80
|
-
|
|
81
|
-
draft.enable();
|
|
82
|
-
// Same flags Next gives its own draft cookie (SameSite=None; Secure outside
|
|
83
|
-
// development): the storefront may be embedded cross-site (editor,
|
|
84
|
-
// Omnitron), and a Lax id cookie would be dropped there while the draft
|
|
85
|
-
// cookie survives — draft mode on, no id, live content under a "Preview"
|
|
86
|
-
// banner. `nextUrl.protocol` is not consulted: behind a TLS-terminating
|
|
87
|
-
// proxy it reads http even in production.
|
|
88
|
-
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
89
|
-
cookieStore.set(PREVIEW_ID_COOKIE, previewId, {
|
|
90
|
-
path: '/',
|
|
91
|
-
httpOnly: true,
|
|
92
|
-
sameSite: isDevelopment ? 'lax' : 'none',
|
|
93
|
-
secure: !isDevelopment
|
|
94
|
-
});
|
|
95
|
-
redirect(safeTarget);
|
|
96
|
-
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
isPreviewCopySlug,
|
|
3
|
-
isPreviewSlug,
|
|
4
|
-
isValidPreviewId,
|
|
5
|
-
previewSlug,
|
|
6
|
-
resolvePreviewSegment
|
|
7
|
-
} from '@akinon/next/utils/preview';
|
|
8
|
-
import {
|
|
9
|
-
decodePzValue,
|
|
10
|
-
encodePzValue,
|
|
11
|
-
getPzSegmentsConfig
|
|
12
|
-
} from '@akinon/next/utils/pz-segments';
|
|
13
|
-
import {
|
|
14
|
-
signPreviewToken,
|
|
15
|
-
verifyPreviewToken
|
|
16
|
-
} from '@akinon/next/utils/preview-token';
|
|
17
|
-
import { webcrypto } from 'node:crypto';
|
|
18
|
-
import { TextEncoder as NodeTextEncoder } from 'node:util';
|
|
19
|
-
|
|
20
|
-
// jsdom ships neither Web Crypto nor TextEncoder; the token code needs both.
|
|
21
|
-
if (!globalThis.crypto?.subtle) {
|
|
22
|
-
Object.defineProperty(globalThis, 'crypto', { value: webcrypto });
|
|
23
|
-
}
|
|
24
|
-
if (typeof globalThis.TextEncoder === 'undefined') {
|
|
25
|
-
Object.defineProperty(globalThis, 'TextEncoder', { value: NodeTextEncoder });
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The half of the preview contract this app owns: which named preview a
|
|
30
|
-
* request belongs to, and the widget slug that preview's copies live under.
|
|
31
|
-
* The theme editor builds the same slugs — if these change, previews stop
|
|
32
|
-
* resolving and every page silently falls back to live.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const req = (cookie?: string, draft = true) => ({
|
|
36
|
-
cookies: {
|
|
37
|
-
get: (name: string) => {
|
|
38
|
-
if (name === '__prerender_bypass') {
|
|
39
|
-
return draft ? { value: 'bypass' } : undefined;
|
|
40
|
-
}
|
|
41
|
-
return name === 'pz-preview-id' && cookie !== undefined
|
|
42
|
-
? { value: cookie }
|
|
43
|
-
: undefined;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('preview id validation', () => {
|
|
49
|
-
it('accepts the ids the editor produces', () => {
|
|
50
|
-
expect(isValidPreviewId('staging')).toBe(true);
|
|
51
|
-
expect(isValidPreviewId('black-friday')).toBe(true);
|
|
52
|
-
expect(isValidPreviewId('kis-kampanyasi-2026')).toBe(true);
|
|
53
|
-
expect(isValidPreviewId('a'.repeat(32))).toBe(true);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('rejects anything that could escape the slug', () => {
|
|
57
|
-
// Ids arrive from a cookie, so this is a trust boundary.
|
|
58
|
-
expect(isValidPreviewId('')).toBe(false);
|
|
59
|
-
expect(isValidPreviewId('-leading')).toBe(false);
|
|
60
|
-
expect(isValidPreviewId('Upper')).toBe(false);
|
|
61
|
-
expect(isValidPreviewId('has space')).toBe(false);
|
|
62
|
-
expect(isValidPreviewId('../etc')).toBe(false);
|
|
63
|
-
expect(isValidPreviewId('a'.repeat(33))).toBe(false);
|
|
64
|
-
expect(isValidPreviewId(undefined)).toBe(false);
|
|
65
|
-
// `--` is the pz segment separator; the editor never produces it.
|
|
66
|
-
expect(isValidPreviewId('a--b')).toBe(false);
|
|
67
|
-
expect(isValidPreviewId('trailing-')).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('preview slugs', () => {
|
|
72
|
-
it('suffixes the live slug with the preview id', () => {
|
|
73
|
-
expect(previewSlug('homepage-body-new', 'staging')).toBe(
|
|
74
|
-
'homepage-body-new-preview-staging'
|
|
75
|
-
);
|
|
76
|
-
expect(previewSlug('homepage-body-new', 'black-friday')).toBe(
|
|
77
|
-
'homepage-body-new-preview-black-friday'
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('recognises its own output so previews are never nested', () => {
|
|
82
|
-
expect(isPreviewSlug('homepage-body-new-preview-staging')).toBe(true);
|
|
83
|
-
expect(isPreviewSlug('homepage-body-new')).toBe(false);
|
|
84
|
-
// The old single-preview suffix carries no id and is not one of ours.
|
|
85
|
-
expect(isPreviewSlug('homepage-body-new-preview')).toBe(false);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('only treats a slug as a copy when it belongs to THIS preview', () => {
|
|
89
|
-
expect(isPreviewCopySlug('header-preview-staging', 'staging')).toBe(true);
|
|
90
|
-
// Another preview's copy is not ours to skip.
|
|
91
|
-
expect(isPreviewCopySlug('header-preview-winter', 'staging')).toBe(false);
|
|
92
|
-
// A live custom page whose name merely looks like a copy still gets its
|
|
93
|
-
// overlay — the shape-only check used to skip it and render live.
|
|
94
|
-
expect(isPreviewCopySlug('page-summer-preview-2026', 'staging')).toBe(
|
|
95
|
-
false
|
|
96
|
-
);
|
|
97
|
-
expect(isPreviewCopySlug('header', '')).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('the preview id inside the pz segment', () => {
|
|
102
|
-
const settings = {
|
|
103
|
-
pzSegments: { segments: [{ name: 'preview' }] }
|
|
104
|
-
};
|
|
105
|
-
const config = getPzSegmentsConfig(settings);
|
|
106
|
-
const url = encodeURIComponent('https://shop.test/collections/summer--sale');
|
|
107
|
-
|
|
108
|
-
it('round-trips a url containing the separator with a preview id after it', () => {
|
|
109
|
-
const encoded = encodePzValue(
|
|
110
|
-
{ locale: 'en', currency: 'usd', url, preview: 'staging' },
|
|
111
|
-
config
|
|
112
|
-
);
|
|
113
|
-
expect(decodePzValue(encoded, config)).toEqual({
|
|
114
|
-
locale: 'en',
|
|
115
|
-
currency: 'usd',
|
|
116
|
-
url,
|
|
117
|
-
preview: 'staging'
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('round-trips such a url when no preview is active', () => {
|
|
122
|
-
const encoded = encodePzValue(
|
|
123
|
-
{ locale: 'en', currency: 'usd', url, preview: '' },
|
|
124
|
-
config
|
|
125
|
-
);
|
|
126
|
-
expect(decodePzValue(encoded, config)).toEqual({
|
|
127
|
-
locale: 'en',
|
|
128
|
-
currency: 'usd',
|
|
129
|
-
url,
|
|
130
|
-
preview: ''
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('still reads a plain segment positionally', () => {
|
|
135
|
-
expect(decodePzValue('tr--try--%2F--kis', config)).toEqual({
|
|
136
|
-
locale: 'tr',
|
|
137
|
-
currency: 'try',
|
|
138
|
-
url: '%2F',
|
|
139
|
-
preview: 'kis'
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
describe('resolvePreviewSegment', () => {
|
|
145
|
-
it('passes a valid cookie through to the pz segment', () => {
|
|
146
|
-
expect(resolvePreviewSegment(req('staging'))).toBe('staging');
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('yields no preview for a missing or tampered cookie', () => {
|
|
150
|
-
expect(resolvePreviewSegment(req())).toBe('');
|
|
151
|
-
expect(resolvePreviewSegment(req(''))).toBe('');
|
|
152
|
-
expect(resolvePreviewSegment(req('../../evil'))).toBe('');
|
|
153
|
-
expect(resolvePreviewSegment(req('Staging'))).toBe('');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('ignores the id cookie unless draft mode is on', () => {
|
|
157
|
-
// An anonymous visitor forging pz-preview-id must not get a private
|
|
158
|
-
// cache key per request — without the draft cookie the id is inert.
|
|
159
|
-
expect(resolvePreviewSegment(req('staging', false))).toBe('');
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
describe('signed preview links', () => {
|
|
164
|
-
const secret = 'k3y';
|
|
165
|
-
const now = 1_800_000_000_000; // ms
|
|
166
|
-
const inAnHour = Math.floor(now / 1000) + 3600;
|
|
167
|
-
|
|
168
|
-
it('round-trips the preview id and expiry it was minted with', async () => {
|
|
169
|
-
const token = await signPreviewToken(
|
|
170
|
-
{ previewId: 'kis-kampanyasi', expiresAt: inAnHour },
|
|
171
|
-
secret
|
|
172
|
-
);
|
|
173
|
-
expect(token.startsWith('v1.kis-kampanyasi.')).toBe(true);
|
|
174
|
-
expect(await verifyPreviewToken(token, secret, now)).toEqual({
|
|
175
|
-
ok: true,
|
|
176
|
-
claims: { previewId: 'kis-kampanyasi', expiresAt: inAnHour }
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('dies at its expiry', async () => {
|
|
181
|
-
const token = await signPreviewToken(
|
|
182
|
-
{ previewId: 'staging', expiresAt: inAnHour },
|
|
183
|
-
secret
|
|
184
|
-
);
|
|
185
|
-
expect(await verifyPreviewToken(token, secret, inAnHour * 1000)).toEqual({
|
|
186
|
-
ok: false,
|
|
187
|
-
reason: 'expired'
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('rejects tampering with the id, the expiry or the key', async () => {
|
|
192
|
-
const token = await signPreviewToken(
|
|
193
|
-
{ previewId: 'staging', expiresAt: inAnHour },
|
|
194
|
-
secret
|
|
195
|
-
);
|
|
196
|
-
const [v, , exp, sig] = token.split('.');
|
|
197
|
-
expect(
|
|
198
|
-
(await verifyPreviewToken(`${v}.winter.${exp}.${sig}`, secret, now)).ok
|
|
199
|
-
).toBe(false);
|
|
200
|
-
expect(
|
|
201
|
-
(await verifyPreviewToken(`${v}.staging.${exp + 1}.${sig}`, secret, now))
|
|
202
|
-
.ok
|
|
203
|
-
).toBe(false);
|
|
204
|
-
expect((await verifyPreviewToken(token, 'other-key', now)).ok).toBe(false);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('treats anything else as malformed — including the old raw secret', async () => {
|
|
208
|
-
expect(await verifyPreviewToken('pz-preview-local', secret, now)).toEqual({
|
|
209
|
-
ok: false,
|
|
210
|
-
reason: 'malformed'
|
|
211
|
-
});
|
|
212
|
-
expect((await verifyPreviewToken('', secret, now)).ok).toBe(false);
|
|
213
|
-
expect(
|
|
214
|
-
(await verifyPreviewToken('v1.Bad Id.123.sig', secret, now)).ok
|
|
215
|
-
).toBe(false);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('refuses to mint for an invalid id', async () => {
|
|
219
|
-
await expect(
|
|
220
|
-
signPreviewToken({ previewId: '../x', expiresAt: inAnHour }, secret)
|
|
221
|
-
).rejects.toThrow();
|
|
222
|
-
});
|
|
223
|
-
});
|