@jasonshimmy/vite-plugin-cer-app 0.7.0 → 0.9.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 +8 -0
- package/ROADMAP.md +278 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +6 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -1
- package/dist/cli/commands/preview-isr.js +12 -0
- package/dist/cli/commands/preview-isr.js.map +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +66 -6
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin/dev-server.d.ts +1 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +4 -2
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +30 -12
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
- package/dist/plugin/transforms/auto-import.js +5 -4
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +7 -1
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/composables/define-middleware.d.ts +15 -0
- package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
- package/dist/runtime/composables/define-middleware.js +16 -0
- package/dist/runtime/composables/define-middleware.js.map +1 -0
- package/dist/runtime/composables/index.d.ts +7 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +4 -1
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-cookie.d.ts +38 -0
- package/dist/runtime/composables/use-cookie.d.ts.map +1 -0
- package/dist/runtime/composables/use-cookie.js +104 -0
- package/dist/runtime/composables/use-cookie.js.map +1 -0
- package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
- package/dist/runtime/composables/use-runtime-config.js +42 -8
- package/dist/runtime/composables/use-runtime-config.js.map +1 -1
- package/dist/runtime/composables/use-seo-meta.d.ts +42 -0
- package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -0
- package/dist/runtime/composables/use-seo-meta.js +58 -0
- package/dist/runtime/composables/use-seo-meta.js.map +1 -0
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +15 -3
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/types/config.d.ts +14 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/middleware.d.ts +8 -2
- package/dist/types/middleware.d.ts.map +1 -1
- package/docs/cli.md +5 -0
- package/docs/composables.md +165 -7
- package/docs/configuration.md +53 -3
- package/docs/middleware.md +53 -25
- package/e2e/cypress/e2e/cookie.cy.ts +68 -0
- package/e2e/cypress/e2e/middleware.cy.ts +45 -0
- package/e2e/cypress/e2e/preview-hardening.cy.ts +79 -0
- package/e2e/cypress/e2e/seo-meta.cy.ts +108 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
- package/e2e/kitchen-sink/app/pages/cookie-test.ts +22 -0
- package/e2e/kitchen-sink/app/pages/seo-test.ts +23 -0
- package/package.json +1 -1
- package/src/__tests__/cli/preview-hardening.test.ts +175 -0
- package/src/__tests__/cli/preview-isr.test.ts +30 -0
- package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +21 -0
- package/src/__tests__/plugin/resolve-config.test.ts +18 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +39 -0
- package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
- package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
- package/src/__tests__/runtime/define-middleware.test.ts +43 -0
- package/src/__tests__/runtime/use-cookie.test.ts +218 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +86 -2
- package/src/__tests__/runtime/use-seo-meta.test.ts +109 -0
- package/src/cli/commands/preview-isr.ts +14 -0
- package/src/cli/commands/preview.ts +78 -6
- package/src/index.ts +3 -1
- package/src/plugin/dev-server.ts +1 -1
- package/src/plugin/dts-generator.ts +4 -2
- package/src/plugin/index.ts +32 -11
- package/src/plugin/transforms/auto-import.ts +5 -4
- package/src/plugin/virtual/routes.ts +7 -1
- package/src/runtime/composables/define-middleware.ts +17 -0
- package/src/runtime/composables/index.ts +7 -1
- package/src/runtime/composables/use-cookie.ts +128 -0
- package/src/runtime/composables/use-runtime-config.ts +67 -11
- package/src/runtime/composables/use-seo-meta.ts +75 -0
- package/src/runtime/entry-server-template.ts +15 -3
- package/src/types/config.ts +15 -0
- package/src/types/index.ts +2 -2
- package/src/types/middleware.ts +8 -6
package/docs/configuration.md
CHANGED
|
@@ -210,7 +210,7 @@ Set any flag to `false` to opt out and manage imports manually.
|
|
|
210
210
|
|
|
211
211
|
## `runtimeConfig` options
|
|
212
212
|
|
|
213
|
-
Expose typed, centralized
|
|
213
|
+
Expose typed, centralized configuration to your app. Public values are available everywhere; private values are server-only secrets resolved from environment variables at startup.
|
|
214
214
|
|
|
215
215
|
```ts
|
|
216
216
|
export default defineConfig({
|
|
@@ -219,6 +219,10 @@ export default defineConfig({
|
|
|
219
219
|
apiBase: process.env.VITE_API_BASE ?? 'https://api.example.com',
|
|
220
220
|
appVersion: '1.0.0',
|
|
221
221
|
},
|
|
222
|
+
private: {
|
|
223
|
+
dbUrl: '', // resolved from process.env.DB_URL at server startup
|
|
224
|
+
secretKey: '', // resolved from process.env.SECRET_KEY at server startup
|
|
225
|
+
},
|
|
222
226
|
},
|
|
223
227
|
})
|
|
224
228
|
```
|
|
@@ -230,9 +234,9 @@ export default defineConfig({
|
|
|
230
234
|
|
|
231
235
|
Values placed here are serialized into `virtual:cer-app-config` at build time and accessible on both server and client via `useRuntimeConfig().public`.
|
|
232
236
|
|
|
233
|
-
> **Security:** Only put values here that are safe to expose to the browser. Do not put secrets, tokens, or private keys in `public`.
|
|
237
|
+
> **Security:** Only put values here that are safe to expose to the browser. Do not put secrets, tokens, or private keys in `public`.
|
|
234
238
|
|
|
235
|
-
> **Serialization:** Values must be JSON-serializable (strings, numbers, booleans, plain objects, arrays). Functions, class instances, `undefined`, and circular references are not supported
|
|
239
|
+
> **Serialization:** Values must be JSON-serializable (strings, numbers, booleans, plain objects, arrays). Functions, class instances, `undefined`, and circular references are not supported.
|
|
236
240
|
|
|
237
241
|
```ts
|
|
238
242
|
// Any page, layout, component, or composable
|
|
@@ -252,6 +256,52 @@ import type { RuntimePublicConfig } from '@jasonshimmy/vite-plugin-cer-app/types
|
|
|
252
256
|
|
|
253
257
|
---
|
|
254
258
|
|
|
259
|
+
### `runtimeConfig.private`
|
|
260
|
+
|
|
261
|
+
**Type:** `Record<string, string>`
|
|
262
|
+
**Default:** `{}`
|
|
263
|
+
|
|
264
|
+
Server-only secrets. Declare keys with empty-string defaults in `cer.config.ts` for typing purposes. **Private values are never included in the client bundle.**
|
|
265
|
+
|
|
266
|
+
**Environment variable resolution order** (at server startup, for each declared key):
|
|
267
|
+
|
|
268
|
+
1. `process.env[key]` — exact case (e.g. `process.env.dbUrl`)
|
|
269
|
+
2. `process.env[UPPER_SNAKE_CASE(key)]` — conventional env var form (e.g. `process.env.DB_URL`)
|
|
270
|
+
3. The declared default value — used as a last-resort fallback
|
|
271
|
+
|
|
272
|
+
camelCase keys are automatically converted: `dbUrl` → `DB_URL`, `secretKey` → `SECRET_KEY`.
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// cer.config.ts
|
|
276
|
+
export default defineConfig({
|
|
277
|
+
runtimeConfig: {
|
|
278
|
+
private: {
|
|
279
|
+
dbUrl: '', // resolved from process.env.dbUrl or process.env.DB_URL
|
|
280
|
+
secretKey: '', // resolved from process.env.secretKey or process.env.SECRET_KEY
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// app/pages/data.ts — loader (server-only)
|
|
288
|
+
export const loader = async () => {
|
|
289
|
+
const { private: priv } = useRuntimeConfig()
|
|
290
|
+
const rows = await db.query(priv!.dbUrl)
|
|
291
|
+
return { rows }
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
> `useRuntimeConfig().private` is `undefined` on the client. Only access it in server-only contexts (loaders, server middleware, API handlers).
|
|
296
|
+
|
|
297
|
+
**TypeScript:** Import `RuntimePrivateConfig` to type your private config:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import type { RuntimePrivateConfig } from '@jasonshimmy/vite-plugin-cer-app/types'
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
255
305
|
## Passing config to the Vite plugin directly
|
|
256
306
|
|
|
257
307
|
When using `vite.config.ts` instead of (or alongside) `cer.config.ts`:
|
package/docs/middleware.md
CHANGED
|
@@ -9,39 +9,35 @@ The framework has two kinds of middleware:
|
|
|
9
9
|
|
|
10
10
|
## Route middleware
|
|
11
11
|
|
|
12
|
-
### Defining
|
|
12
|
+
### Defining middleware
|
|
13
13
|
|
|
14
|
-
Create a file in `app/middleware/`.
|
|
14
|
+
Create a file in `app/middleware/`. Export a default `MiddlewareFn` using `defineMiddleware`:
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
17
|
// app/middleware/auth.ts
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const auth: RouteMiddleware = async (to, from, next) => {
|
|
18
|
+
export default defineMiddleware(async (to, from) => {
|
|
21
19
|
const session = await getSession()
|
|
22
|
-
if (!session)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
next() // allow navigation
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export default auth
|
|
20
|
+
if (!session) return '/login' // redirect
|
|
21
|
+
return true // allow navigation
|
|
22
|
+
})
|
|
30
23
|
```
|
|
31
24
|
|
|
32
|
-
|
|
25
|
+
`defineMiddleware` is a no-op identity helper — it just provides TypeScript types without
|
|
26
|
+
any runtime overhead. It is auto-imported, so you don't need to import it manually.
|
|
27
|
+
|
|
28
|
+
### `MiddlewareFn` signature
|
|
33
29
|
|
|
34
30
|
```ts
|
|
35
|
-
type
|
|
31
|
+
type GuardResult = boolean | string | Promise<boolean | string>
|
|
36
32
|
|
|
37
|
-
type
|
|
38
|
-
to: Route,
|
|
39
|
-
from: Route | null,
|
|
40
|
-
next: NextFunction,
|
|
41
|
-
) => void | Promise<void>
|
|
33
|
+
type MiddlewareFn = (to: RouteState, from: RouteState | null) => GuardResult
|
|
42
34
|
```
|
|
43
35
|
|
|
44
|
-
|
|
36
|
+
| Return value | Effect |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `true` | Allow navigation |
|
|
39
|
+
| `false` | Block navigation (stay on current route) |
|
|
40
|
+
| `string` | Redirect to that path |
|
|
45
41
|
|
|
46
42
|
---
|
|
47
43
|
|
|
@@ -56,28 +52,60 @@ export const meta = {
|
|
|
56
52
|
}
|
|
57
53
|
```
|
|
58
54
|
|
|
59
|
-
Named middleware runs in addition to any global middleware.
|
|
60
|
-
|
|
61
55
|
---
|
|
62
56
|
|
|
63
57
|
### Multiple middleware
|
|
64
58
|
|
|
59
|
+
Middleware runs in the order listed. The first non-`true` result wins:
|
|
60
|
+
|
|
65
61
|
```ts
|
|
66
62
|
// app/pages/admin.ts
|
|
67
63
|
export const meta = {
|
|
68
64
|
middleware: ['auth', 'admin-role'],
|
|
69
|
-
// Runs: auth → admin-role → page
|
|
65
|
+
// Runs: auth → admin-role → page render
|
|
70
66
|
}
|
|
71
67
|
```
|
|
72
68
|
|
|
73
69
|
---
|
|
74
70
|
|
|
71
|
+
### Execution order within a navigation
|
|
72
|
+
|
|
73
|
+
1. `beforeEnter` fires on the matched route — runs all declared middleware in order
|
|
74
|
+
2. Route state updates (component renders)
|
|
75
|
+
3. `afterEnter` fires (analytics, logging)
|
|
76
|
+
|
|
77
|
+
Redirect loop protection: the router stops after 10 consecutive redirects.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### Error handling
|
|
82
|
+
|
|
83
|
+
If a middleware function throws (synchronously or asynchronously), navigation is **blocked** — the framework catches the error, logs it, and returns `false` to keep the user on the current route:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
[cer-app] Middleware "auth" threw an error: Error: session store unavailable
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This means a crashing middleware is always safe: the user stays put rather than landing on a broken page or being incorrectly redirected. Subsequent middleware in the same chain does not run.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
### TypeScript types
|
|
94
|
+
|
|
95
|
+
`MiddlewareFn` and `GuardResult` are exported from the package if you need them outside of auto-imported files:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import type { MiddlewareFn, GuardResult } from '@jasonshimmy/vite-plugin-cer-app/types'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
75
103
|
### All middleware files
|
|
76
104
|
|
|
77
105
|
All files in `app/middleware/` are registered and available by name. They are exported from `virtual:cer-middleware`:
|
|
78
106
|
|
|
79
107
|
```ts
|
|
80
|
-
import middleware from 'virtual:cer-middleware'
|
|
108
|
+
import { middleware } from 'virtual:cer-middleware'
|
|
81
109
|
// { auth: [Function], 'admin-role': [Function], ... }
|
|
82
110
|
```
|
|
83
111
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCookie() e2e tests — verifies isomorphic cookie read/write/remove
|
|
3
|
+
* works correctly in all rendering modes.
|
|
4
|
+
*
|
|
5
|
+
* Client-side path: set/remove via document.cookie.
|
|
6
|
+
* SSR path: reads from req.headers.cookie on initial request.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
10
|
+
|
|
11
|
+
// Helper: find an element inside the page-cookie-test shadow DOM (post-hydration).
|
|
12
|
+
const cookiePage = () =>
|
|
13
|
+
cy.get('cer-layout-view').shadow().find('page-cookie-test').shadow()
|
|
14
|
+
|
|
15
|
+
describe('useCookie() — client-side read/write/remove', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
cy.clearCookies()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('shows "not set" when the cookie is absent', () => {
|
|
21
|
+
cy.visit('/cookie-test')
|
|
22
|
+
cookiePage().find('[data-cy=cookie-value]').should('contain', 'not set')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reads a cookie set via cy.setCookie()', () => {
|
|
26
|
+
cy.setCookie('ks-test-cookie', 'cypress-value')
|
|
27
|
+
cy.visit('/cookie-test')
|
|
28
|
+
cookiePage().find('[data-cy=cookie-value]').should('contain', 'cypress-value')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('sets the cookie when the set button is clicked', () => {
|
|
32
|
+
cy.visit('/cookie-test')
|
|
33
|
+
cookiePage().find('[data-cy=set-cookie]').click({ force: true })
|
|
34
|
+
// After reload the SSR-rendered light DOM shows the new value
|
|
35
|
+
cy.get('[data-cy=cookie-value]').should('contain', 'hello-from-cer-app')
|
|
36
|
+
cy.getCookie('ks-test-cookie').should('have.property', 'value', 'hello-from-cer-app')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('removes the cookie when the remove button is clicked', () => {
|
|
40
|
+
cy.setCookie('ks-test-cookie', 'to-remove')
|
|
41
|
+
cy.visit('/cookie-test')
|
|
42
|
+
cookiePage().find('[data-cy=cookie-value]').should('contain', 'to-remove')
|
|
43
|
+
cookiePage().find('[data-cy=remove-cookie]').click({ force: true })
|
|
44
|
+
// After reload the SSR-rendered light DOM shows "not set"
|
|
45
|
+
cy.get('[data-cy=cookie-value]').should('contain', 'not set')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (mode === 'ssr') {
|
|
50
|
+
describe('useCookie() — server-side read (SSR)', () => {
|
|
51
|
+
it('reads the cookie from the request and renders its value', () => {
|
|
52
|
+
cy.setCookie('ks-test-cookie', 'ssr-cookie-value')
|
|
53
|
+
cy.visit('/cookie-test')
|
|
54
|
+
// The SSR-rendered HTML should include the cookie value server-side
|
|
55
|
+
cy.get('[data-cy=cookie-value]').should('contain', 'ssr-cookie-value')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('initial HTML contains the cookie value when cookie is sent with the request', () => {
|
|
59
|
+
cy.setCookie('ks-test-cookie', 'in-html')
|
|
60
|
+
cy.request({
|
|
61
|
+
url: '/cookie-test',
|
|
62
|
+
headers: { Cookie: 'ks-test-cookie=in-html' },
|
|
63
|
+
}).then((response) => {
|
|
64
|
+
expect(response.body).to.include('in-html')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for client-side route middleware (navigation guards).
|
|
3
|
+
*
|
|
4
|
+
* The kitchen-sink app ships an `auth` middleware that redirects unauthenticated
|
|
5
|
+
* users to /login. The /protected page declares `meta: { middleware: ['auth'] }`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
describe('Route middleware', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Ensure no stale auth token from a previous test
|
|
11
|
+
cy.clearLocalStorage()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
context('unauthenticated navigation', () => {
|
|
15
|
+
it('redirects to /login when visiting /protected without a token', () => {
|
|
16
|
+
cy.visit('/protected')
|
|
17
|
+
cy.url().should('include', '/login')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('shows the login page after redirect', () => {
|
|
21
|
+
cy.visit('/protected')
|
|
22
|
+
cy.get('[data-cy=login-heading]').should('contain', 'Login')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
context('authenticated navigation', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
cy.visit('/')
|
|
29
|
+
cy.window().then((win) => {
|
|
30
|
+
win.localStorage.setItem('ks-token', '1')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('allows navigation to /protected when a token is present', () => {
|
|
35
|
+
cy.visit('/protected')
|
|
36
|
+
cy.url().should('include', '/protected')
|
|
37
|
+
cy.get('[data-cy=protected-heading]').should('contain', 'Protected')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the protected page content', () => {
|
|
41
|
+
cy.visit('/protected')
|
|
42
|
+
cy.get('[data-cy=protected-note]').should('exist')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview server hardening e2e tests — verifies security headers,
|
|
3
|
+
* Cache-Control values, and graceful shutdown behaviour.
|
|
4
|
+
*
|
|
5
|
+
* Security headers and Cache-Control are server-level concerns, so these
|
|
6
|
+
* tests only run in SSR and SSG modes (both use `cer-app preview`).
|
|
7
|
+
* SPA mode also runs through the preview server, so the tests apply there too.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
describe('Preview server — security headers', () => {
|
|
11
|
+
it('responds with X-Content-Type-Options: nosniff', () => {
|
|
12
|
+
cy.request('/').then((response) => {
|
|
13
|
+
expect(response.headers['x-content-type-options']).to.eq('nosniff')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('responds with X-Frame-Options: DENY', () => {
|
|
18
|
+
cy.request('/').then((response) => {
|
|
19
|
+
expect(response.headers['x-frame-options']).to.eq('DENY')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('responds with Referrer-Policy: strict-origin-when-cross-origin', () => {
|
|
24
|
+
cy.request('/').then((response) => {
|
|
25
|
+
expect(response.headers['referrer-policy']).to.eq('strict-origin-when-cross-origin')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('includes security headers on 404 responses', () => {
|
|
30
|
+
cy.request({ url: '/definitely-not-a-real-page-xyz', failOnStatusCode: false }).then((response) => {
|
|
31
|
+
expect(response.headers['x-content-type-options']).to.eq('nosniff')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('Preview server — path traversal protection', () => {
|
|
37
|
+
// HTTP clients (including Cypress/got) normalize `..` segments before sending,
|
|
38
|
+
// so a raw traversal like `/../../../../etc/passwd` arrives at the server as
|
|
39
|
+
// `/etc/passwd`. The `isPathBounded` guard is exercised via unit tests
|
|
40
|
+
// (src/__tests__/cli/preview-isr.test.ts). Here we verify the observable
|
|
41
|
+
// guarantee: files that do not exist inside dist/ are never served.
|
|
42
|
+
it('does not serve a file that does not exist inside dist/', () => {
|
|
43
|
+
cy.request({ url: '/etc/passwd', failOnStatusCode: false }).then((response) => {
|
|
44
|
+
// Either 404 (static) or 200 (SSR SPA fallback) — never a served file from
|
|
45
|
+
// outside the dist directory. Critically, the response body must NOT contain
|
|
46
|
+
// typical /etc/passwd content.
|
|
47
|
+
expect(response.body).not.to.include('root:x:')
|
|
48
|
+
expect(response.body).not.to.include('/bin/bash')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// NOTE: HTTP clients (including Cypress/got) normalize `..` segments before
|
|
53
|
+
// sending, so raw traversal sequences never arrive at the server unchanged.
|
|
54
|
+
// The isPathBounded() guard is exhaustively covered at the unit level in
|
|
55
|
+
// src/__tests__/cli/preview-isr.test.ts. No additional e2e assertion is needed.
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('Preview server — Cache-Control', () => {
|
|
59
|
+
it('serves HTML with Cache-Control: no-cache', () => {
|
|
60
|
+
cy.request('/').then((response) => {
|
|
61
|
+
expect(response.headers['cache-control']).to.include('no-cache')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('serves content-hashed assets with immutable Cache-Control', () => {
|
|
66
|
+
// Get the page to discover an actual asset URL (Vite hashes asset filenames)
|
|
67
|
+
cy.request('/').then((htmlResponse) => {
|
|
68
|
+
const assetMatch = htmlResponse.body.match(/\/assets\/[^"'\s]+\.js/)
|
|
69
|
+
if (!assetMatch) return // no JS asset found in this page, skip
|
|
70
|
+
|
|
71
|
+
const assetUrl = assetMatch[0]
|
|
72
|
+
cy.request(assetUrl).then((assetResponse) => {
|
|
73
|
+
const cc = assetResponse.headers['cache-control'] as string
|
|
74
|
+
expect(cc).to.include('max-age=31536000')
|
|
75
|
+
expect(cc).to.include('immutable')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSeoMeta() e2e tests — verifies Open Graph, Twitter Card, canonical, and
|
|
3
|
+
* title/description tags are injected correctly in all modes.
|
|
4
|
+
*
|
|
5
|
+
* In SSR/SSG: tags must be present in the initial server-rendered HTML.
|
|
6
|
+
* In all modes: tags must be present after client hydration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
10
|
+
|
|
11
|
+
describe('useSeoMeta() — title and description', () => {
|
|
12
|
+
it('sets document title', () => {
|
|
13
|
+
cy.visit('/seo-test')
|
|
14
|
+
cy.title().should('eq', 'SEO Test — Kitchen Sink')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('sets meta description', () => {
|
|
18
|
+
cy.visit('/seo-test')
|
|
19
|
+
cy.get('meta[name="description"]').should('have.attr', 'content', 'A test page for useSeoMeta().')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('useSeoMeta() — Open Graph tags', () => {
|
|
24
|
+
it('sets og:title', () => {
|
|
25
|
+
cy.visit('/seo-test')
|
|
26
|
+
cy.get('meta[property="og:title"]').should('have.attr', 'content', 'SEO Test OG Title')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('sets og:description', () => {
|
|
30
|
+
cy.visit('/seo-test')
|
|
31
|
+
cy.get('meta[property="og:description"]').should('have.attr', 'content', 'SEO Test OG description.')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('sets og:image', () => {
|
|
35
|
+
cy.visit('/seo-test')
|
|
36
|
+
cy.get('meta[property="og:image"]').should('have.attr', 'content', 'https://example.com/og/seo-test.png')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('sets og:url', () => {
|
|
40
|
+
cy.visit('/seo-test')
|
|
41
|
+
cy.get('meta[property="og:url"]').should('have.attr', 'content', 'https://example.com/seo-test')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('sets og:type', () => {
|
|
45
|
+
cy.visit('/seo-test')
|
|
46
|
+
cy.get('meta[property="og:type"]').should('have.attr', 'content', 'website')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('sets og:site_name', () => {
|
|
50
|
+
cy.visit('/seo-test')
|
|
51
|
+
cy.get('meta[property="og:site_name"]').should('have.attr', 'content', 'Kitchen Sink')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('useSeoMeta() — Twitter Card tags', () => {
|
|
56
|
+
it('sets twitter:card', () => {
|
|
57
|
+
cy.visit('/seo-test')
|
|
58
|
+
cy.get('meta[name="twitter:card"]').should('have.attr', 'content', 'summary_large_image')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('sets twitter:title', () => {
|
|
62
|
+
cy.visit('/seo-test')
|
|
63
|
+
cy.get('meta[name="twitter:title"]').should('have.attr', 'content', 'SEO Test Twitter Title')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('sets twitter:site', () => {
|
|
67
|
+
cy.visit('/seo-test')
|
|
68
|
+
cy.get('meta[name="twitter:site"]').should('have.attr', 'content', '@ks')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('useSeoMeta() — canonical link', () => {
|
|
73
|
+
it('sets canonical link element', () => {
|
|
74
|
+
cy.visit('/seo-test')
|
|
75
|
+
cy.get('link[rel="canonical"]').should('have.attr', 'href', 'https://example.com/seo-test')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (mode !== 'spa') {
|
|
80
|
+
describe('useSeoMeta() — server-side injection (SSR/SSG)', () => {
|
|
81
|
+
it('title is in the initial HTML', () => {
|
|
82
|
+
cy.request('/seo-test').then((response) => {
|
|
83
|
+
expect(response.body).to.include('<title>SEO Test — Kitchen Sink</title>')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('og:title meta is in the initial HTML', () => {
|
|
88
|
+
cy.request('/seo-test').then((response) => {
|
|
89
|
+
expect(response.body).to.include('og:title')
|
|
90
|
+
expect(response.body).to.include('SEO Test OG Title')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('twitter:card meta is in the initial HTML', () => {
|
|
95
|
+
cy.request('/seo-test').then((response) => {
|
|
96
|
+
expect(response.body).to.include('twitter:card')
|
|
97
|
+
expect(response.body).to.include('summary_large_image')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('canonical link is in the initial HTML', () => {
|
|
102
|
+
cy.request('/seo-test').then((response) => {
|
|
103
|
+
expect(response.body).to.include('canonical')
|
|
104
|
+
expect(response.body).to.include('https://example.com/seo-test')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
// Route middleware — redirects to /login if not authenticated.
|
|
2
2
|
// Set localStorage.setItem('ks-token', '1') to simulate login.
|
|
3
|
-
export default (
|
|
3
|
+
export default defineMiddleware((_to, _from) => {
|
|
4
4
|
const isLoggedIn = typeof localStorage !== 'undefined'
|
|
5
5
|
? !!localStorage.getItem('ks-token')
|
|
6
6
|
: false
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} else {
|
|
11
|
-
next()
|
|
12
|
-
}
|
|
13
|
-
}
|
|
8
|
+
return isLoggedIn ? true : '/login'
|
|
9
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
component('page-cookie-test', () => {
|
|
2
|
+
const testCookie = useCookie('ks-test-cookie')
|
|
3
|
+
|
|
4
|
+
function setCookie() {
|
|
5
|
+
testCookie.set('hello-from-cer-app')
|
|
6
|
+
window.location.reload()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function removeCookie() {
|
|
10
|
+
testCookie.remove()
|
|
11
|
+
window.location.reload()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return html`
|
|
15
|
+
<div>
|
|
16
|
+
<h1 data-cy="cookie-test-heading">Cookie Test</h1>
|
|
17
|
+
<p data-cy="cookie-value">Value: ${testCookie.value ?? 'not set'}</p>
|
|
18
|
+
<button data-cy="set-cookie" @click="${setCookie}">Set cookie</button>
|
|
19
|
+
<button data-cy="remove-cookie" @click="${removeCookie}">Remove cookie</button>
|
|
20
|
+
</div>
|
|
21
|
+
`
|
|
22
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
component('page-seo-test', () => {
|
|
2
|
+
useSeoMeta({
|
|
3
|
+
title: 'SEO Test — Kitchen Sink',
|
|
4
|
+
description: 'A test page for useSeoMeta().',
|
|
5
|
+
ogTitle: 'SEO Test OG Title',
|
|
6
|
+
ogDescription: 'SEO Test OG description.',
|
|
7
|
+
ogImage: 'https://example.com/og/seo-test.png',
|
|
8
|
+
ogUrl: 'https://example.com/seo-test',
|
|
9
|
+
ogType: 'website',
|
|
10
|
+
ogSiteName: 'Kitchen Sink',
|
|
11
|
+
twitterCard: 'summary_large_image',
|
|
12
|
+
twitterTitle: 'SEO Test Twitter Title',
|
|
13
|
+
twitterSite: '@ks',
|
|
14
|
+
canonical: 'https://example.com/seo-test',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
return html`
|
|
18
|
+
<div>
|
|
19
|
+
<h1 data-cy="seo-test-heading">SEO Test</h1>
|
|
20
|
+
<p data-cy="seo-test-description">This page sets SEO tags via <code>useSeoMeta()</code>.</p>
|
|
21
|
+
</div>
|
|
22
|
+
`
|
|
23
|
+
})
|