@userfrosting/sprinkle-core 6.0.0-alpha.4 → 6.0.0-alpha.6
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/app/assets/composables/index.ts +3 -2
- package/app/assets/composables/useAxiosInterceptor.ts +30 -0
- package/app/assets/composables/useCsrf.ts +129 -0
- package/app/assets/composables/{sprunjer.ts → useSprunjer.ts} +10 -3
- package/app/assets/index.ts +16 -2
- package/app/assets/interfaces/ApiResponse.ts +8 -1
- package/app/assets/interfaces/index.ts +1 -1
- package/app/assets/interfaces/sprunjer.ts +2 -1
- package/app/assets/routes/index.ts +4 -4
- package/app/assets/stores/index.ts +3 -1
- package/app/assets/stores/useAlertsStore.ts +30 -0
- package/app/assets/tests/composables/useCsrf.test.ts +212 -0
- package/app/assets/tests/plugin.test.ts +4 -3
- package/app/assets/tests/stores/config.test.ts +1 -1
- package/package.json +5 -5
- /package/app/assets/stores/{config.ts → useConfigStore.ts} +0 -0
- /package/app/assets/{composables → stores}/usePageMeta.ts +0 -0
- /package/app/assets/views/{401Unauthorized.vue → Page401Unauthorized.vue} +0 -0
- /package/app/assets/views/{403Forbidden.vue → Page403Forbidden.vue} +0 -0
- /package/app/assets/views/{404NotFound.vue → Page404NotFound.vue} +0 -0
- /package/app/assets/views/{ErrorPage.vue → PageError.vue} +0 -0
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export { useSprunjer } from './
|
|
2
|
-
export {
|
|
1
|
+
export { useSprunjer } from './useSprunjer'
|
|
2
|
+
export { useCsrf } from './useCsrf'
|
|
3
|
+
export { useAxiosInterceptor } from './useAxiosInterceptor'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
import { useAlertsStore } from '../stores/useAlertsStore'
|
|
3
|
+
import { Severity } from '../interfaces'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Axios Error Handler
|
|
7
|
+
*
|
|
8
|
+
* This composable sets up an Axios interceptor to handle errors globally using
|
|
9
|
+
* the Alerts store. It sends the error to the Alerts store, unless it's a 401
|
|
10
|
+
* error.
|
|
11
|
+
*
|
|
12
|
+
* @see https://axios-http.com/docs/interceptors
|
|
13
|
+
*/
|
|
14
|
+
export const useAxiosInterceptor = () => {
|
|
15
|
+
axios.interceptors.response.use(
|
|
16
|
+
(response) => response,
|
|
17
|
+
(error) => {
|
|
18
|
+
if (error.response.status !== 401) {
|
|
19
|
+
const alertStore = useAlertsStore()
|
|
20
|
+
alertStore.push({
|
|
21
|
+
title: error.response.data.title ?? error.response?.statusText,
|
|
22
|
+
description: error.response?.data?.description ?? error.message,
|
|
23
|
+
style: Severity.Danger
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return Promise.reject(error)
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ref, watchEffect } from 'vue'
|
|
2
|
+
import { useConfigStore } from '../stores'
|
|
3
|
+
import axios from 'axios'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CSRF Protection Composable
|
|
7
|
+
*
|
|
8
|
+
* Automatically sets the CSRF token in the axios headers for all requests.
|
|
9
|
+
* The CSRF token is read from the meta tags in the HTML document.
|
|
10
|
+
* The CSRF token can updated when the server responds with a new token in the headers.
|
|
11
|
+
*
|
|
12
|
+
* @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#axios
|
|
13
|
+
*/
|
|
14
|
+
export const useCsrf = () => {
|
|
15
|
+
/**
|
|
16
|
+
* Public constant for the CSRF token name and value, plus respective keys.
|
|
17
|
+
*/
|
|
18
|
+
const key_name = ref(getNameKey())
|
|
19
|
+
const key_value = ref(getValueKey())
|
|
20
|
+
const name = ref(readMetaTag(key_name.value))
|
|
21
|
+
const token = ref(readMetaTag(key_value.value))
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set the axios headers for CSRF protection
|
|
25
|
+
*/
|
|
26
|
+
function setAxiosHeader() {
|
|
27
|
+
axios.defaults.headers.post[key_name.value] = name.value
|
|
28
|
+
axios.defaults.headers.post[key_value.value] = token.value
|
|
29
|
+
axios.defaults.headers.put[key_name.value] = name.value
|
|
30
|
+
axios.defaults.headers.put[key_value.value] = token.value
|
|
31
|
+
axios.defaults.headers.delete[key_name.value] = name.value
|
|
32
|
+
axios.defaults.headers.delete[key_value.value] = token.value
|
|
33
|
+
axios.defaults.headers.patch[key_name.value] = name.value
|
|
34
|
+
axios.defaults.headers.patch[key_value.value] = token.value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetch the CSRF token from the server
|
|
39
|
+
*/
|
|
40
|
+
async function fetchCsrfToken() {
|
|
41
|
+
const response = await axios.get('/api/csrf')
|
|
42
|
+
updateFromHeaders(response.headers)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the CSRF token name and value keys from config.
|
|
47
|
+
*/
|
|
48
|
+
function getNameKey(): string {
|
|
49
|
+
const config = useConfigStore()
|
|
50
|
+
return config.get('csrf.name', 'csrf') + '_name'
|
|
51
|
+
}
|
|
52
|
+
function getValueKey(): string {
|
|
53
|
+
const config = useConfigStore()
|
|
54
|
+
return config.get('csrf.name', 'csrf') + '_value'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Meta tag reader and writer
|
|
59
|
+
*/
|
|
60
|
+
function readMetaTag(name: string): string {
|
|
61
|
+
return document.querySelector("meta[name='" + name + "']")?.getAttribute('content') ?? ''
|
|
62
|
+
}
|
|
63
|
+
function writeMetaTag(name: string, value: string) {
|
|
64
|
+
const metaTag = document.querySelector("meta[name='" + name + "']")
|
|
65
|
+
if (metaTag) {
|
|
66
|
+
metaTag.setAttribute('content', value)
|
|
67
|
+
} else {
|
|
68
|
+
const newMetaTag = document.createElement('meta')
|
|
69
|
+
newMetaTag.setAttribute('name', name)
|
|
70
|
+
newMetaTag.setAttribute('content', value)
|
|
71
|
+
document.head.appendChild(newMetaTag)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Update the CSRF token with the values from the request headers.
|
|
77
|
+
*
|
|
78
|
+
* N.B.: CSRF keys are hardcoded with '{name}_name' and '{name}_value' in
|
|
79
|
+
* PHP. However, the headers doesn't allows underscores that are replaced
|
|
80
|
+
* with dashes automatically.
|
|
81
|
+
*/
|
|
82
|
+
function updateFromHeaders(headers: any) {
|
|
83
|
+
const config = useConfigStore()
|
|
84
|
+
const nameKey = config.get('csrf.name', 'csrf') + '-name'
|
|
85
|
+
const valueKey = config.get('csrf.name', 'csrf') + '-value'
|
|
86
|
+
|
|
87
|
+
// Update both value only if the headers are present
|
|
88
|
+
// This is to avoid overwriting the CSRF token with empty values
|
|
89
|
+
if (nameKey in headers) {
|
|
90
|
+
name.value = headers[nameKey]
|
|
91
|
+
}
|
|
92
|
+
if (valueKey in headers) {
|
|
93
|
+
token.value = headers[valueKey]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Return if CSRF is enabled
|
|
99
|
+
*/
|
|
100
|
+
function isEnabled(): boolean {
|
|
101
|
+
const config = useConfigStore()
|
|
102
|
+
return config.get('csrf.enabled', true)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Watchers - Watch for changes in the CSRF token and update the axios
|
|
107
|
+
* headers + meta tags
|
|
108
|
+
*/
|
|
109
|
+
watchEffect(() => {
|
|
110
|
+
if (isEnabled() && name.value !== '' && token.value !== '') {
|
|
111
|
+
writeMetaTag(key_name.value, name.value)
|
|
112
|
+
writeMetaTag(key_value.value, token.value)
|
|
113
|
+
setAxiosHeader()
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Export functions and managed states
|
|
119
|
+
*/
|
|
120
|
+
return {
|
|
121
|
+
key_name,
|
|
122
|
+
key_value,
|
|
123
|
+
name,
|
|
124
|
+
token,
|
|
125
|
+
isEnabled,
|
|
126
|
+
updateFromHeaders,
|
|
127
|
+
fetchCsrfToken
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -33,7 +33,13 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { ref, toValue, watchEffect, computed } from 'vue'
|
|
35
35
|
import axios from 'axios'
|
|
36
|
-
import type {
|
|
36
|
+
import type {
|
|
37
|
+
ApiErrorResponse,
|
|
38
|
+
AssociativeArray,
|
|
39
|
+
Sprunjer,
|
|
40
|
+
SprunjerData,
|
|
41
|
+
SprunjerResponse
|
|
42
|
+
} from '../interfaces'
|
|
37
43
|
|
|
38
44
|
export const useSprunjer = (
|
|
39
45
|
dataUrl: string | (() => string),
|
|
@@ -60,6 +66,7 @@ export const useSprunjer = (
|
|
|
60
66
|
|
|
61
67
|
// State
|
|
62
68
|
const loading = ref<boolean>(false)
|
|
69
|
+
const error = ref<ApiErrorResponse | null>(null)
|
|
63
70
|
|
|
64
71
|
/**
|
|
65
72
|
* Api fetch function
|
|
@@ -87,8 +94,7 @@ export const useSprunjer = (
|
|
|
87
94
|
data.value.filterable = response.data.filterable ?? []
|
|
88
95
|
})
|
|
89
96
|
.catch((err) => {
|
|
90
|
-
|
|
91
|
-
console.error(err)
|
|
97
|
+
error.value = err.response.data as ApiErrorResponse
|
|
92
98
|
})
|
|
93
99
|
.finally(() => {
|
|
94
100
|
loading.value = false
|
|
@@ -169,6 +175,7 @@ export const useSprunjer = (
|
|
|
169
175
|
data,
|
|
170
176
|
fetch,
|
|
171
177
|
loading,
|
|
178
|
+
error,
|
|
172
179
|
downloadCsv,
|
|
173
180
|
totalPages,
|
|
174
181
|
countFiltered,
|
package/app/assets/index.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import type { App } from 'vue'
|
|
2
2
|
import { useConfigStore, useTranslator } from './stores'
|
|
3
|
+
import { useCsrf } from './composables/useCsrf'
|
|
4
|
+
import { useAxiosInterceptor } from './composables'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Core Sprinkle initialization recipe.
|
|
6
8
|
*
|
|
7
9
|
* This recipe is responsible for loading the configuration from the api,
|
|
8
|
-
* loading the translations
|
|
9
|
-
* properties.
|
|
10
|
+
* loading the translations, register the translator as $t and $tdate global
|
|
11
|
+
* properties and setting up the axios CSRF headers.
|
|
10
12
|
*/
|
|
11
13
|
export default {
|
|
12
14
|
install: (app: App) => {
|
|
15
|
+
/**
|
|
16
|
+
* Add Axios error handler.
|
|
17
|
+
* Load first to ensure that all axios requests are intercepted.
|
|
18
|
+
* This is important for the config loading and translator loading.
|
|
19
|
+
*/
|
|
20
|
+
useAxiosInterceptor()
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* Load configuration
|
|
15
24
|
*/
|
|
@@ -22,5 +31,10 @@ export default {
|
|
|
22
31
|
translator.load()
|
|
23
32
|
app.config.globalProperties.$t = translator.translate
|
|
24
33
|
app.config.globalProperties.$tdate = translator.translateDate
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Setup CSRF Protection.
|
|
37
|
+
*/
|
|
38
|
+
useCsrf()
|
|
25
39
|
}
|
|
26
40
|
}
|
|
@@ -27,4 +27,4 @@ export type { SprunjerRequest, SprunjerResponse } from './sprunjerApi'
|
|
|
27
27
|
export type { DictionaryResponse, DictionaryEntries, DictionaryConfig } from './DictionaryApi'
|
|
28
28
|
|
|
29
29
|
// Misc
|
|
30
|
-
export type { ApiResponse } from './ApiResponse'
|
|
30
|
+
export type { ApiResponse, ApiErrorResponse } from './ApiResponse'
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Represents the interface for the Sprunjer composable.
|
|
5
5
|
*/
|
|
6
6
|
import type { Ref, ComputedRef } from 'vue'
|
|
7
|
-
import type { AssociativeArray } from '.'
|
|
7
|
+
import type { ApiErrorResponse, AssociativeArray } from '.'
|
|
8
8
|
|
|
9
9
|
export interface Sprunjer {
|
|
10
10
|
dataUrl: string | (() => string)
|
|
@@ -15,6 +15,7 @@ export interface Sprunjer {
|
|
|
15
15
|
data: Ref<SprunjerData>
|
|
16
16
|
fetch: () => void
|
|
17
17
|
loading: Ref<boolean>
|
|
18
|
+
error: Ref<ApiErrorResponse | null>
|
|
18
19
|
totalPages: ComputedRef<number>
|
|
19
20
|
downloadCsv: () => void
|
|
20
21
|
countFiltered: ComputedRef<number>
|
|
@@ -12,7 +12,7 @@ export default [
|
|
|
12
12
|
title: 'ERROR.404.TITLE',
|
|
13
13
|
description: 'ERROR.404.DESCRIPTION'
|
|
14
14
|
},
|
|
15
|
-
component: () => import('../views/
|
|
15
|
+
component: () => import('../views/Page404NotFound.vue')
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
path: '/:pathMatch(.*)*',
|
|
@@ -21,7 +21,7 @@ export default [
|
|
|
21
21
|
title: 'ERROR.401.TITLE',
|
|
22
22
|
description: 'ERROR.401.DESCRIPTION'
|
|
23
23
|
},
|
|
24
|
-
component: () => import('../views/
|
|
24
|
+
component: () => import('../views/Page401Unauthorized.vue')
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
path: '/:pathMatch(.*)*',
|
|
@@ -30,7 +30,7 @@ export default [
|
|
|
30
30
|
title: 'ERROR.403.TITLE',
|
|
31
31
|
description: 'ERROR.403.DESCRIPTION'
|
|
32
32
|
},
|
|
33
|
-
component: () => import('../views/
|
|
33
|
+
component: () => import('../views/Page403Forbidden.vue')
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
path: '/:pathMatch(.*)*',
|
|
@@ -39,6 +39,6 @@ export default [
|
|
|
39
39
|
title: 'ERROR.TITLE',
|
|
40
40
|
description: 'ERROR.DESCRIPTION'
|
|
41
41
|
},
|
|
42
|
-
component: () => import('../views/
|
|
42
|
+
component: () => import('../views/PageError.vue')
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import type { AlertInterface } from '../interfaces'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Alerts Store
|
|
6
|
+
*
|
|
7
|
+
* Manages application alerts in a reactive store. Templates can use this
|
|
8
|
+
* store to display alerts to users, including errors, warnings,
|
|
9
|
+
* informational, or success messages. When an alert is added, templates
|
|
10
|
+
* should automatically update the interface to reflect the change.
|
|
11
|
+
*/
|
|
12
|
+
export const useAlertsStore = defineStore('alerts', {
|
|
13
|
+
state: () => ({
|
|
14
|
+
alerts: [] as AlertInterface[]
|
|
15
|
+
}),
|
|
16
|
+
actions: {
|
|
17
|
+
push(alert: AlertInterface) {
|
|
18
|
+
this.alerts.push(alert)
|
|
19
|
+
},
|
|
20
|
+
pop() {
|
|
21
|
+
return this.alerts.pop()
|
|
22
|
+
},
|
|
23
|
+
shift() {
|
|
24
|
+
return this.alerts.shift()
|
|
25
|
+
},
|
|
26
|
+
clear() {
|
|
27
|
+
this.alerts = []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, beforeEach, afterEach, test, vi } from 'vitest'
|
|
2
|
+
import axios from 'axios'
|
|
3
|
+
import { useConfigStore } from '../../stores/useConfigStore'
|
|
4
|
+
import { useCsrf } from '../../composables/useCsrf'
|
|
5
|
+
import { nextTick } from 'vue'
|
|
6
|
+
|
|
7
|
+
// Mock the config store
|
|
8
|
+
vi.mock('../../stores/useConfigStore')
|
|
9
|
+
const mockUseConfigStore = {
|
|
10
|
+
get: vi.fn()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('Csrf Composable', () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.clearAllMocks()
|
|
16
|
+
vi.resetAllMocks()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// Mock the config store
|
|
21
|
+
mockUseConfigStore.get.mockImplementation((key, defaultValue) => {
|
|
22
|
+
if (key === 'csrf.enabled') return true
|
|
23
|
+
if (key === 'csrf.name') return 'csrf'
|
|
24
|
+
return defaultValue
|
|
25
|
+
})
|
|
26
|
+
vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
|
|
27
|
+
|
|
28
|
+
// Reset axios defaults
|
|
29
|
+
axios.defaults.headers.post = {}
|
|
30
|
+
axios.defaults.headers.put = {}
|
|
31
|
+
axios.defaults.headers.delete = {}
|
|
32
|
+
axios.defaults.headers.patch = {}
|
|
33
|
+
|
|
34
|
+
// Reset the document head
|
|
35
|
+
document.head.innerHTML = ''
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('initializes CSRF token name and value from meta tags', () => {
|
|
39
|
+
document.head.innerHTML = `
|
|
40
|
+
<meta name="csrf_name" content="123456">
|
|
41
|
+
<meta name="csrf_value" content="7c4a8d09">
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
const { name, token } = useCsrf()
|
|
45
|
+
expect(name.value).toBe('123456')
|
|
46
|
+
expect(token.value).toBe('7c4a8d09')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('sets axios headers correctly when CSRF is enabled', async () => {
|
|
50
|
+
const csrf = useCsrf()
|
|
51
|
+
|
|
52
|
+
// Expect axios default headers
|
|
53
|
+
expect(axios.defaults.headers.post).toEqual({})
|
|
54
|
+
expect(axios.defaults.headers.put).toEqual({})
|
|
55
|
+
expect(axios.defaults.headers.delete).toEqual({})
|
|
56
|
+
expect(axios.defaults.headers.patch).toEqual({})
|
|
57
|
+
|
|
58
|
+
// Set CSRF token values - Will trigger the WatchEffect
|
|
59
|
+
csrf.name.value = '654321'
|
|
60
|
+
csrf.token.value = 'abcdef'
|
|
61
|
+
|
|
62
|
+
// Wait for the next tick to ensure watchEffect is triggered
|
|
63
|
+
await nextTick()
|
|
64
|
+
|
|
65
|
+
// Expect axios headers to be set correctly
|
|
66
|
+
expect(csrf.isEnabled()).toBe(true)
|
|
67
|
+
expect(csrf.name.value).toBe('654321')
|
|
68
|
+
expect(csrf.token.value).toBe('abcdef')
|
|
69
|
+
expect(csrf.key_name.value).toBe('csrf_name')
|
|
70
|
+
expect(csrf.key_value.value).toBe('csrf_value')
|
|
71
|
+
expect(axios.defaults.headers.post['csrf_name']).toBe('654321')
|
|
72
|
+
expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
|
|
73
|
+
expect(axios.defaults.headers.put['csrf_name']).toBe('654321')
|
|
74
|
+
expect(axios.defaults.headers.put['csrf_value']).toBe('abcdef')
|
|
75
|
+
expect(axios.defaults.headers.delete['csrf_name']).toBe('654321')
|
|
76
|
+
expect(axios.defaults.headers.delete['csrf_value']).toBe('abcdef')
|
|
77
|
+
expect(axios.defaults.headers.patch['csrf_name']).toBe('654321')
|
|
78
|
+
expect(axios.defaults.headers.patch['csrf_value']).toBe('abcdef')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('does not set axios headers when CSRF is disabled', () => {
|
|
82
|
+
// Change the mock implementation to simulate CSRF being disabled
|
|
83
|
+
mockUseConfigStore.get.mockImplementation((key, defaultValue) => {
|
|
84
|
+
if (key === 'csrf.enabled') return false // CSRF is disabled
|
|
85
|
+
if (key === 'csrf.name') return 'csrf'
|
|
86
|
+
return defaultValue
|
|
87
|
+
})
|
|
88
|
+
vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
|
|
89
|
+
|
|
90
|
+
// Get the CSRF composable
|
|
91
|
+
const csrf = useCsrf()
|
|
92
|
+
|
|
93
|
+
// Assert everything is empty
|
|
94
|
+
expect(csrf.isEnabled()).toBe(false)
|
|
95
|
+
expect(csrf.name.value).toBe('')
|
|
96
|
+
expect(csrf.token.value).toBe('')
|
|
97
|
+
expect(axios.defaults.headers.post).toEqual({})
|
|
98
|
+
expect(axios.defaults.headers.put).toEqual({})
|
|
99
|
+
expect(axios.defaults.headers.delete).toEqual({})
|
|
100
|
+
expect(axios.defaults.headers.patch).toEqual({})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('updates CSRF token updates meta tags', async () => {
|
|
104
|
+
document.head.innerHTML = `
|
|
105
|
+
<meta name="csrf_name" content="old_name">
|
|
106
|
+
<meta name="csrf_value" content="old_value">
|
|
107
|
+
`
|
|
108
|
+
|
|
109
|
+
// Assert initial state
|
|
110
|
+
const csrf = useCsrf()
|
|
111
|
+
expect(csrf.name.value).toBe('old_name')
|
|
112
|
+
expect(csrf.token.value).toBe('old_value')
|
|
113
|
+
expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
|
|
114
|
+
'old_name'
|
|
115
|
+
)
|
|
116
|
+
expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
|
|
117
|
+
'old_value'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Update CSRF tokens manually
|
|
121
|
+
csrf.name.value = 'new_name'
|
|
122
|
+
csrf.token.value = 'new_value'
|
|
123
|
+
|
|
124
|
+
// Wait for the next tick to ensure watchEffect is triggered
|
|
125
|
+
await nextTick()
|
|
126
|
+
|
|
127
|
+
// Assert new state
|
|
128
|
+
expect(csrf.name.value).toBe('new_name')
|
|
129
|
+
expect(csrf.token.value).toBe('new_value')
|
|
130
|
+
expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
|
|
131
|
+
'new_name'
|
|
132
|
+
)
|
|
133
|
+
expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
|
|
134
|
+
'new_value'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('CSRF token can be updated from headers', async () => {
|
|
139
|
+
const csrf = useCsrf()
|
|
140
|
+
|
|
141
|
+
// Assert initial state
|
|
142
|
+
expect(csrf.name.value).toBe('')
|
|
143
|
+
expect(csrf.token.value).toBe('')
|
|
144
|
+
|
|
145
|
+
const headers = {
|
|
146
|
+
'csrf-name': 'new_name',
|
|
147
|
+
'csrf-value': 'new_value'
|
|
148
|
+
}
|
|
149
|
+
csrf.updateFromHeaders(headers)
|
|
150
|
+
|
|
151
|
+
// Wait for the next tick to ensure watchEffect is triggered
|
|
152
|
+
await nextTick()
|
|
153
|
+
|
|
154
|
+
expect(csrf.name.value).toBe('new_name')
|
|
155
|
+
expect(csrf.token.value).toBe('new_value')
|
|
156
|
+
expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
|
|
157
|
+
'new_name'
|
|
158
|
+
)
|
|
159
|
+
expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
|
|
160
|
+
'new_value'
|
|
161
|
+
)
|
|
162
|
+
expect(axios.defaults.headers.post['csrf_name']).toBe('new_name')
|
|
163
|
+
expect(axios.defaults.headers.post['csrf_value']).toBe('new_value')
|
|
164
|
+
expect(axios.defaults.headers.put['csrf_name']).toBe('new_name')
|
|
165
|
+
expect(axios.defaults.headers.put['csrf_value']).toBe('new_value')
|
|
166
|
+
expect(axios.defaults.headers.delete['csrf_name']).toBe('new_name')
|
|
167
|
+
expect(axios.defaults.headers.delete['csrf_value']).toBe('new_value')
|
|
168
|
+
expect(axios.defaults.headers.patch['csrf_name']).toBe('new_name')
|
|
169
|
+
expect(axios.defaults.headers.patch['csrf_value']).toBe('new_value')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('CSRF token can handle empty headers', async () => {
|
|
173
|
+
document.head.innerHTML = `
|
|
174
|
+
<meta name="csrf_name" content="123456">
|
|
175
|
+
<meta name="csrf_value" content="abcdef">
|
|
176
|
+
`
|
|
177
|
+
|
|
178
|
+
// Assert initial state
|
|
179
|
+
const csrf = useCsrf()
|
|
180
|
+
expect(csrf.name.value).toBe('123456')
|
|
181
|
+
expect(csrf.token.value).toBe('abcdef')
|
|
182
|
+
expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
|
|
183
|
+
'123456'
|
|
184
|
+
)
|
|
185
|
+
expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
|
|
186
|
+
'abcdef'
|
|
187
|
+
)
|
|
188
|
+
expect(axios.defaults.headers.post['csrf_name']).toBe('123456')
|
|
189
|
+
expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
|
|
190
|
+
|
|
191
|
+
// Call updateFromHeaders with empty headers
|
|
192
|
+
const headers = {
|
|
193
|
+
foo: 'bar'
|
|
194
|
+
}
|
|
195
|
+
csrf.updateFromHeaders(headers)
|
|
196
|
+
|
|
197
|
+
// Wait for the next tick to ensure watchEffect is triggered
|
|
198
|
+
await nextTick()
|
|
199
|
+
|
|
200
|
+
// Assert state remains unchanged
|
|
201
|
+
expect(csrf.name.value).toBe('123456')
|
|
202
|
+
expect(csrf.token.value).toBe('abcdef')
|
|
203
|
+
expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
|
|
204
|
+
'123456'
|
|
205
|
+
)
|
|
206
|
+
expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
|
|
207
|
+
'abcdef'
|
|
208
|
+
)
|
|
209
|
+
expect(axios.defaults.headers.post['csrf_name']).toBe('123456')
|
|
210
|
+
expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test, vi } from 'vitest'
|
|
2
2
|
import { createApp } from 'vue'
|
|
3
|
-
import { useConfigStore } from '../stores/
|
|
3
|
+
import { useConfigStore } from '../stores/useConfigStore'
|
|
4
4
|
import plugin from '..'
|
|
5
|
-
import * as Config from '../stores/
|
|
5
|
+
import * as Config from '../stores/useConfigStore'
|
|
6
6
|
import * as Translator from '../stores/useTranslator'
|
|
7
7
|
|
|
8
8
|
const mockConfigStore = {
|
|
9
|
-
load: vi.fn()
|
|
9
|
+
load: vi.fn(),
|
|
10
|
+
get: vi.fn().mockReturnValue('csrf')
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const mockTranslatorStore = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, beforeEach, test, vi } from 'vitest'
|
|
2
2
|
import { setActivePinia, createPinia } from 'pinia'
|
|
3
3
|
import axios from 'axios'
|
|
4
|
-
import { useConfigStore } from '../../stores/
|
|
4
|
+
import { useConfigStore } from '../../stores/useConfigStore'
|
|
5
5
|
|
|
6
6
|
const testConfig = {
|
|
7
7
|
name: 'Test Config',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@userfrosting/sprinkle-core",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core Sprinkle for UserFrosting",
|
|
6
6
|
"funding": "https://opencollective.com/userfrosting",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@types/luxon": "^3.4.2",
|
|
50
50
|
"@types/node": "^20.12.5",
|
|
51
51
|
"@vitejs/plugin-vue": "^5.0.4",
|
|
52
|
-
"@vitest/coverage-v8": "^1.
|
|
52
|
+
"@vitest/coverage-v8": "^3.1.1",
|
|
53
53
|
"@vue/eslint-config-prettier": "^9.0.0",
|
|
54
54
|
"@vue/eslint-config-typescript": "^13.0.0",
|
|
55
55
|
"@vue/test-utils": "^2.4.6",
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
"happy-dom": "^15.11.6",
|
|
60
60
|
"less": "^4.2.0",
|
|
61
61
|
"npm-run-all2": "^6.1.2",
|
|
62
|
-
"prettier": "^3.2
|
|
63
|
-
"vite": "^
|
|
62
|
+
"prettier": "^3.6.2",
|
|
63
|
+
"vite": "^6.2",
|
|
64
64
|
"vite-plugin-dts": "^4.0.0",
|
|
65
|
-
"vitest": "^1.
|
|
65
|
+
"vitest": "^3.1.1",
|
|
66
66
|
"vue": "^3.4.21",
|
|
67
67
|
"vue-router": "^4.2.4",
|
|
68
68
|
"vue-tsc": "^2.0.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|