From d4b57d2d6c36bd3830c3740b6a39b3c4bd15cc74 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 3 Mar 2026 11:56:30 +0100 Subject: [PATCH 1/3] feat: add `setRequestToken` and `fetchRequestToken` methods Signed-off-by: Ferdinand Thiessen --- .gitignore | 5 +- lib/globals.d.ts => globals.d.ts | 3 + lib/eventbus.d.ts | 1 + lib/index.ts | 2 +- lib/requesttoken.ts | 99 ++++- package-lock.json | 592 +++++++++++++++++++++++++++- package.json | 4 +- test/request-token.test.ts | 66 ---- test/requestToken-observers.test.ts | 83 ++++ test/requesttoken.test.ts | 164 ++++++++ test/tsconfig.json | 4 + tsconfig.json | 3 +- 12 files changed, 936 insertions(+), 90 deletions(-) rename lib/globals.d.ts => globals.d.ts (70%) delete mode 100644 test/request-token.test.ts create mode 100644 test/requestToken-observers.test.ts create mode 100644 test/requesttoken.test.ts create mode 100644 test/tsconfig.json diff --git a/.gitignore b/.gitignore index efa17b24..33fa2547 100644 --- a/.gitignore +++ b/.gitignore @@ -6,13 +6,16 @@ logs npm-debug.log* yarn-debug.log* yarn-error.log* - # Runtime data pids *.pid *.seed *.pid.lock +# Tests +.vitest* +__screenshots__/ + # Directory for instrumented libs generated by jscoverage/JSCover lib-cov diff --git a/lib/globals.d.ts b/globals.d.ts similarity index 70% rename from lib/globals.d.ts rename to globals.d.ts index 8ec15dde..637a4415 100644 --- a/lib/globals.d.ts +++ b/globals.d.ts @@ -4,6 +4,9 @@ */ declare global { + // eslint-disable-next-line camelcase + var _nc_auth_requesttoken: string | undefined + interface Window { _oc_isadmin?: boolean } diff --git a/lib/eventbus.d.ts b/lib/eventbus.d.ts index cbd674c4..45ed99bb 100644 --- a/lib/eventbus.d.ts +++ b/lib/eventbus.d.ts @@ -7,6 +7,7 @@ import type { NextcloudUser } from './user.ts' declare module '@nextcloud/event-bus' { export interface NextcloudEvents { + 'csrf-token-update': { token: string, _internal?: true } // mapping of 'event name' => 'event type' 'user:info:changed': NextcloudUser } diff --git a/lib/index.ts b/lib/index.ts index bc6dea4c..72cf2243 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -8,5 +8,5 @@ export type { NextcloudUser } from './user.ts' export { getCSPNonce } from './csp-nonce.ts' export { getGuestNickname, getGuestUser, setGuestNickname } from './guest.ts' -export { getRequestToken, onRequestTokenUpdate } from './requesttoken.ts' +export { fetchRequestToken, getRequestToken, onRequestTokenUpdate, setRequestToken } from './requesttoken.ts' export { getCurrentUser } from './user.ts' diff --git a/lib/requesttoken.ts b/lib/requesttoken.ts index 38cebe37..073e8350 100644 --- a/lib/requesttoken.ts +++ b/lib/requesttoken.ts @@ -3,14 +3,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { subscribe } from '@nextcloud/event-bus' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { generateUrl } from '@nextcloud/router' export interface CsrfTokenObserver { (token: string): void } -let token: string | null | undefined -const observers: CsrfTokenObserver[] = [] +_subscribeToTokenUpdates() // TODO: remove once we drop support for Nextcloud 33 and before /** * Get current request token @@ -18,33 +18,98 @@ const observers: CsrfTokenObserver[] = [] * @return Current request token or null if not set */ export function getRequestToken(): string | null { - if (token === undefined) { - // Only on first load, try to get token from document - token = document.head.dataset.requesttoken ?? null + if (globalThis._nc_auth_requestToken) { + return globalThis._nc_auth_requestToken } - return token + + if (globalThis.document) { + // for service workers or other contexts without DOM we need to safeguard this + return document.head.dataset.requesttoken ?? null + } + return null } /** - * Add an observer which is called when the CSRF token changes + * Set a new CSRF token (e.g. because of session refresh). + * This also emits an event bus event for the updated token. * - * @param observer The observer + * @param token - The new token + * @throws {Error} - If the passed token is not a potential valid token */ -export function onRequestTokenUpdate(observer: CsrfTokenObserver): void { - observers.push(observer) +export function setRequestToken(token: string): void { + if (!token || typeof token !== 'string') { + throw new Error('Invalid CSRF token given', { cause: { token } }) + } + + if (globalThis._nc_auth_requestToken === token) { + // token is the same as before, no need to update and especially no need to notify the observers + return + } + + globalThis._nc_auth_requestToken = token + if (globalThis.document) { + // For DOM environments we also set the token to the DOM, so it is available for legacy code + document.head.dataset.requesttoken = token + } + + emit('csrf-token-update', { token, _internal: true }) } -// Listen to server event and keep token in sync -subscribe('csrf-token-update', (e: unknown) => { - token = (e as { token: string }).token +/** + * Fetch the request token from the API. + * This does also set it on the current context, see `setRequestToken`. + * + * @throws {Error} - If the request failed + */ +export async function fetchRequestToken(): Promise { + const url = generateUrl('/csrftoken') + + const response = await fetch(url) + if (!response.ok) { + throw new Error('Could not fetch CSRF token from API', { cause: response }) + } + + try { + const { token } = await response.json() + setRequestToken(token) + return token + } catch (error) { + throw new Error('Could not parse CSRF token from API response', { cause: error }) + } +} - observers.forEach((observer) => { +/** + * Add an observer which is called when the CSRF token changes + * + * @param observer The observer + * @return A function to unsubscribe the observer + */ +export function onRequestTokenUpdate(observer: CsrfTokenObserver): () => void { + const wrapper = async ({ token }: { token: string }) => { try { - observer(token!) + observer(token) } catch (error) { // we cannot use the logger as the logger uses this library = circular dependency // eslint-disable-next-line no-console console.error('Error updating CSRF token observer', error) } + } + + subscribe('csrf-token-update', wrapper) + return () => unsubscribe('csrf-token-update', wrapper) +} + +/** + * Subscribe to token update events from server. + * + * @todo - This is legacy and not needed once all supported server versions use `setRequestToken` of this library. + */ +function _subscribeToTokenUpdates(): void { + // Listen to server event and keep token in sync + subscribe('csrf-token-update', ({ token, _internal }) => { + if (!_internal) { + // Only update the token if the event is not emitted from this library, otherwise we would end in a loop + setRequestToken(token) + } }) -}) +} diff --git a/package-lock.json b/package-lock.json index fbd1d4ff..8b79a7ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "GPL-3.0-or-later", "dependencies": { "@nextcloud/browser-storage": "^0.5.0", - "@nextcloud/event-bus": "^3.3.3" + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/router": "^3.1.0" }, "devDependencies": { "@nextcloud/eslint-config": "^9.0.0-rc.9", @@ -19,6 +20,7 @@ "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "happy-dom": "^20.8.9", + "msw": "^2.13.2", "typedoc": "^0.28.18", "typescript": "^6.0.2", "vite": "^7.3.2", @@ -844,6 +846,94 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1023,6 +1113,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nextcloud/browser-storage": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.5.0.tgz", @@ -1126,11 +1234,22 @@ "node": "^20 || ^22 || ^24" } }, + "node_modules/@nextcloud/router": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.1.0.tgz", + "integrity": "sha512-e4dkIaxRSwdZJlZFpn9x03QgBn/Sa2hN1hp/BA7+AbzykmSAlKuWfdmX8j/8ewrLpQwYmZR23IZO9XwpJXq2Uw==", + "license": "GPL-3.0-or-later", + "dependencies": { + "@nextcloud/typings": "^1.10.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, "node_modules/@nextcloud/typings": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.10.0.tgz", "integrity": "sha512-SMC42rDjOH3SspPTLMZRv76ZliHpj2JJkF8pGLP8l1QrVTZxE47Qz5qeKmbj2VL+dRv2e/NgixlAFmzVnxkhqg==", - "dev": true, "license": "GPL-3.0-or-later", "dependencies": { "@types/jquery": "3.5.16" @@ -1168,6 +1287,31 @@ "vite": "^7.1.10" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -2235,7 +2379,6 @@ "version": "3.5.16", "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", "integrity": "sha512-bsI7y4ZgeMkmpG9OM710RRzDFp+w4P1RGiIt30C1mSBT+ExCleeh4HObwgArnDFELmRrOpXgSYN9VF1hj+f1lw==", - "dev": true, "license": "MIT", "dependencies": { "@types/sizzle": "*" @@ -2268,6 +2411,12 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.9.tgz", "integrity": "sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==", + "license": "MIT" + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "dev": true, "license": "MIT" }, @@ -3017,6 +3166,32 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/are-docs-informative": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", @@ -3546,6 +3721,69 @@ "node": ">= 0.10" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comment-parser": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", @@ -3597,6 +3835,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js-compat": { "version": "3.43.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz", @@ -3942,6 +4194,13 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -4668,6 +4927,16 @@ "node": ">= 0.4" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4763,6 +5032,16 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/happy-dom": { "version": "20.8.9", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", @@ -4894,6 +5173,13 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -5059,6 +5345,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5109,6 +5405,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5621,6 +5924,51 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.2.tgz", + "integrity": "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -5628,6 +5976,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5843,6 +6201,13 @@ "dev": true, "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5979,6 +6344,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6264,6 +6636,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6308,6 +6690,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -6737,6 +7126,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sort-object-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.1.0.tgz", @@ -6899,6 +7301,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -6930,6 +7342,13 @@ "xtend": "^4.0.2" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6950,6 +7369,34 @@ "node": ">=0.6.19" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7002,6 +7449,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -7059,6 +7519,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -7106,6 +7586,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -7139,6 +7632,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7286,6 +7795,16 @@ "node": ">= 10.0.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -8192,6 +8711,21 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -8234,6 +8768,16 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -8257,6 +8801,35 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8269,6 +8842,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index adbdbb8d..82cc76e7 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ }, "dependencies": { "@nextcloud/browser-storage": "^0.5.0", - "@nextcloud/event-bus": "^3.3.3" + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/router": "^3.1.0" }, "devDependencies": { "@nextcloud/eslint-config": "^9.0.0-rc.9", @@ -45,6 +46,7 @@ "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "happy-dom": "^20.8.9", + "msw": "^2.13.2", "typedoc": "^0.28.18", "typescript": "^6.0.2", "vite": "^7.3.2", diff --git a/test/request-token.test.ts b/test/request-token.test.ts deleted file mode 100644 index ba375e54..00000000 --- a/test/request-token.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { emit } from '@nextcloud/event-bus' -import { beforeEach, describe, expect, test, vi } from 'vitest' - -describe('request token', () => { - beforeEach(() => { - vi.resetModules() - vi.resetAllMocks() - delete document.head.dataset.requesttoken - }) - - test('return null if no token found', async () => { - const { getRequestToken } = await import('../lib/index.ts') - expect(getRequestToken()).toBe(null) - }) - - test('read initial token', async () => { - document.head.dataset.requesttoken = 'random-token' - const { getRequestToken } = await import('../lib/index.ts') - expect(getRequestToken()).toBe('random-token') - }) - - test('can update token by event', async () => { - const { getRequestToken } = await import('../lib/index.ts') - - emit('csrf-token-update', { - token: 'token123', - }) - - expect(getRequestToken()).toBe('token123') - }) - - test('request token observer is called', async () => { - const { onRequestTokenUpdate } = await import('../lib/index.ts') - const observer = vi.fn(() => { }) - - onRequestTokenUpdate(observer) - emit('csrf-token-update', { - token: 'token123', - }) - - expect(observer.mock.calls.length).toBe(1) - }) - - test('handle exception in observer', async () => { - const spy = vi.spyOn(window.console, 'error') - const { onRequestTokenUpdate } = await import('../lib/index.ts') - const observer = vi.fn(() => { - throw new Error('!Error!') - }) - // silence the console - spy.mockImplementationOnce(() => {}) - - onRequestTokenUpdate(observer) - emit('csrf-token-update', { - token: 'token123', - }) - - expect(observer.mock.calls.length).toBe(1) - expect(spy).toHaveBeenCalledOnce() - }) -}) diff --git a/test/requestToken-observers.test.ts b/test/requestToken-observers.test.ts new file mode 100644 index 00000000..14140844 --- /dev/null +++ b/test/requestToken-observers.test.ts @@ -0,0 +1,83 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { emit } from '@nextcloud/event-bus' +import { beforeEach, describe, expect, it, test, vi } from 'vitest' + +test('it does set the new token form legacy event', async () => { + const { getRequestToken } = await import('../lib/requestToken.ts') + mockToken('old-token') + + // older server versions emitted the event directly without using this libary + emit('csrf-token-update', { token: 'new-token' }) + expect(getRequestToken()).toBe('new-token') +}) + +describe('request token observers', () => { + beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() + mockToken(undefined) + }) + + it('request token observer is called', async () => { + const { onRequestTokenUpdate, setRequestToken } = await import('../lib/requestToken.ts') + const observer = vi.fn(() => { }) + + onRequestTokenUpdate(observer) + expect(observer).not.toHaveBeenCalled() + + setRequestToken('token123') + expect(observer).toHaveBeenCalledTimes(1) + expect(observer).toHaveBeenCalledWith('token123') + }) + + it('request token observer can be unsubscribed', async () => { + const { onRequestTokenUpdate, setRequestToken } = await import('../lib/requestToken.ts') + const observer = vi.fn(() => { }) + + const unsubscribe = onRequestTokenUpdate(observer) + expect(observer).not.toHaveBeenCalled() + + setRequestToken('token123') + expect(observer).toHaveBeenCalledTimes(1) + expect(observer).toHaveBeenCalledWith('token123') + + unsubscribe() + setRequestToken('token456') + expect(observer).toHaveBeenCalledTimes(1) // Should not be called again + }) + + it('handle exception in observer', async () => { + const { onRequestTokenUpdate, setRequestToken } = await import('../lib/requestToken.ts') + const spy = vi.spyOn(window.console, 'error') + const observer = vi.fn(() => { + throw new Error('!Error!') + }) + // silence the console + spy.mockImplementationOnce(() => {}) + + onRequestTokenUpdate(observer) + setRequestToken('token123') + + expect(observer).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledOnce() + }) +}) + +/** + * Mock the request token directly so we can it reading it. + * + * @param token - The CSRF token to mock + */ +function mockToken(token?: string) { + if (token === undefined) { + delete document.head.dataset.requesttoken + delete globalThis._nc_auth_requestToken + } else { + document.head.dataset.requesttoken = token + globalThis._nc_auth_requestToken = token + } +} diff --git a/test/requesttoken.test.ts b/test/requesttoken.test.ts new file mode 100644 index 00000000..f5d9ca6f --- /dev/null +++ b/test/requesttoken.test.ts @@ -0,0 +1,164 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchRequestToken, getRequestToken, setRequestToken } from '../lib/requestToken.ts' + +const eventbus = vi.hoisted(() => ({ emit: vi.fn(), subscribe: vi.fn() })) +vi.mock('@nextcloud/event-bus', () => eventbus) + +const server = setupServer() + +describe('getRequestToken', () => { + it('can read the token from DOM', () => { + mockToken('tokenmock-123', undefined) + expect(getRequestToken()).toBe('tokenmock-123') + }) + + it('can handle missing token', () => { + mockToken(undefined) + expect(getRequestToken()).toBeNull() + }) + + it('can handle cache token', () => { + mockToken('cached-token') + expect(getRequestToken()).toBe('cached-token') + }) + + it('prioritizes cached token', () => { + mockToken('dom-token', 'cached-token') + expect(getRequestToken()).toBe('cached-token') + }) +}) + +describe('setRequestToken', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('does emit an event on change', () => { + setRequestToken('new-token') + expect(eventbus.emit).toBeCalledTimes(1) + expect(eventbus.emit).toBeCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' })) + }) + + it('does set the new token to the DOM', () => { + setRequestToken('new-token') + expect(document.head.dataset.requesttoken).toBe('new-token') + }) + + it('does remember the new token', () => { + mockToken('old-token') + setRequestToken('new-token') + expect(getRequestToken()).toBe('new-token') + }) + + it('throws if the token is not a string', () => { + // @ts-expect-error it invalid values + expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given') + }) + + it('throws if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + }) + + it('does not emit an event if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + expect(eventbus.emit).not.toBeCalled() + }) +}) + +describe('fetchRequestToken', () => { + const successfullCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json({ token: 'new-token' }) + }) + const forbiddenCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 403 }) + }) + const serverErrorCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 500 }) + }) + const networkErrorCsrf = http.get('/index.php/csrftoken', () => { + return new HttpResponse(null, { type: 'error' }) + }) + + beforeAll(() => { + server.listen() + ;(window as unknown as Record)._oc_webroot = '' + }) + + beforeEach(() => { + vi.resetAllMocks() + mockToken('oldToken') + }) + + it('correctly parses response', async () => { + server.use(successfullCsrf) + + const token = await fetchRequestToken() + expect(token).toBe('new-token') + }) + + it('sets the token', async () => { + server.use(successfullCsrf) + + await fetchRequestToken() + expect(getRequestToken()).toBe('new-token') + }) + + it('does emit an event', async () => { + server.use(successfullCsrf) + + await fetchRequestToken() + expect(eventbus.emit).toHaveBeenCalledOnce() + expect(eventbus.emit).toBeCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' })) + }) + + it('handles 403 error due to invalid cookies', async () => { + server.use(forbiddenCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles server error', async () => { + server.use(serverErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles network error', async () => { + server.use(networkErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrow() + expect(getRequestToken()).toBe('oldToken') + }) +}) + +/** + * Mock the request token directly so we can it reading it. + * + * @param token - The CSRF token to mock + * @param internalToken - The internal (cached version of the DOM token) token to mock, if null the `token` will be used as internal token + */ +function mockToken(token?: string, internalToken: string | undefined | null = null) { + if (token === undefined) { + delete document.head.dataset.requesttoken + } else { + document.head.dataset.requesttoken = token + } + + if (internalToken === null) { + globalThis._nc_auth_requestToken = token + } else { + globalThis._nc_auth_requestToken = internalToken + } +} diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..a0b10871 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": [".", "../lib", "../*.d.ts"], +} diff --git a/tsconfig.json b/tsconfig.json index dc956383..6a9af061 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./lib"], + "include": ["./lib", "./*.d.ts"], "compilerOptions": { "allowSyntheticDefaultImports": true, "allowImportingTsExtensions": true, @@ -10,6 +10,7 @@ "lib": ["DOM", "ESNext"], "declaration": true, "strict": true, + "rootDir": "./lib", "outDir": "./dist" } } From 579679be150c634a30f50d447781dee312fd2183 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 3 Mar 2026 11:58:14 +0100 Subject: [PATCH 2/3] chore: use proper camelCase for names Signed-off-by: Ferdinand Thiessen --- globals.d.ts | 2 +- lib/csp-nonce.ts | 4 ++-- lib/index.ts | 4 ++-- lib/{requesttoken.ts => requestToken.ts} | 0 test/{requesttoken.test.ts => requestToken.test.ts} | 0 5 files changed, 5 insertions(+), 5 deletions(-) rename lib/{requesttoken.ts => requestToken.ts} (100%) rename test/{requesttoken.test.ts => requestToken.test.ts} (100%) diff --git a/globals.d.ts b/globals.d.ts index 637a4415..d6328bce 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -5,7 +5,7 @@ declare global { // eslint-disable-next-line camelcase - var _nc_auth_requesttoken: string | undefined + var _nc_auth_requestToken: string | undefined interface Window { _oc_isadmin?: boolean diff --git a/lib/csp-nonce.ts b/lib/csp-nonce.ts index 29831cea..ec8853fd 100644 --- a/lib/csp-nonce.ts +++ b/lib/csp-nonce.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { getRequestToken } from './requesttoken.ts' +import { getRequestToken } from './requestToken.ts' /** * Get the CSP nonce for script loading @@ -18,7 +18,7 @@ import { getRequestToken } from './requesttoken.ts' */ export function getCSPNonce(): string | undefined { const meta = document?.querySelector('meta[name="csp-nonce"]') - // backwards compatibility with older Nextcloud versions + // backwards compatibility with older Nextcloud versions (before Nextcloud 30) if (!meta) { const token = getRequestToken() return token ? btoa(token) : undefined diff --git a/lib/index.ts b/lib/index.ts index 72cf2243..cfcfd013 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -export type { CsrfTokenObserver } from './requesttoken.ts' +export type { CsrfTokenObserver } from './requestToken.ts' export type { NextcloudUser } from './user.ts' export { getCSPNonce } from './csp-nonce.ts' export { getGuestNickname, getGuestUser, setGuestNickname } from './guest.ts' -export { fetchRequestToken, getRequestToken, onRequestTokenUpdate, setRequestToken } from './requesttoken.ts' +export { fetchRequestToken, getRequestToken, onRequestTokenUpdate, setRequestToken } from './requestToken.ts' export { getCurrentUser } from './user.ts' diff --git a/lib/requesttoken.ts b/lib/requestToken.ts similarity index 100% rename from lib/requesttoken.ts rename to lib/requestToken.ts diff --git a/test/requesttoken.test.ts b/test/requestToken.test.ts similarity index 100% rename from test/requesttoken.test.ts rename to test/requestToken.test.ts From 669418f21ef6a77ba60190d94a7a56306af2dee6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 14 Apr 2026 16:42:10 +0200 Subject: [PATCH 3/3] test: fix vitests by running them in real browser to workaround Node.JS limitations Signed-off-by: Ferdinand Thiessen --- .github/workflows/node-test.yml | 2 + REUSE.toml | 2 +- lib/guest.ts | 11 ++ package-lock.json | 233 +++++++++++++++++++++----------- package.json | 2 +- test/csp-nonce.test.ts | 5 +- test/guest.test.ts | 65 ++------- test/requestToken.test.ts | 27 ++-- test/tsconfig.json | 3 + vitest.config.ts | 33 ++--- 10 files changed, 224 insertions(+), 159 deletions(-) diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 0c62487c..95636ed1 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -51,6 +51,8 @@ jobs: CYPRESS_INSTALL_BINARY: 0 run: | npm ci + # Install browsers for tests + npx playwright install chromium npm run build --if-present - name: Test diff --git a/REUSE.toml b/REUSE.toml index 6a77595a..481e25f8 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Nextcloud GmbH " SPDX-PackageDownloadLocation = "https://github.com/nextcloud-libraries/nextcloud-auth" [[annotations]] -path = ["package.json", "package-lock.json", "tsconfig.json"] +path = ["package.json", "package-lock.json", "tsconfig.json", "test/tsconfig.json"] precedence = "aggregate" SPDX-FileCopyrightText = "2019 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/lib/guest.ts b/lib/guest.ts index 34fa9bee..2065a6bd 100644 --- a/lib/guest.ts +++ b/lib/guest.ts @@ -74,6 +74,17 @@ export function setGuestNickname(nickname: string): void { getGuestUser().displayName = nickname } +/** + * Reset the guest user state. + * + * @internal + */ +export function resetGuestUser(): void { + currentUser = undefined + browserStorage.removeItem('guestUid') + browserStorage.removeItem('guestNickname') +} + /** * Generate a random UUID (version 4) if the crypto API is not available. * If the crypto API is available, it uses the less secure `randomUUID` method. diff --git a/package-lock.json b/package-lock.json index 8b79a7ea..b6eac7d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,9 @@ "@nextcloud/eslint-config": "^9.0.0-rc.9", "@nextcloud/typings": "^1.10.0", "@nextcloud/vite-config": "^2.5.2", + "@vitest/browser-playwright": "^4.1.4", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", - "happy-dom": "^20.8.9", "msw": "^2.13.2", "typedoc": "^0.28.18", "typescript": "^6.0.2", @@ -90,6 +90,13 @@ "node": ">=18" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.86.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", @@ -1636,6 +1643,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.29", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", @@ -2391,16 +2405,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "20.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", - "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@types/semver": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", @@ -2427,23 +2431,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/whatwg-mimetype": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", - "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", @@ -2743,6 +2730,53 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/browser": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.4.tgz", + "integrity": "sha512-TrNaY/yVOwxtrxNsDUC/wQ56xSwplpytTeRAqF/197xV/ZddxxulBsxR6TrhVMyniJmp9in8d5u0AcDaNRY30w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.4" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.4.tgz", + "integrity": "sha512-q3PchVhZINX23Pv+RERgAtDlp6wzVkID/smOPnZ5YGWpeWUe3jMNYppeVh15j4il3G7JIJty1d1Kicpm0HSMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.4", + "@vitest/mocker": "4.1.4", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.4" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", @@ -5042,37 +5076,6 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/happy-dom": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", - "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": ">=20.0.0", - "@types/whatwg-mimetype": "^3.0.2", - "@types/ws": "^8.18.1", - "entities": "^7.0.1", - "whatwg-mimetype": "^3.0.0", - "ws": "^8.18.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/happy-dom/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5917,6 +5920,16 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6421,6 +6434,66 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7139,6 +7212,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sort-object-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.1.0.tgz", @@ -7586,6 +7674,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -7778,13 +7876,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -8636,16 +8727,6 @@ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 82cc76e7..364d93fc 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,9 @@ "@nextcloud/eslint-config": "^9.0.0-rc.9", "@nextcloud/typings": "^1.10.0", "@nextcloud/vite-config": "^2.5.2", + "@vitest/browser-playwright": "^4.1.4", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", - "happy-dom": "^20.8.9", "msw": "^2.13.2", "typedoc": "^0.28.18", "typescript": "^6.0.2", diff --git a/test/csp-nonce.test.ts b/test/csp-nonce.test.ts index e68daf1c..94458d7a 100644 --- a/test/csp-nonce.test.ts +++ b/test/csp-nonce.test.ts @@ -2,14 +2,15 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -import { randomBytes } from 'crypto' + import { beforeEach, describe, expect, test, vi } from 'vitest' /** * Mock `` element with nonce */ function mockNonce() { - const nonce = randomBytes(16).toString('base64') + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)) + const nonce = bytes.toBase64() const el = document.createElement('meta') el.name = 'csp-nonce' el.nonce = nonce diff --git a/test/guest.test.ts b/test/guest.test.ts index d018ac9e..411c46dc 100644 --- a/test/guest.test.ts +++ b/test/guest.test.ts @@ -3,82 +3,47 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { getBuilder } from '@nextcloud/browser-storage' import { emit } from '@nextcloud/event-bus' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies -vi.mock('@nextcloud/browser-storage') -vi.mock('@nextcloud/event-bus') - -let tmpBrowserStorage = {} +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getGuestUser, resetGuestUser } from '../lib/guest.ts' // Mock browser storage -const mockBrowserStorage = { +let tmpBrowserStorage: Record = {} +const mockBrowserStorage = vi.hoisted(() => ({ getItem: vi.fn((key) => tmpBrowserStorage[key]), setItem: vi.fn((key, value) => { tmpBrowserStorage[key] = value }), removeItem: vi.fn((key) => { delete tmpBrowserStorage[key] }), -} +})) -// Mock crypto for UUID generation -const originalCrypto = globalThis.crypto -const mockCrypto = { - randomUUID: vi.fn(() => 'mock-uuid-' + Math.random().toString(36).slice(2, 10)), -} +// Mock dependencies +vi.mock('@nextcloud/event-bus', { spy: true }) +vi.mock('@nextcloud/browser-storage', () => ({ + getBuilder: vi.fn(() => ({ + persist: () => ({ build: () => mockBrowserStorage }), + })), +})) describe('Guest User Module', () => { beforeEach(() => { // Setup mocks vi.clearAllMocks() - vi.resetModules() + resetGuestUser() // Clear temporary browser storage tmpBrowserStorage = {} - - // Mock getBuilder to return our mockBrowserStorage - vi.mocked(getBuilder).mockReturnValue({ - persist: () => ({ - // @ts-expect-error Mocking builder - build: () => mockBrowserStorage, - }), - }) - - // Replace globalThis crypto with mock - Object.defineProperty(globalThis, 'crypto', { - value: mockCrypto, - writable: true, - }) - }) - - afterEach(() => { - // Restore original crypto - Object.defineProperty(globalThis, 'crypto', { - value: originalCrypto, - writable: true, - }) }) describe('getGuestUser', () => { it('should create a new guest user with default values when no storage exists', async () => { - const { getGuestUser } = await import('../lib/index.ts') const guestUser = getGuestUser() - const uid = guestUser.uid - - expect(guestUser.uid).toBeTruthy() + expect(guestUser.uid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) expect(guestUser.displayName).toBe('') expect(guestUser.isAdmin).toBe(false) expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(1, 'guestUid') expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(2, 'guestNickname') - expect(mockBrowserStorage.setItem).toHaveBeenCalledWith('guestUid', uid) - expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(3, 'guestUid') - - expect(guestUser.uid).toBe(uid) - expect(guestUser.displayName).toBe('') - expect(guestUser.isAdmin).toBe(false) - - expect(mockCrypto.randomUUID).toHaveBeenCalledOnce() + expect(mockBrowserStorage.setItem).toHaveBeenCalledWith('guestUid', guestUser.uid) }) it('should return the existing guest user if already created', async () => { diff --git a/test/requestToken.test.ts b/test/requestToken.test.ts index f5d9ca6f..fcfff28c 100644 --- a/test/requestToken.test.ts +++ b/test/requestToken.test.ts @@ -3,15 +3,15 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import * as eventbus from '@nextcloud/event-bus' import { http, HttpResponse } from 'msw' -import { setupServer } from 'msw/node' +import { setupWorker } from 'msw/browser' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { fetchRequestToken, getRequestToken, setRequestToken } from '../lib/requestToken.ts' -const eventbus = vi.hoisted(() => ({ emit: vi.fn(), subscribe: vi.fn() })) -vi.mock('@nextcloud/event-bus', () => eventbus) +vi.mock('@nextcloud/event-bus', { spy: true }) -const server = setupServer() +const server = setupWorker() describe('getRequestToken', () => { it('can read the token from DOM', () => { @@ -42,8 +42,8 @@ describe('setRequestToken', () => { it('does emit an event on change', () => { setRequestToken('new-token') - expect(eventbus.emit).toBeCalledTimes(1) - expect(eventbus.emit).toBeCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' })) + expect(eventbus.emit).toHaveBeenCalledTimes(1) + expect(eventbus.emit).toHaveBeenCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' })) }) it('does set the new token to the DOM', () => { @@ -86,9 +86,10 @@ describe('fetchRequestToken', () => { return new HttpResponse(null, { type: 'error' }) }) - beforeAll(() => { - server.listen() - ;(window as unknown as Record)._oc_webroot = '' + beforeAll(async () => { + // @ts-expect-error - mocking global variable for testing + globalThis._oc_webroot = '' + await server.start() }) beforeEach(() => { @@ -97,7 +98,7 @@ describe('fetchRequestToken', () => { }) it('correctly parses response', async () => { - server.use(successfullCsrf) + server.resetHandlers(successfullCsrf) const token = await fetchRequestToken() expect(token).toBe('new-token') @@ -115,14 +116,14 @@ describe('fetchRequestToken', () => { await fetchRequestToken() expect(eventbus.emit).toHaveBeenCalledOnce() - expect(eventbus.emit).toBeCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' })) + expect(eventbus.emit).toHaveBeenCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' })) }) it('handles 403 error due to invalid cookies', async () => { server.use(forbiddenCsrf) mockToken('oldToken') - await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + await expect(() => fetchRequestToken()).rejects.toThrow('Could not fetch CSRF token from API') expect(getRequestToken()).toBe('oldToken') }) @@ -130,7 +131,7 @@ describe('fetchRequestToken', () => { server.use(serverErrorCsrf) mockToken('oldToken') - await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + await expect(() => fetchRequestToken()).rejects.toThrow('Could not fetch CSRF token from API') expect(getRequestToken()).toBe('oldToken') }) diff --git a/test/tsconfig.json b/test/tsconfig.json index a0b10871..f532f482 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../tsconfig.json", "include": [".", "../lib", "../*.d.ts"], + "compilerOptions": { + "rootDir": ".." + } } diff --git a/vitest.config.ts b/vitest.config.ts index 96d330d7..6ba53310 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,22 +1,23 @@ -/** +/*! * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -import type { UserConfig } from 'vite' +import { playwright } from '@vitest/browser-playwright' +import { defineConfig } from 'vitest/config' -import viteConfig from './vite.config' - -export default async (env) => { - const config = typeof viteConfig === 'function' ? await viteConfig(env) : viteConfig - - return { - ...config, - test: { - environment: 'happy-dom', - coverage: { - reporter: ['text', 'lcov'], - }, +export default defineConfig({ + test: { + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [ + { browser: 'chromium' }, + ], + }, + coverage: { + reporter: ['text', 'lcov'], }, - } as UserConfig -} + }, +})